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:
+42
@@ -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
@@ -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.
|
||||
==================================================
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
flask>=3.0.0
|
||||
reportlab>=4.0.0
|
||||
Pillow>=10.0.0
|
||||
werkzeug>=3.0.0
|
||||
+424
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
@@ -0,0 +1,378 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Marine Maintenance{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--navy: #0a1628;
|
||||
--navy2: #0f2040;
|
||||
--steel: #1a3a5c;
|
||||
--ocean: #1e5f8a;
|
||||
--cyan: #00b4d8;
|
||||
--foam: #90e0ef;
|
||||
--white: #f0f4f8;
|
||||
--gray: #8a9bb0;
|
||||
--warn: #f4a261;
|
||||
--danger: #e63946;
|
||||
--success: #2ec4b6;
|
||||
--sidebar-w: 230px;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family: 'Barlow', sans-serif;
|
||||
background: var(--navy);
|
||||
color: var(--white);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
}
|
||||
/* SIDEBAR */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
min-height: 100vh;
|
||||
background: var(--navy2);
|
||||
border-right: 1px solid rgba(0,180,216,0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 24px 20px 20px;
|
||||
border-bottom: 1px solid rgba(0,180,216,0.15);
|
||||
}
|
||||
.sidebar-logo .logo-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sidebar-logo h1 {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--cyan);
|
||||
letter-spacing: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.sidebar-logo span {
|
||||
font-size: 11px;
|
||||
color: var(--gray);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sidebar-nav { flex: 1; padding: 16px 0; }
|
||||
.nav-section {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
color: var(--gray);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.nav-link:hover { color: var(--white); background: rgba(0,180,216,0.08); }
|
||||
.nav-link.active {
|
||||
color: var(--cyan);
|
||||
background: rgba(0,180,216,0.12);
|
||||
border-left-color: var(--cyan);
|
||||
}
|
||||
.nav-link .icon { font-size: 18px; width: 22px; text-align: center; }
|
||||
.sidebar-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid rgba(0,180,216,0.15);
|
||||
font-size: 11px;
|
||||
color: var(--gray);
|
||||
}
|
||||
/* MAIN */
|
||||
.main {
|
||||
margin-left: var(--sidebar-w);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.topbar {
|
||||
background: var(--navy2);
|
||||
border-bottom: 1px solid rgba(0,180,216,0.15);
|
||||
padding: 14px 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.topbar h2 {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.topbar-actions { display: flex; gap: 10px; align-items: center; }
|
||||
.content { padding: 28px; flex: 1; }
|
||||
/* BUTTONS */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 18px; border-radius: 6px; border: none;
|
||||
font-family: 'Barlow', sans-serif; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; text-decoration: none; transition: all 0.2s;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.btn-primary { background: var(--cyan); color: var(--navy); }
|
||||
.btn-primary:hover { background: var(--foam); }
|
||||
.btn-secondary { background: rgba(255,255,255,0.08); color: var(--white); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.btn-secondary:hover { background: rgba(255,255,255,0.14); }
|
||||
.btn-danger { background: var(--danger); color: white; }
|
||||
.btn-warning { background: var(--warn); color: var(--navy); }
|
||||
.btn-success { background: var(--success); color: var(--navy); }
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
/* CARDS */
|
||||
.card {
|
||||
background: var(--navy2);
|
||||
border: 1px solid rgba(0,180,216,0.12);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-size: 16px; font-weight: 600; letter-spacing: 1px;
|
||||
color: var(--cyan); margin-bottom: 16px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* STATS */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card {
|
||||
background: var(--navy2);
|
||||
border: 1px solid rgba(0,180,216,0.12);
|
||||
border-radius: 10px; padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.stat-label { font-size: 11px; color: var(--gray); text-transform: uppercase; letter-spacing: 1.5px; }
|
||||
.stat-value { font-family: 'Barlow Condensed', sans-serif; font-size: 36px; font-weight: 700; color: var(--cyan); }
|
||||
.stat-sub { font-size: 12px; color: var(--gray); }
|
||||
/* TABLES */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; padding: 10px 14px; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--gray); border-bottom: 1px solid rgba(0,180,216,0.15); font-weight: 600; }
|
||||
td { padding: 11px 14px; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
tr:hover td { background: rgba(0,180,216,0.04); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
/* BADGES */
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 20px;
|
||||
font-size: 11px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase;
|
||||
}
|
||||
.badge-open { background: rgba(0,180,216,0.2); color: var(--cyan); }
|
||||
.badge-in_progress { background: rgba(244,162,97,0.2); color: var(--warn); }
|
||||
.badge-completed { background: rgba(46,196,182,0.2); color: var(--success); }
|
||||
.badge-cancelled { background: rgba(255,255,255,0.1); color: var(--gray); }
|
||||
.badge-warn { background: rgba(244,162,97,0.2); color: var(--warn); }
|
||||
.badge-danger { background: rgba(230,57,70,0.2); color: var(--danger); }
|
||||
/* FORMS */
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-grid.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group.full { grid-column: 1 / -1; }
|
||||
label { font-size: 12px; color: var(--gray); text-transform: uppercase; letter-spacing: 1px; font-weight: 600; }
|
||||
input, select, textarea {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(0,180,216,0.2);
|
||||
border-radius: 6px; padding: 9px 12px; color: var(--white);
|
||||
font-family: 'Barlow', sans-serif; font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none; border-color: var(--cyan);
|
||||
background: rgba(0,180,216,0.08);
|
||||
}
|
||||
select option { background: var(--navy2); }
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
/* ALERTS */
|
||||
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
|
||||
.alert-warn { background: rgba(244,162,97,0.15); border: 1px solid rgba(244,162,97,0.3); color: var(--warn); }
|
||||
.alert-danger { background: rgba(230,57,70,0.15); border: 1px solid rgba(230,57,70,0.3); color: var(--danger); }
|
||||
/* GRID LAYOUTS */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
|
||||
/* PHOTO GRID */
|
||||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
|
||||
.photo-card {
|
||||
background: rgba(255,255,255,0.04); border-radius: 8px; overflow: hidden;
|
||||
border: 1px solid rgba(0,180,216,0.15); position: relative;
|
||||
}
|
||||
.photo-card img { width: 100%; height: 130px; object-fit: cover; display: block; }
|
||||
.photo-card .photo-label {
|
||||
padding: 6px 10px; font-size: 11px; font-weight: 600;
|
||||
letter-spacing: 1px; text-transform: uppercase;
|
||||
}
|
||||
.photo-card.before .photo-label { color: var(--warn); }
|
||||
.photo-card.after .photo-label { color: var(--success); }
|
||||
.photo-del {
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
background: rgba(0,0,0,0.7); border: none; color: white;
|
||||
border-radius: 50%; width: 24px; height: 24px;
|
||||
cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
/* MISC */
|
||||
.text-cyan { color: var(--cyan); }
|
||||
.text-warn { color: var(--warn); }
|
||||
.text-danger { color: var(--danger); }
|
||||
.text-success { color: var(--success); }
|
||||
.text-gray { color: var(--gray); }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mt-6 { margin-top: 24px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.flex { display: flex; align-items: center; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.section-title {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-size: 18px; font-weight: 600; letter-spacing: 1px;
|
||||
color: var(--cyan); margin-bottom: 14px; text-transform: uppercase;
|
||||
}
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.main { margin-left: 0; }
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||
.hamburger {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px; height: 38px;
|
||||
background: rgba(0,180,216,0.15);
|
||||
border: 1px solid rgba(0,180,216,0.3);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.overlay {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 998;
|
||||
}
|
||||
.overlay.open { display: block; }
|
||||
}
|
||||
.hamburger { display: none; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<div class="logo-icon">⚓</div>
|
||||
<h1>MARINE<br>MAINTENANCE</h1>
|
||||
<span>v1.0</span>
|
||||
</div>
|
||||
<div class="sidebar-nav">
|
||||
<div class="nav-section">Principal</div>
|
||||
<a href="{{ url_for('dashboard') }}" class="nav-link {% if request.endpoint=='dashboard' %}active{% endif %}">
|
||||
<span class="icon">📊</span> Dashboard
|
||||
</a>
|
||||
<div class="nav-section">Operaciones</div>
|
||||
<a href="{{ url_for('vessels') }}" class="nav-link {% if 'vessel' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">🚢</span> Embarcaciones
|
||||
</a>
|
||||
<a href="{{ url_for('work_orders') }}" class="nav-link {% if 'work_order' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">🔧</span> Órdenes de Trabajo
|
||||
</a>
|
||||
<a href="{{ url_for('systems') }}" class="nav-link {% if 'system' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">🔩</span> Sistemas
|
||||
</a>
|
||||
<a href="{{ url_for('ism_index') }}" class="nav-link {% if 'swp' in request.endpoint or 'msds' in request.endpoint or 'ism' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">🛡️</span> ISM / SWP
|
||||
</a>
|
||||
<div class="nav-section">Inventario</div>
|
||||
<a href="{{ url_for('inventory') }}" class="nav-link {% if 'inventory' in request.endpoint or 'part_' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">📦</span> Repuestos
|
||||
</a>
|
||||
<a href="{{ url_for('purchases') }}" class="nav-link {% if 'purchase' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">🛒</span> Compras
|
||||
</a>
|
||||
<a href="{{ url_for('suppliers') }}" class="nav-link {% if 'supplier' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">🏪</span> Proveedores
|
||||
</a>
|
||||
{% if current_user and current_user.role in ('superadmin', 'admin') %}
|
||||
<div class="nav-section">Administración</div>
|
||||
<a href="{{ url_for('companies') }}" class="nav-link {% if 'compan' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">🏢</span> Compañías
|
||||
</a>
|
||||
<a href="{{ url_for('users') }}" class="nav-link {% if 'user' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">👥</span> Usuarios
|
||||
</a>
|
||||
<a href="{{ url_for('email_settings') }}" class="nav-link {% if 'email' in request.endpoint %}active{% endif %}">
|
||||
<span class="icon">✉️</span> Config. Email
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<div style="font-size:12px;color:var(--white);font-weight:600;margin-bottom:2px">
|
||||
{{ current_user.full_name if current_user else '—' }}
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--cyan);margin-bottom:10px">
|
||||
{% if current_user %}
|
||||
{% if current_user.role == 'superadmin' %}⭐ Super Admin
|
||||
{% elif current_user.role == 'admin' %}🔑 Admin
|
||||
{% else %}🔧 Técnico{% endif %}
|
||||
{% if current_user.company_name %} · {{ current_user.company_name }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ url_for('auth_logout') }}" class="btn btn-secondary btn-sm" style="width:100%;text-align:center">
|
||||
🚪 Cerrar Sesión
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="sideOverlay" onclick="closeSidebar()"></div>
|
||||
<div class="main">
|
||||
<div class="topbar">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<button class="hamburger" id="hamburger" onclick="toggleSidebar()">☰</button>
|
||||
<h2>{% block page_title %}Dashboard{% endblock %}</h2>
|
||||
</div>
|
||||
<div class="topbar-actions">{% block topbar_actions %}{% endblock %}</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleSidebar() {
|
||||
document.querySelector('.sidebar').classList.toggle('open');
|
||||
document.getElementById('sideOverlay').classList.toggle('open');
|
||||
}
|
||||
function closeSidebar() {
|
||||
document.querySelector('.sidebar').classList.remove('open');
|
||||
document.getElementById('sideOverlay').classList.remove('open');
|
||||
}
|
||||
// Close sidebar when nav link clicked on mobile
|
||||
document.querySelectorAll('.nav-link').forEach(function(link) {
|
||||
link.addEventListener('click', function() {
|
||||
if (window.innerWidth <= 768) closeSidebar();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Compañías{% endblock %}
|
||||
{% block page_title %}Compañías{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('company_new') }}" class="btn btn-primary">+ Nueva Compañía</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px">
|
||||
{% for c in companies %}
|
||||
<div class="card" style="position:relative">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:14px">
|
||||
{% if c.logo_path %}
|
||||
<img src="/static/uploads/logos/{{ c.logo_path }}"
|
||||
style="width:56px;height:56px;object-fit:contain;border-radius:8px;
|
||||
background:white;padding:4px;border:1px solid rgba(0,180,216,0.2)">
|
||||
{% else %}
|
||||
<div style="width:56px;height:56px;border-radius:8px;background:rgba(0,180,216,0.1);
|
||||
display:flex;align-items:center;justify-content:center;font-size:24px">🏢</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div style="font-size:16px;font-weight:700;color:var(--white)">{{ c.name }}</div>
|
||||
<div style="font-size:12px;color:var(--cyan)">{{ c.vessel_count }} embarcación{{ 'es' if c.vessel_count != 1 else '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--gray);display:flex;flex-direction:column;gap:4px">
|
||||
{% if c.phone %}<span>📞 {{ c.phone }}</span>{% endif %}
|
||||
{% if c.email %}<span>✉️ {{ c.email }}</span>{% endif %}
|
||||
{% if c.address %}<span>📍 {{ c.address }}</span>{% endif %}
|
||||
{% if c.website %}<span>🌐 {{ c.website }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<a href="{{ url_for('vessels') }}?company={{ c.id }}" class="btn btn-sm btn-secondary">Ver embarcaciones</a>
|
||||
<a href="{{ url_for('company_edit', coid=c.id) }}" class="btn btn-sm btn-secondary">✏️ Editar</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="grid-column:1/-1;text-align:center;padding:50px;color:var(--gray)">
|
||||
<div style="font-size:40px;margin-bottom:12px">🏢</div>
|
||||
<div>No hay compañías registradas.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if company %}Editar{% else %}Nueva{% endif %} Compañía{% endblock %}
|
||||
{% block page_title %}{% if company %}Editar Compañía{% else %}Nueva Compañía{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('companies') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:640px">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
{% if company and company.logo_path %}
|
||||
<div style="margin-bottom:16px">
|
||||
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Logo actual</div>
|
||||
<img src="/static/uploads/logos/{{ company.logo_path }}"
|
||||
style="height:60px;object-fit:contain;background:white;padding:6px;border-radius:8px;border:1px solid rgba(0,180,216,0.2)">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>Nombre de la Compañía *</label>
|
||||
<input type="text" name="name" value="{{ company.name if company else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Logo (PNG o JPG — aparece en los reportes PDF)</label>
|
||||
<input type="file" name="logo" accept="image/*" style="padding:8px">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Teléfono</label>
|
||||
<input type="tel" name="phone" value="{{ company.phone if company else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" name="email" value="{{ company.email if company else '' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Dirección</label>
|
||||
<input type="text" name="address" value="{{ company.address if company else '' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Sitio Web</label>
|
||||
<input type="text" name="website" value="{{ company.website if company else '' }}" placeholder="https://...">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Notas</label>
|
||||
<textarea name="notes">{{ company.notes if company else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar</button>
|
||||
<a href="{{ url_for('companies') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Dashboard — Marine Maintenance{% endblock %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('work_order_new') }}" class="btn btn-primary">+ Nueva Orden</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">🚢 Embarcaciones</div>
|
||||
<div class="stat-value">{{ stats.vessels }}</div>
|
||||
<div class="stat-sub">registradas</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">🔧 Órdenes Activas</div>
|
||||
<div class="stat-value" style="color:var(--warn)">{{ stats.open_orders }}</div>
|
||||
<div class="stat-sub">abiertas / en progreso</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">📦 Stock Bajo</div>
|
||||
<div class="stat-value" style="color:{% if stats.low_stock > 0 %}var(--danger){% else %}var(--success){% endif %}">{{ stats.low_stock }}</div>
|
||||
<div class="stat-sub">repuestos bajo mínimo</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">✅ Completadas</div>
|
||||
<div class="stat-value" style="color:var(--success)">{{ stats.completed_this_month }}</div>
|
||||
<div class="stat-sub">este mes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header">🔧 Órdenes Recientes</div>
|
||||
{% if recent_orders %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Orden</th><th>Embarcación</th><th>Estado</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for o in recent_orders %}
|
||||
<tr>
|
||||
<td><span class="text-cyan">{{ o.order_number }}</span></td>
|
||||
<td>{{ o.vessel_name }}</td>
|
||||
<td><span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span></td>
|
||||
<td><a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray" style="font-size:13px">No hay órdenes aún.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% if low_stock_parts %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">⚠️ Stock Bajo</div>
|
||||
{% for p in low_stock_parts %}
|
||||
<div class="flex justify-between" style="padding:8px 0; border-bottom:1px solid rgba(255,255,255,0.05); font-size:13px">
|
||||
<span>{{ p.name }}</span>
|
||||
<span class="text-danger">{{ p.quantity }} {{ p.unit }} / mín {{ p.min_quantity }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if upcoming %}
|
||||
<div class="card">
|
||||
<div class="card-header">📅 Mantenimientos Próximos</div>
|
||||
{% for s in upcoming %}
|
||||
<div style="padding:8px 0; border-bottom:1px solid rgba(255,255,255,0.05); font-size:13px">
|
||||
<div class="flex justify-between">
|
||||
<span>{{ s.vessel_name }}</span>
|
||||
<span class="text-warn">{{ s.next_due_date }}</span>
|
||||
</div>
|
||||
<div class="text-gray" style="font-size:12px">{{ s.task_name }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,117 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Configuración Email{% endblock %}
|
||||
{% block page_title %}Configuración de Email{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;max-width:900px">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">⚙️ Servidor SMTP</div>
|
||||
<p style="font-size:12px;color:var(--gray);margin-bottom:16px">
|
||||
Configura el servidor de correo para enviar reportes por email.
|
||||
Funciona con Gmail, Outlook, Yahoo o cualquier servidor SMTP.
|
||||
</p>
|
||||
<form method="POST">
|
||||
<div class="form-group mb-3">
|
||||
<label>Servidor SMTP</label>
|
||||
<input type="text" name="smtp_host" value="{{ cfg.smtp_host if cfg else '' }}"
|
||||
placeholder="smtp.gmail.com">
|
||||
</div>
|
||||
<div class="form-grid mb-3">
|
||||
<div class="form-group">
|
||||
<label>Puerto</label>
|
||||
<select name="smtp_port">
|
||||
<option value="587" {% if not cfg or cfg.smtp_port==587 %}selected{% endif %}>587 (TLS — recomendado)</option>
|
||||
<option value="465" {% if cfg and cfg.smtp_port==465 %}selected{% endif %}>465 (SSL)</option>
|
||||
<option value="25" {% if cfg and cfg.smtp_port==25 %}selected{% endif %}>25 (sin cifrado)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Usar TLS</label>
|
||||
<select name="use_tls">
|
||||
<option value="1" {% if not cfg or cfg.use_tls %}selected{% endif %}>Sí</option>
|
||||
<option value="0" {% if cfg and not cfg.use_tls %}selected{% endif %}>No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Usuario (email de la cuenta)</label>
|
||||
<input type="email" name="smtp_user" value="{{ cfg.smtp_user if cfg else '' }}"
|
||||
placeholder="tu@gmail.com">
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Contraseña / App Password</label>
|
||||
<input type="password" name="smtp_password" value="{{ cfg.smtp_password if cfg else '' }}"
|
||||
placeholder="••••••••">
|
||||
<div style="font-size:11px;color:var(--gray);margin-top:4px">
|
||||
Para Gmail usa una <a href="https://myaccount.google.com/apppasswords" target="_blank" style="color:var(--cyan)">App Password</a>, no tu contraseña normal.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Nombre del Remitente</label>
|
||||
<input type="text" name="from_name" value="{{ cfg.from_name if cfg else '' }}"
|
||||
placeholder="Marine Maintenance Pro">
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Email del Remitente</label>
|
||||
<input type="email" name="from_email" value="{{ cfg.from_email if cfg else '' }}"
|
||||
placeholder="reportes@tuempresa.com">
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">🧪 Probar Configuración</div>
|
||||
<p style="font-size:12px;color:var(--gray);margin-bottom:12px">
|
||||
Envía un email de prueba para verificar que la configuración es correcta.
|
||||
</p>
|
||||
<div class="form-group mb-3">
|
||||
<label>Enviar prueba a</label>
|
||||
<input type="email" id="testEmail" placeholder="tu@email.com">
|
||||
</div>
|
||||
<button onclick="testEmail()" class="btn btn-secondary">📧 Enviar Prueba</button>
|
||||
<div id="testResult" style="margin-top:10px;font-size:13px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">📋 Guías Rápidas</div>
|
||||
<div style="font-size:12px;color:var(--gray);line-height:1.8">
|
||||
<div style="margin-bottom:10px">
|
||||
<strong style="color:var(--white)">Gmail:</strong><br>
|
||||
Host: smtp.gmail.com · Puerto: 587<br>
|
||||
Requiere App Password (2FA activado)
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<strong style="color:var(--white)">Outlook / Hotmail:</strong><br>
|
||||
Host: smtp-mail.outlook.com · Puerto: 587
|
||||
</div>
|
||||
<div>
|
||||
<strong style="color:var(--white)">Yahoo:</strong><br>
|
||||
Host: smtp.mail.yahoo.com · Puerto: 587
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function testEmail() {
|
||||
const to = document.getElementById('testEmail').value.trim();
|
||||
const res = document.getElementById('testResult');
|
||||
if (!to) { res.textContent = 'Ingresa un email.'; return; }
|
||||
res.textContent = 'Enviando...'; res.style.color = 'var(--gray)';
|
||||
fetch('/settings/email/test', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({to})
|
||||
}).then(r=>r.json()).then(d=>{
|
||||
if (d.ok) { res.textContent = '✅ Email enviado. Revisa tu bandeja.'; res.style.color='var(--success)'; }
|
||||
else { res.textContent = '❌ ' + d.error; res.style.color='var(--danger)'; }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if equip %}Editar{% else %}Nuevo{% endif %} Equipo — {{ vessel.name }}{% endblock %}
|
||||
{% block page_title %}{% if equip %}Editar Equipo{% else %}Nuevo Equipo{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('vessel_history', vid=vessel.id) }}" class="btn btn-secondary">← Volver</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:720px">
|
||||
<div style="margin-bottom:18px;font-size:13px;color:var(--gray)">
|
||||
Embarcación: <span class="text-cyan">{{ vessel.name }}</span>
|
||||
</div>
|
||||
<form method="POST">
|
||||
<div class="form-grid cols-3">
|
||||
<div class="form-group full">
|
||||
<label>Nombre del Equipo *</label>
|
||||
<input type="text" name="name" value="{{ equip.name if equip else '' }}" required
|
||||
placeholder="Ej: Motor Principal Estribor">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tipo de Equipo</label>
|
||||
<select name="equipment_type">
|
||||
<option value="">-- Seleccionar --</option>
|
||||
{% for t in [('engine','Motor'),('generator','Generador'),('pump','Bomba'),
|
||||
('hydraulic','Hidráulico'),('electrical','Eléctrico/Electrónico'),
|
||||
('hvac','HVAC / A/C'),('navigation','Navegación'),
|
||||
('safety','Seguridad'),('thruster','Thruster'),
|
||||
('watermaker','Water Maker'),('other','Otro')] %}
|
||||
<option value="{{ t[0] }}" {% if equip and equip.equipment_type==t[0] %}selected{% endif %}>{{ t[1] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Posición a Bordo</label>
|
||||
<select name="position">
|
||||
<option value="">-- Seleccionar --</option>
|
||||
{% for p in [('starboard','Estribor'),('port','Babor'),('center','Centro'),
|
||||
('forward','Proa'),('aft','Popa'),('engine_room','Sala de Máquinas')] %}
|
||||
<option value="{{ p[0] }}" {% if equip and equip.position==p[0] %}selected{% endif %}>{{ p[1] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Marca</label>
|
||||
<input type="text" name="make" value="{{ equip.make if equip else '' }}"
|
||||
placeholder="Ej: MTU, Caterpillar, Volvo">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Modelo</label>
|
||||
<input type="text" name="model" value="{{ equip.model if equip else '' }}"
|
||||
placeholder="Ej: 12V2000 M94">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Año</label>
|
||||
<input type="number" name="year" value="{{ equip.year if equip else '' }}" min="1950" max="2030">
|
||||
</div>
|
||||
<div class="form-group full" style="background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.25);border-radius:8px;padding:14px">
|
||||
<label style="color:var(--cyan)">⚠ Número de Serie (crítico)</label>
|
||||
<input type="text" name="serial_number"
|
||||
value="{{ equip.serial_number if equip else '' }}"
|
||||
placeholder="Ej: MTU-2024-12V-00432-S"
|
||||
style="border-color:rgba(0,180,216,0.4);margin-top:4px">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Horas Actuales</label>
|
||||
<input type="number" step="0.1" name="engine_hours"
|
||||
value="{{ equip.engine_hours if equip else '0' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Último Servicio (fecha)</label>
|
||||
<input type="date" name="last_service_date"
|
||||
value="{{ equip.last_service_date if equip else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Último Servicio (horas)</label>
|
||||
<input type="number" step="0.1" name="last_service_hours"
|
||||
value="{{ equip.last_service_hours if equip else '' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Notas</label>
|
||||
<textarea name="notes">{{ equip.notes if equip else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar</button>
|
||||
<a href="{{ url_for('vessel_history', vid=vessel.id) }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,140 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Inventario{% endblock %}
|
||||
{% block page_title %}Inventario{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('part_new') }}" class="btn btn-primary">+ Agregar Item</a>
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
<style>
|
||||
.inv-table { display:block; }
|
||||
.inv-cards { display:none; }
|
||||
@media (max-width:768px) {
|
||||
.inv-table { display:none; }
|
||||
.inv-cards { display:block; }
|
||||
}
|
||||
.icard {
|
||||
background:var(--navy2);border:1px solid rgba(0,180,216,0.12);
|
||||
border-radius:10px;margin-bottom:10px;overflow:hidden;
|
||||
}
|
||||
.icard-header {
|
||||
padding:10px 14px;background:rgba(0,0,0,0.2);
|
||||
border-bottom:1px solid rgba(255,255,255,0.05);
|
||||
display:flex;justify-content:space-between;align-items:center;
|
||||
}
|
||||
.icard-name { font-size:14px;font-weight:600;color:var(--white); }
|
||||
.icard-body { padding:10px 14px;font-size:13px; }
|
||||
.icard-meta { display:flex;gap:12px;flex-wrap:wrap;color:var(--gray);margin-bottom:8px; }
|
||||
.icard-stock { font-size:22px;font-weight:700;color:var(--cyan); }
|
||||
.icard-actions {
|
||||
display:flex;gap:8px;padding:8px 14px;
|
||||
border-top:1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.stock-low { color:var(--danger) !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center">
|
||||
<select id="catFilter" onchange="filterInv()"
|
||||
style="padding:7px 12px;border-radius:6px;background:rgba(255,255,255,0.06);
|
||||
border:1px solid rgba(0,180,216,0.25);color:var(--white);font-size:13px">
|
||||
<option value="">Todas las categorías</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.name|lower }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" id="invSearch" placeholder="🔍 Buscar repuesto o material..."
|
||||
oninput="filterInv()"
|
||||
style="flex:1;min-width:180px;padding:7px 12px;border-radius:6px;
|
||||
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
|
||||
color:var(--white);font-size:13px">
|
||||
</div>
|
||||
|
||||
<!-- TABLA DESKTOP -->
|
||||
<div class="inv-table card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Nombre</th><th>Categoría</th><th>N° Parte</th><th>Marca</th><th>Stock</th><th>Mín.</th><th>Precio</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in parts %}
|
||||
<tr class="inv-row" data-search="{{ (p.name ~ ' ' ~ (p.part_number or '') ~ ' ' ~ (p.brand or '') ~ ' ' ~ (p.category_name or ''))|lower }}" data-cat="{{ (p.category_name or '')|lower }}">
|
||||
<td>
|
||||
<strong>{{ p.name }}</strong>
|
||||
{% if p.description %}<br><span class="text-gray" style="font-size:11px">{{ p.description[:60] }}</span>{% endif %}
|
||||
</td>
|
||||
<td><span class="badge badge-open" style="font-size:11px">{{ p.category_name or '—' }}</span></td>
|
||||
<td class="text-gray" style="font-family:monospace;font-size:12px">{{ p.part_number or '—' }}</td>
|
||||
<td class="text-gray">{{ p.brand or '—' }}</td>
|
||||
<td>
|
||||
<span style="font-weight:600;{% if p.quantity <= p.min_quantity %}color:var(--danger){% else %}color:var(--success){% endif %}">
|
||||
{{ p.quantity }} {{ p.unit }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-gray">{{ p.min_quantity }}</td>
|
||||
<td>${{ "%.2f"|format(p.cost_price or 0) }}</td>
|
||||
<td class="flex gap-2">
|
||||
<a href="{{ url_for('part_edit', pid=p.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">Sin items en inventario.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TARJETAS MÓVIL -->
|
||||
<div class="inv-cards">
|
||||
{% for p in parts %}
|
||||
<div class="icard inv-row" data-search="{{ (p.name ~ ' ' ~ (p.part_number or '') ~ ' ' ~ (p.brand or '') ~ ' ' ~ (p.category_name or ''))|lower }}" data-cat="{{ (p.category_name or '')|lower }}">
|
||||
<div class="icard-header">
|
||||
<div>
|
||||
<div class="icard-name">{{ p.name }}</div>
|
||||
{% if p.part_number %}<div style="font-size:11px;color:var(--gray);font-family:monospace">{{ p.part_number }}</div>{% endif %}
|
||||
</div>
|
||||
<span class="badge badge-open" style="font-size:11px">{{ p.category_name or '—' }}</span>
|
||||
</div>
|
||||
<div class="icard-body">
|
||||
<div class="icard-meta">
|
||||
{% if p.brand %}<span>🏷️ {{ p.brand }}</span>{% endif %}
|
||||
{% if p.location %}<span>📍 {{ p.location }}</span>{% endif %}
|
||||
<span>💲{{ "%.2f"|format(p.cost_price or 0) }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:16px">
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--gray);margin-bottom:2px">Stock actual</div>
|
||||
<div class="icard-stock {% if p.quantity <= p.min_quantity %}stock-low{% endif %}">
|
||||
{{ p.quantity }} <span style="font-size:14px">{{ p.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--gray);margin-bottom:2px">Mínimo</div>
|
||||
<div style="font-size:16px;color:var(--gray)">{{ p.min_quantity }} {{ p.unit }}</div>
|
||||
</div>
|
||||
{% if p.quantity <= p.min_quantity %}
|
||||
<span style="color:var(--danger);font-size:12px;font-weight:600">⚠️ Stock bajo</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="icard-actions">
|
||||
<a href="{{ url_for('part_edit', pid=p.id) }}" class="btn btn-sm btn-secondary" style="flex:1;text-align:center">✏️ Editar</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:40px;color:var(--gray)">Sin items en inventario.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function filterInv() {
|
||||
const q = (document.getElementById('invSearch').value || '').toLowerCase().trim();
|
||||
const cat = (document.getElementById('catFilter').value || '').toLowerCase();
|
||||
document.querySelectorAll('.inv-row').forEach(r => {
|
||||
const matchQ = !q || r.dataset.search.includes(q);
|
||||
const matchC = !cat || r.dataset.cat.includes(cat);
|
||||
r.style.display = (matchQ && matchC) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}ISM — Procedimientos{% endblock %}
|
||||
{% block page_title %}ISM — Procedimientos de Trabajo Seguro{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('swp_new') }}" class="btn btn-primary">+ Nuevo SWP</a>
|
||||
<a href="{{ url_for('msds_index') }}" class="btn btn-secondary">📋 MSDS</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Filtros -->
|
||||
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;align-items:center">
|
||||
{% if is_superadmin and companies %}
|
||||
<select onchange="location.href='{{ url_for('ism_index') }}?company_id='+this.value"
|
||||
style="padding:7px 12px;border-radius:6px;background:rgba(255,255,255,0.06);
|
||||
border:1px solid rgba(0,180,216,0.25);color:var(--white);font-size:13px">
|
||||
<option value="">🏢 Todas las compañías</option>
|
||||
{% for c in companies %}
|
||||
<option value="{{ c.id }}" {% if current_company==c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
<input type="text" id="swpSearch" placeholder="🔍 Buscar procedimiento..."
|
||||
oninput="filterSWP(this.value)"
|
||||
style="flex:1;min-width:200px;padding:7px 12px;border-radius:6px;
|
||||
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
|
||||
color:var(--white);font-size:13px">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{% if is_superadmin %}<th>Compañía</th>{% endif %}
|
||||
<th>Código</th><th>Título</th><th>Categoría</th>
|
||||
<th>Versión</th><th>Estado versión</th><th>Vigente desde</th><th>Aprobado por</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in swps %}
|
||||
<tr class="swp-row" data-search="{{ (s.code ~ ' ' ~ s.title ~ ' ' ~ s.category ~ ' ' ~ (s.company_name or ''))|lower }}">
|
||||
{% if is_superadmin %}
|
||||
<td class="text-gray" style="font-size:12px">{{ s.company_name or '—' }}</td>
|
||||
{% endif %}
|
||||
<td class="text-cyan" style="font-family:monospace;font-weight:600">{{ s.code }}</td>
|
||||
<td><strong>{{ s.title }}</strong></td>
|
||||
<td>
|
||||
{% set cat_map = {'electrical':'⚡ Eléctrico','mechanical':'⚙️ Mecánico',
|
||||
'chemical':'🧪 Químicos','confined':'🔒 Esp. Confinado',
|
||||
'height':'⚓ Altura','welding':'🔥 Soldadura',
|
||||
'hull':'🚢 Casco','other':'📋 Otro'} %}
|
||||
<span style="font-size:12px">{{ cat_map.get(s.category, s.category) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if s.version %}
|
||||
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">{{ s.version }}</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if s.ver_status == 'active' %}
|
||||
<span class="badge badge-completed">✅ Aprobada</span>
|
||||
{% elif s.ver_status == 'draft' %}
|
||||
<span class="badge badge-open">📝 Borrador</span>
|
||||
{% elif s.ver_status == 'superseded' %}
|
||||
<span class="badge" style="background:rgba(138,155,176,0.2);color:var(--gray)">Sup.</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-gray" style="font-size:12px">{{ s.effective_date or '—' }}</td>
|
||||
<td class="text-gray" style="font-size:12px">{{ s.approved_by or '—' }}</td>
|
||||
<td class="flex gap-2" style="white-space:nowrap">
|
||||
<a href="{{ url_for('swp_detail', sid=s.id) }}" class="btn btn-sm btn-secondary">Ver</a>
|
||||
{% if s.ver_status == 'draft' and s.ver_id %}
|
||||
<a href="{{ url_for('swp_edit_version', sid=s.id, vid=s.ver_id) }}" class="btn btn-sm btn-warning">✏️</a>
|
||||
{% endif %}
|
||||
{% if s.ver_status == 'active' %}
|
||||
<a href="{{ url_for('swp_pdf', sid=s.id) }}?lang=es" target="_blank" class="btn btn-sm btn-primary">📄 ES</a>
|
||||
<a href="{{ url_for('swp_pdf', sid=s.id) }}?lang=en" target="_blank" class="btn btn-sm btn-secondary">📄 EN</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="text-gray" style="text-align:center;padding:30px">
|
||||
Sin procedimientos. <a href="{{ url_for('swp_new') }}" style="color:var(--cyan)">Crear el primero</a>
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function filterSWP(q) {
|
||||
q = q.toLowerCase();
|
||||
document.querySelectorAll('.swp-row').forEach(r => {
|
||||
r.style.display = (!q || r.dataset.search.includes(q)) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,153 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Marine Maintenance — Login</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family: 'Barlow', sans-serif;
|
||||
background: #0a1628;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* fondo decorativo */
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 600px; height: 600px;
|
||||
background: radial-gradient(circle, rgba(0,180,216,0.08) 0%, transparent 70%);
|
||||
top: -100px; right: -100px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 400px; height: 400px;
|
||||
background: radial-gradient(circle, rgba(0,180,216,0.05) 0%, transparent 70%);
|
||||
bottom: -80px; left: -80px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.login-box {
|
||||
background: #0f2040;
|
||||
border: 1px solid rgba(0,180,216,0.2);
|
||||
border-radius: 16px;
|
||||
padding: 48px 44px;
|
||||
width: 400px;
|
||||
max-width: 95vw;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.logo-area {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.logo-icon { font-size: 42px; margin-bottom: 10px; }
|
||||
h1 {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #00b4d8;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #8a9bb0;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.form-group { margin-bottom: 18px; }
|
||||
label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #8a9bb0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(0,180,216,0.25);
|
||||
border-radius: 8px;
|
||||
padding: 11px 14px;
|
||||
color: #f0f4f8;
|
||||
font-family: 'Barlow', sans-serif;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #00b4d8;
|
||||
background: rgba(0,180,216,0.08);
|
||||
}
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
background: #00b4d8;
|
||||
color: #0a1628;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-family: 'Barlow', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-login:hover { background: #90e0ef; }
|
||||
.error {
|
||||
background: rgba(230,57,70,0.15);
|
||||
border: 1px solid rgba(230,57,70,0.3);
|
||||
color: #e63946;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.version {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: rgba(138,155,176,0.5);
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<div class="logo-area">
|
||||
<div class="logo-icon">⚓</div>
|
||||
<h1>Marine Maintenance</h1>
|
||||
<div class="subtitle">Sistema de Gestión</div>
|
||||
</div>
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label>Usuario</label>
|
||||
<input type="text" name="username" autocomplete="username"
|
||||
value="{{ username or '' }}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Contraseña</label>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">Ingresar →</button>
|
||||
</form>
|
||||
<div class="version">Marine Maintenance Pro v2.0 · Puerto 5500</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if msds %}Editar{% else %}Nueva{% endif %} MSDS{% endblock %}
|
||||
{% block page_title %}{% if msds %}Editar MSDS — {{ msds.product_name }}{% else %}Nueva Ficha MSDS{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('msds_index') }}" class="btn btn-secondary">← Volver</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:820px">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Nombre del Producto *</label>
|
||||
<input type="text" name="product_name" required value="{{ msds.product_name if msds else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Fabricante</label>
|
||||
<input type="text" name="manufacturer" value="{{ msds.manufacturer if msds else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Clase de Peligro (GHS)</label>
|
||||
<select name="hazard_class">
|
||||
<option value="">— Seleccionar —</option>
|
||||
{% for cls in ['GHS01 Explosivo','GHS02 Inflamable','GHS03 Oxidante','GHS04 Gas comprimido','GHS05 Corrosivo','GHS06 Tóxico','GHS07 Nocivo/Irritante','GHS08 Peligro salud','GHS09 Peligro ambiental'] %}
|
||||
<option value="{{ cls }}" {% if msds and msds.hazard_class==cls %}selected{% endif %}>{{ cls }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Repuesto Vinculado (opcional)</label>
|
||||
<select name="part_id">
|
||||
<option value="">— Sin vincular —</option>
|
||||
{% for p in parts %}
|
||||
<option value="{{ p.id }}" {% if msds and msds.part_id==p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Versión</label>
|
||||
<input type="text" name="version" value="{{ msds.version if msds else 'v1.0' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Riesgos y Peligros</label>
|
||||
<textarea name="hazards" rows="3">{{ msds.hazards if msds else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Primeros Auxilios</label>
|
||||
<textarea name="first_aid" rows="3">{{ msds.first_aid if msds else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>EPP Requerido</label>
|
||||
<textarea name="ppe_required" rows="2">{{ msds.ppe_required if msds else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Manejo</label>
|
||||
<textarea name="handling" rows="2">{{ msds.handling if msds else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Almacenamiento</label>
|
||||
<textarea name="storage" rows="2">{{ msds.storage if msds else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Procedimiento de derrame</label>
|
||||
<textarea name="spill_procedure" rows="2">{{ msds.spill_procedure if msds else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Disposición / Desecho</label>
|
||||
<textarea name="disposal" rows="2">{{ msds.disposal if msds else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Referencias (OSHA, SDS, etc.)</label>
|
||||
<textarea name="ref_standards" rows="2">{{ msds.ref_standards if msds else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>PDF Oficial del Fabricante</label>
|
||||
<input type="file" name="pdf_file" accept=".pdf" style="padding:8px">
|
||||
{% if msds and msds.pdf_filename %}
|
||||
<div style="margin-top:6px;font-size:12px;color:var(--gray)">
|
||||
Actual: <a href="/static/uploads/docs/{{ msds.pdf_filename }}" target="_blank" style="color:var(--cyan)">{{ msds.pdf_filename }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar MSDS</button>
|
||||
<a href="{{ url_for('msds_index') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}MSDS — Fichas Técnicas{% endblock %}
|
||||
{% block page_title %}Fichas de Seguridad (MSDS){% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('msds_new') }}" class="btn btn-primary">+ Nueva MSDS</a>
|
||||
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">← ISM</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Producto</th><th>Fabricante</th><th>Clase GHS</th><th>Versión</th><th>Repuesto vinculado</th><th>PDF</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in msds_list %}
|
||||
<tr>
|
||||
<td><strong>{{ m.product_name }}</strong></td>
|
||||
<td class="text-gray">{{ m.manufacturer or '—' }}</td>
|
||||
<td>
|
||||
{% if m.hazard_class %}
|
||||
<span class="badge" style="background:rgba(230,57,70,0.15);color:var(--danger)">{{ m.hazard_class }}</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td><span class="badge badge-open" style="font-size:11px">{{ m.version }}</span></td>
|
||||
<td class="text-gray" style="font-size:12px">{{ m.part_name or '—' }}</td>
|
||||
<td>
|
||||
{% if m.pdf_filename %}
|
||||
<a href="/static/uploads/docs/{{ m.pdf_filename }}" target="_blank" class="btn btn-sm btn-primary">📄</a>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('msds_edit', mid=m.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-gray" style="text-align:center;padding:30px">
|
||||
Sin fichas MSDS. <a href="{{ url_for('msds_new') }}" style="color:var(--cyan)">Agregar primera</a>
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if part %}Editar{% else %}Nuevo{% endif %} Repuesto{% endblock %}
|
||||
{% block page_title %}{% if part %}Editar Repuesto{% else %}Nuevo Repuesto{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('inventory') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:700px">
|
||||
<form method="POST">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>Nombre *</label>
|
||||
<input type="text" name="name" value="{{ part.name if part else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Número de Parte</label>
|
||||
<input type="text" name="part_number" value="{{ part.part_number if part else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Categoría</label>
|
||||
<select name="category_id">
|
||||
<option value="">-- Sin categoría --</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if part and part.category_id==c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Marca</label>
|
||||
<input type="text" name="brand" value="{{ part.brand if part else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ubicación en Taller</label>
|
||||
<input type="text" name="location" value="{{ part.location if part else '' }}" placeholder="Ej: Estante A-3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Cantidad</label>
|
||||
<input type="number" step="0.01" name="quantity" value="{{ part.quantity if part else '0' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Unidad</label>
|
||||
<select name="unit">
|
||||
{% for u in ['pcs','ft','m','gal','L','qt','kg','lb','set','pair','roll','box'] %}
|
||||
<option value="{{ u }}" {% if part and part.unit==u %}selected{% endif %}>{{ u }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stock Mínimo (alerta)</label>
|
||||
<input type="number" step="0.01" name="min_quantity" value="{{ part.min_quantity if part else '0' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Precio Costo ($)</label>
|
||||
<input type="number" step="0.01" name="cost_price" value="{{ part.cost_price if part else '0' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Precio Venta ($)</label>
|
||||
<input type="number" step="0.01" name="sale_price" value="{{ part.sale_price if part else '0' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Descripción</label>
|
||||
<textarea name="description">{{ part.description if part else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Notas</label>
|
||||
<textarea name="notes">{{ part.notes if part else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar</button>
|
||||
<a href="{{ url_for('inventory') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Compra #{{ purchase.id }}{% endblock %}
|
||||
{% block page_title %}Compra — {{ purchase.purchase_date }}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('purchases') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">📋 Detalles</div>
|
||||
<table style="font-size:13px">
|
||||
<tr><td style="color:var(--gray);padding:6px 0;width:140px">Proveedor</td><td>{{ purchase.supplier_name or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Factura</td><td>{{ purchase.invoice_number or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Fecha</td><td>{{ purchase.purchase_date }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Total</td><td class="text-cyan">${{ "%.2f"|format(purchase.total_amount or 0) }}</td></tr>
|
||||
</table>
|
||||
{% if purchase.notes %}<p style="font-size:13px;color:var(--gray);margin-top:10px">{{ purchase.notes }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between">
|
||||
<span>📦 Ítems Comprados</span>
|
||||
<button onclick="document.getElementById('itemModal').style.display='flex'" class="btn btn-sm btn-primary">+ Agregar Ítem</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Repuesto</th><th>Descripción</th><th>Cantidad</th><th>Costo Unit.</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{% for i in items %}
|
||||
<tr>
|
||||
<td>{{ i.part_name or '—' }}</td>
|
||||
<td class="text-gray">{{ i.description or '' }}</td>
|
||||
<td>{{ i.quantity }}</td>
|
||||
<td>${{ "%.2f"|format(i.unit_cost) }}</td>
|
||||
<td class="text-cyan">${{ "%.2f"|format(i.total_cost) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-gray" style="text-align:center;padding:20px">Sin ítems.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="itemModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
|
||||
<div class="card" style="width:460px;max-width:95vw">
|
||||
<div class="card-header">📦 Agregar Ítem</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Repuesto del Inventario</label>
|
||||
<select id="itemPart">
|
||||
<option value="">-- Sin inventario (manual) --</option>
|
||||
{% for p in parts %}
|
||||
<option value="{{ p.id }}">{{ p.name }} {% if p.part_number %}({{ p.part_number }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Descripción (si es manual)</label>
|
||||
<input type="text" id="itemDesc">
|
||||
</div>
|
||||
<div class="form-grid mb-4">
|
||||
<div class="form-group">
|
||||
<label>Cantidad</label>
|
||||
<input type="number" id="itemQty" value="1" step="0.01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Costo Unitario ($)</label>
|
||||
<input type="number" id="itemCost" value="0" step="0.01">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="addItem()" class="btn btn-primary">➕ Agregar</button>
|
||||
<button onclick="document.getElementById('itemModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function addItem() {
|
||||
const data = {
|
||||
part_id: document.getElementById('itemPart').value || null,
|
||||
description: document.getElementById('itemDesc').value,
|
||||
quantity: document.getElementById('itemQty').value,
|
||||
unit_cost: document.getElementById('itemCost').value
|
||||
};
|
||||
fetch(`/purchases/{{ purchase.id }}/add-item`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
}).then(() => location.reload());
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Nueva Compra{% endblock %}
|
||||
{% block page_title %}Nueva Compra{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('purchases') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:600px">
|
||||
<form method="POST">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Proveedor</label>
|
||||
<select name="supplier_id">
|
||||
<option value="">-- Sin proveedor --</option>
|
||||
{% for s in suppliers %}
|
||||
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Fecha *</label>
|
||||
<input type="date" name="purchase_date" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Número de Factura</label>
|
||||
<input type="text" name="invoice_number">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Notas</label>
|
||||
<textarea name="notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Crear y Agregar Ítems</button>
|
||||
<a href="{{ url_for('purchases') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>document.querySelector('[name=purchase_date]').value = new Date().toISOString().split('T')[0];</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Compras{% endblock %}
|
||||
{% block page_title %}Compras de Materiales{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('purchase_new') }}" class="btn btn-primary">+ Nueva Compra</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Fecha</th><th>Proveedor</th><th>Factura</th><th>Total</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in purchases %}
|
||||
<tr>
|
||||
<td>{{ p.purchase_date }}</td>
|
||||
<td>{{ p.supplier_name or 'Sin proveedor' }}</td>
|
||||
<td class="text-gray">{{ p.invoice_number or '—' }}</td>
|
||||
<td class="text-cyan">${{ "%.2f"|format(p.total_amount or 0) }}</td>
|
||||
<td><a href="{{ url_for('purchase_detail', pid=p.id) }}" class="btn btn-sm btn-secondary">Ver</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-gray" style="text-align:center;padding:30px">Sin compras registradas.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Nuevo Proveedor{% endblock %}
|
||||
{% block page_title %}Nuevo Proveedor{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('suppliers') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:600px">
|
||||
<form method="POST">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>Nombre *</label>
|
||||
<input type="text" name="name" required value="{{ supplier.name if supplier else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Contacto</label>
|
||||
<input type="text" name="contact_name" value="{{ supplier.contact_name if supplier else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Teléfono</label>
|
||||
<input type="tel" name="phone" value="{{ supplier.phone if supplier else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" name="email" value="{{ supplier.email if supplier else '' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Dirección</label>
|
||||
<input type="text" name="address" value="{{ supplier.address if supplier else '' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Notas</label>
|
||||
<textarea name="notes">{{ supplier.notes if supplier else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar</button>
|
||||
<a href="{{ url_for('suppliers') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Proveedores{% endblock %}
|
||||
{% block page_title %}Proveedores{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('supplier_new') }}" class="btn btn-primary">+ Nuevo Proveedor</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Nombre</th><th>Contacto</th><th>Teléfono</th><th>Email</th><th>Dirección</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in suppliers %}
|
||||
<tr>
|
||||
<td><strong>{{ s.name }}</strong></td>
|
||||
<td>{{ s.contact_name or '—' }}</td>
|
||||
<td>{{ s.phone or '—' }}</td>
|
||||
<td>{{ s.email or '—' }}</td>
|
||||
<td class="text-gray" style="font-size:12px">{{ s.address or '—' }}</td>
|
||||
<td class="flex gap-2">
|
||||
<a href="{{ url_for('supplier_edit', sid=s.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
<button onclick="deleteSupplier({{ s.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="text-gray" style="text-align:center;padding:30px">Sin proveedores.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function deleteSupplier(id, row) {
|
||||
if (!confirm('¿Eliminar este proveedor? Solo si no tiene órdenes de compra asociadas.')) return;
|
||||
fetch('/suppliers/' + id + '/delete', {method: 'DELETE'})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) row.remove();
|
||||
else alert('No se puede eliminar: ' + (d.error || 'tiene registros asociados'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,171 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ swp.code }} — {{ swp.title }}{% endblock %}
|
||||
{% block page_title %}{{ swp.code }} — {{ swp.title }}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('swp_pdf', sid=swp.id) }}?lang=es" target="_blank" class="btn btn-primary">📄 PDF ES</a>
|
||||
<a href="{{ url_for('swp_pdf', sid=swp.id) }}?lang=en" target="_blank" class="btn btn-secondary">📄 PDF EN</a>
|
||||
{% if current and current.status == 'draft' %}
|
||||
<a href="{{ url_for('swp_edit_version', sid=swp.id, vid=current.id) }}" class="btn btn-warning">✏️ Editar Borrador</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('swp_edit', sid=swp.id) }}" class="btn btn-secondary">✏️ Editar</a>
|
||||
<a href="{{ url_for('swp_new_version', sid=swp.id) }}" class="btn btn-secondary">📝 Nueva Versión</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">← Volver</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if current and current.status == 'draft' %}
|
||||
<div style="background:rgba(244,162,97,0.12);border:1px solid rgba(244,162,97,0.4);border-radius:8px;padding:12px 16px;margin-bottom:16px;display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<span style="color:var(--warning);font-weight:600">📝 Versión {{ current.version }} pendiente de aprobación</span>
|
||||
<span style="color:var(--gray);font-size:12px;margin-left:10px">Creada por {{ current.created_by }} · {{ current.created_at[:10] }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('swp_edit_version', sid=swp.id, vid=current.id) }}" class="btn btn-sm btn-warning">✏️ Editar</a>
|
||||
<button onclick="approveVersion({{ current.id }})" class="btn btn-sm btn-success">✅ Aprobar ahora</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current %}
|
||||
{% set hazards = json.loads(current.hazards or '[]') %}
|
||||
{% set ppe = json.loads(current.ppe or '[]') %}
|
||||
{% set tools = json.loads(current.tools or '[]') %}
|
||||
{% set steps = json.loads(current.steps or '[]') %}
|
||||
{% set refs = json.loads(current.ref_standards or '[]') %}
|
||||
|
||||
<!-- META -->
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="card" style="padding:16px">
|
||||
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Información</div>
|
||||
<table style="width:100%;font-size:13px">
|
||||
<tr><td style="color:var(--gray);padding:4px 0;width:40%">Código</td><td style="color:var(--cyan);font-weight:700;font-family:monospace">{{ swp.code }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:4px 0">Versión activa</td><td><span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">{{ current.version }}</span></td></tr>
|
||||
<tr><td style="color:var(--gray);padding:4px 0">Categoría</td><td>{{ categories.get(swp.category, swp.category) }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:4px 0">Vigente desde</td><td>{{ current.effective_date or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:4px 0">Aprobado por</td><td>{{ current.approved_by or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:4px 0">Creado por</td><td>{{ current.created_by or '—' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card" style="padding:16px">
|
||||
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Propósito y Alcance</div>
|
||||
{% if current.purpose %}<p style="font-size:13px;margin-bottom:8px">{{ current.purpose }}</p>{% endif %}
|
||||
{% if current.scope %}<p style="font-size:13px;color:var(--gray)">{{ current.scope }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIESGOS + EPP -->
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="card" style="padding:16px;border-left:3px solid var(--danger)">
|
||||
<div style="font-size:10px;color:var(--danger);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">⚠️ Riesgos Identificados</div>
|
||||
{% for h in hazards %}
|
||||
<div style="display:flex;gap:8px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px">
|
||||
<span style="color:var(--danger)">●</span> {{ h }}
|
||||
</div>
|
||||
{% else %}<p class="text-gray" style="font-size:13px">—</p>{% endfor %}
|
||||
</div>
|
||||
<div class="card" style="padding:16px;border-left:3px solid var(--warning)">
|
||||
<div style="font-size:10px;color:var(--warning);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">🦺 EPP Requerido</div>
|
||||
{% for p in ppe %}
|
||||
<div style="display:flex;gap:8px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px">
|
||||
<span style="color:var(--warning)">✓</span> {{ p }}
|
||||
</div>
|
||||
{% else %}<p class="text-gray" style="font-size:13px">—</p>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HERRAMIENTAS -->
|
||||
{% if tools %}
|
||||
<div class="card mb-4" style="padding:16px;border-left:3px solid #7b2d8b">
|
||||
<div style="font-size:10px;color:#7b2d8b;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">🔧 Herramientas y Materiales</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
{% for t in tools %}
|
||||
<span style="background:rgba(123,45,139,0.1);border:1px solid rgba(123,45,139,0.3);color:#b57bee;padding:4px 10px;border-radius:20px;font-size:12px">{{ t }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- PASOS -->
|
||||
<div class="card mb-4" style="padding:16px;border-left:3px solid var(--cyan)">
|
||||
<div style="font-size:10px;color:var(--cyan);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px">📋 Pasos del Procedimiento</div>
|
||||
{% for step in steps %}
|
||||
<div style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px">
|
||||
<span style="color:var(--cyan);font-weight:700;min-width:24px">{{ loop.index }}.</span>
|
||||
<span>{{ step }}</span>
|
||||
</div>
|
||||
{% else %}<p class="text-gray" style="font-size:13px">Sin pasos definidos.</p>{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- EMERGENCIA -->
|
||||
{% if current.emergency %}
|
||||
<div class="card mb-4" style="padding:16px;border:1px solid var(--warning);background:rgba(244,162,97,0.05)">
|
||||
<div style="font-size:10px;color:var(--warning);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">🚨 Medidas de Emergencia</div>
|
||||
<p style="font-size:13px">{{ current.emergency }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- REFERENCIAS -->
|
||||
{% if refs %}
|
||||
<div class="card mb-4" style="padding:16px">
|
||||
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px">📚 Referencias y Normativa</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
{% for r in refs %}
|
||||
<span style="background:rgba(0,180,216,0.1);border:1px solid rgba(0,180,216,0.2);color:var(--cyan);padding:4px 10px;border-radius:20px;font-size:12px">{{ r }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="card" style="padding:40px;text-align:center;color:var(--gray)">
|
||||
Sin versión activa. <a href="{{ url_for('swp_new_version', sid=swp.id) }}" style="color:var(--cyan)">Crear primera versión</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- HISTORIAL DE VERSIONES -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">📚 Historial de Versiones</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Versión</th><th>Estado</th><th>Motivo</th><th>Diferencias</th><th>Creado por</th><th>Aprobado por</th><th>Vigente desde</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in versions %}
|
||||
<tr>
|
||||
<td><span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan);font-family:monospace">{{ v.version }}</span></td>
|
||||
<td>
|
||||
{% if v.status == 'active' %}<span class="badge badge-completed">Activa</span>
|
||||
{% elif v.status == 'draft' %}<span class="badge badge-open">Borrador</span>
|
||||
{% elif v.status == 'superseded' %}<span class="badge" style="background:rgba(138,155,176,0.2);color:var(--gray)">Supersedida</span>
|
||||
{% else %}<span class="badge badge-cancelled">Archivada</span>{% endif %}
|
||||
</td>
|
||||
<td style="font-size:12px">{{ v.change_reason or '—' }}</td>
|
||||
<td style="font-size:12px;color:var(--gray)">{{ v.diff_summary or '—' }}</td>
|
||||
<td style="font-size:12px">{{ v.created_by or '—' }}</td>
|
||||
<td style="font-size:12px">{{ v.approved_by or '—' }}</td>
|
||||
<td style="font-size:12px">{{ v.effective_date or '—' }}</td>
|
||||
<td>
|
||||
{% if v.status == 'draft' %}
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('swp_edit_version', sid=swp.id, vid=v.id) }}" class="btn btn-sm btn-warning">✏️</a>
|
||||
<button onclick="approveVersion({{ v.id }})" class="btn btn-sm btn-success">✅ Aprobar</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function approveVersion(vid) {
|
||||
if (!confirm('¿Aprobar esta versión? La versión actual quedará como supersedida.')) return;
|
||||
fetch('/ism/{{ swp.id }}/versions/' + vid + '/approve', {method:'POST'})
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.ok) location.reload(); else alert('Error: ' + d.error); });
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Editar — {{ swp.code }}{% endblock %}
|
||||
{% block page_title %}Editar datos — {{ swp.code }}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">← Cancelar</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card mb-3" style="padding:12px 16px;background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.2)">
|
||||
<div style="font-size:13px;color:var(--gray)">
|
||||
ℹ️ Edita código, título, categoría y compañía. Para cambiar el contenido (pasos, riesgos, EPP) usa
|
||||
<a href="{{ url_for('swp_detail', sid=swp.id) }}" style="color:var(--warning)">✏️ Editar Borrador</a> o
|
||||
<a href="{{ url_for('swp_new_version', sid=swp.id) }}" style="color:var(--cyan)">📝 Nueva Versión</a> desde el detalle del procedimiento.
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="max-width:640px">
|
||||
<form method="POST">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>Compañía *</label>
|
||||
<select name="company_id" required>
|
||||
<option value="">— Seleccionar —</option>
|
||||
{% for c in companies %}
|
||||
<option value="{{ c.id }}" {% if swp.company_id==c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Código <span style="color:var(--gray);font-size:11px">(solo corrección de errores tipográficos)</span></label>
|
||||
<input type="text" name="code" value="{{ swp.code }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Categoría *</label>
|
||||
<select name="category" required>
|
||||
{% for val, label in categories %}
|
||||
<option value="{{ val }}" {% if swp.category==val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Título *</label>
|
||||
<input type="text" name="title" value="{{ swp.title }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar</button>
|
||||
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if swp %}Editar{% else %}Nuevo{% endif %} Procedimiento{% endblock %}
|
||||
{% block page_title %}{% if swp %}Editar — {{ swp.code }}{% else %}Nuevo Procedimiento SWP{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">← Volver</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:860px">
|
||||
<form method="POST">
|
||||
{% if is_superadmin and companies %}
|
||||
<div class="form-group mb-4" style="background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.2);border-radius:8px;padding:12px 16px">
|
||||
<label style="color:var(--cyan)">🏢 Compañía *</label>
|
||||
<select name="company_id" required>
|
||||
<option value="">— Seleccionar compañía —</option>
|
||||
{% for c in companies %}
|
||||
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Código <span style="color:var(--gray);font-size:11px">(asignado automáticamente)</span></label>
|
||||
<input type="text" name="code" value="{{ swp.code if swp else code }}"
|
||||
readonly style="opacity:0.6;cursor:not-allowed;background:rgba(255,255,255,0.03)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Categoría *</label>
|
||||
<select name="category" required>
|
||||
{% for val, label in categories %}
|
||||
<option value="{{ val }}" {% if swp and swp.category==val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Título del Procedimiento *</label>
|
||||
<input type="text" name="title" required
|
||||
value="{{ swp.title if swp else '' }}"
|
||||
placeholder="Ej: Procedimiento para trabajo eléctrico a bordo">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Propósito</label>
|
||||
<textarea name="purpose" rows="2" placeholder="¿Para qué sirve este procedimiento?">{{ swp.purpose if swp else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Alcance</label>
|
||||
<textarea name="scope" rows="2" placeholder="¿A qué trabajos y personal aplica?">{{ swp.scope if swp else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Riesgos Identificados <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="hazards" rows="4" placeholder="Electrocución Quemaduras Caída al agua Cortocircuito">{{ swp.hazards if swp else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>EPP Requerido <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="ppe" rows="3" placeholder="Guantes dieléctricos Gafas de seguridad Zapatos con aislamiento">{{ swp.ppe if swp else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Herramientas y Materiales <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="tools" rows="3" placeholder="Multímetro Destornillador aislado Cinta aislante"></textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Pasos del Procedimiento <span style="color:var(--gray);font-size:11px">(uno por línea, en orden)</span></label>
|
||||
<textarea name="steps" rows="8" placeholder="Verificar que el sistema esté desenergizado Bloquear y etiquetar el breaker (LOTO) Verificar ausencia de voltaje con multímetro Realizar el trabajo Verificar conexiones antes de energizar Energizar y probar funcionamiento">{{ swp.steps if swp else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Medidas de Emergencia</label>
|
||||
<textarea name="emergency" rows="3" placeholder="En caso de accidente eléctrico: cortar energía inmediatamente, llamar al 911, aplicar RCP si es necesario. No tocar a la víctima sin cortar la corriente primero.">{{ swp.emergency if swp else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Referencias y Normativa <span style="color:var(--gray);font-size:11px">(una por línea)</span></label>
|
||||
<textarea name="ref_standards" rows="3" placeholder="OSHA 1910.147 — Control of Hazardous Energy NFPA 70E — Electrical Safety Código ISM — Sección 7">{{ swp.ref_standards if swp else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Fecha de Vigencia</label>
|
||||
<input type="date" name="effective_date">
|
||||
</div>
|
||||
{% if not swp %}
|
||||
<div class="form-group full" style="background:rgba(244,162,97,0.08);border:1px solid rgba(244,162,97,0.3);border-radius:8px;padding:12px">
|
||||
<div style="font-size:12px;color:var(--warning);margin-bottom:4px">📝 Se creará como <strong>Borrador (v1.0)</strong></div>
|
||||
<div style="font-size:12px;color:var(--gray)">Puedes editarlo libremente mientras sea borrador. Una vez aprobado por el admin, quedará activo y solo se podrá modificar creando una nueva versión.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar Procedimiento</button>
|
||||
<a href="{{ url_for('ism_index') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Editar {{ swp.code }} {{ version.version }}{% endblock %}
|
||||
{% block page_title %}✏️ Editar {{ swp.code }} — {{ version.version }}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">← Cancelar</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if version.status == 'active' %}
|
||||
<div class="card mb-4" style="padding:12px 16px;background:rgba(244,162,97,0.08);border:1px solid rgba(244,162,97,0.3)">
|
||||
<div style="font-size:13px;color:var(--warning)">
|
||||
⚠️ Esta versión ya está <strong>aprobada y activa</strong>. Los cambios se guardarán pero considera si es mejor crear una Nueva Versión para mantener el historial.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" style="max-width:860px">
|
||||
<form method="POST">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Motivo del cambio</label>
|
||||
<input type="text" name="change_reason" value="{{ version.change_reason or '' }}"
|
||||
placeholder="Ej: Corrección inicial">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Fecha de Vigencia</label>
|
||||
<input type="date" name="effective_date" value="{{ version.effective_date or '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Resumen de diferencias</label>
|
||||
<input type="text" name="diff_summary" value="{{ version.diff_summary or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<hr style="border-color:rgba(255,255,255,0.08);margin:16px 0">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>Propósito</label>
|
||||
<textarea name="purpose" rows="2">{{ version.purpose or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Alcance</label>
|
||||
<textarea name="scope" rows="2">{{ version.scope or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Riesgos Identificados <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="hazards" rows="4">{% set h=json.loads(version.hazards or '[]') %}{% for i in h %}{{ i }}
|
||||
{% endfor %}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>EPP Requerido <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="ppe" rows="3">{% set p=json.loads(version.ppe or '[]') %}{% for i in p %}{{ i }}
|
||||
{% endfor %}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Herramientas y Materiales <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="tools" rows="3" placeholder="Multímetro Destornillador aislado Cinta aislante">{% set to=json.loads(version.tools or '[]') %}{% for i in to %}{{ i }}
|
||||
{% endfor %}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Pasos del Procedimiento <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="steps" rows="8">{% set s=json.loads(version.steps or '[]') %}{% for i in s %}{{ i }}
|
||||
{% endfor %}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Medidas de Emergencia</label>
|
||||
<textarea name="emergency" rows="3">{{ version.emergency or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Referencias y Normativa <span style="color:var(--gray);font-size:11px">(una por línea)</span></label>
|
||||
<textarea name="ref_standards" rows="3">{% set r=json.loads(version.ref_standards or '[]') %}{% for i in r %}{{ i }}
|
||||
{% endfor %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar Cambios</button>
|
||||
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Nueva Versión — {{ swp.code }}{% endblock %}
|
||||
{% block page_title %}Nueva Versión {{ new_version }} — {{ swp.code }}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">← Cancelar</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card mb-4" style="padding:12px 16px;background:rgba(244,162,97,0.08);border:1px solid rgba(244,162,97,0.3)">
|
||||
<div style="font-size:13px;color:var(--warning)">
|
||||
⚠️ Estás creando la versión <strong>{{ new_version }}</strong> de <strong>{{ swp.code }}</strong>.
|
||||
La versión actual quedará como "Supersedida" cuando apruebes esta nueva versión.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width:860px">
|
||||
<form method="POST">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Motivo del Cambio *</label>
|
||||
<input type="text" name="change_reason" required
|
||||
placeholder="Ej: Actualización requisitos OSHA 2026">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Resumen de Diferencias</label>
|
||||
<input type="text" name="diff_summary"
|
||||
placeholder="Ej: Se agregó paso de verificación LOTO">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Fecha de Vigencia</label>
|
||||
<input type="date" name="effective_date">
|
||||
</div>
|
||||
</div>
|
||||
<hr style="border-color:rgba(255,255,255,0.08);margin:16px 0">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>Propósito</label>
|
||||
<textarea name="purpose" rows="2">{{ current.purpose if current else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Alcance</label>
|
||||
<textarea name="scope" rows="2">{{ current.scope if current else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Riesgos Identificados <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="hazards" rows="4">{% if current %}{% set h=json.loads(current.hazards or '[]') %}{% for i in h %}{{ i }}
|
||||
{% endfor %}{% endif %}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>EPP Requerido <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="ppe" rows="3">{% if current %}{% set p=json.loads(current.ppe or '[]') %}{% for i in p %}{{ i }}
|
||||
{% endfor %}{% endif %}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Herramientas y Materiales <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="tools" rows="3" placeholder="Multímetro Destornillador aislado Cinta aislante">{% if current %}{% set to=json.loads(current.tools or '[]') %}{% for i in to %}{{ i }}
|
||||
{% endfor %}{% endif %}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Pasos del Procedimiento <span style="color:var(--gray);font-size:11px">(uno por línea)</span></label>
|
||||
<textarea name="steps" rows="8">{% if current %}{% set s=json.loads(current.steps or '[]') %}{% for i in s %}{{ i }}
|
||||
{% endfor %}{% endif %}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Medidas de Emergencia</label>
|
||||
<textarea name="emergency" rows="3">{{ current.emergency if current else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Referencias y Normativa <span style="color:var(--gray);font-size:11px">(una por línea)</span></label>
|
||||
<textarea name="ref_standards" rows="3">{% if current %}{% set r=json.loads(current.ref_standards or '[]') %}{% for i in r %}{{ i }}
|
||||
{% endfor %}{% endif %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:rgba(0,180,216,0.06);border:1px solid rgba(0,180,216,0.2);border-radius:8px;padding:12px;margin-top:16px;font-size:12px;color:var(--gray)">
|
||||
ℹ️ Esta versión quedará en estado <strong style="color:var(--cyan)">Borrador</strong> hasta que el admin la apruebe desde el detalle del procedimiento.
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar Borrador</button>
|
||||
<a href="{{ url_for('swp_detail', sid=swp.id) }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if system %}Editar{% else %}Nuevo{% endif %} Sistema{% endblock %}
|
||||
{% block page_title %}{% if system %}Editar Sistema{% else %}Nuevo Sistema{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('systems') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:500px">
|
||||
{% if system and system.is_default %}
|
||||
<div class="alert alert-warn mb-4">Los sistemas predefinidos no se pueden editar.</div>
|
||||
{% else %}
|
||||
<form method="POST">
|
||||
<div class="form-group mb-4">
|
||||
<label>Nombre del Sistema *</label>
|
||||
<input type="text" name="name" value="{{ system.name if system else '' }}"
|
||||
required placeholder="Ej: Watermaker, Bow Thruster...">
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Descripción (opcional)</label>
|
||||
<textarea name="description">{{ system.description if system else '' }}</textarea>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar</button>
|
||||
<a href="{{ url_for('systems') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Sistemas{% endblock %}
|
||||
{% block page_title %}Sistemas{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('system_new') }}" class="btn btn-primary">+ Nuevo Sistema</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Sistema</th><th>Descripción</th><th>Tipo</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in systems %}
|
||||
<tr>
|
||||
<td><strong>{{ s.name }}</strong></td>
|
||||
<td class="text-gray">{{ s.description or '—' }}</td>
|
||||
<td>
|
||||
{% if s.is_default %}
|
||||
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">Predefinido</span>
|
||||
{% else %}
|
||||
<span class="badge" style="background:rgba(46,196,182,0.15);color:var(--success)">Personalizado</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not s.is_default %}
|
||||
<a href="{{ url_for('system_edit', sid=s.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
<button onclick="deleteSystem({{ s.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function deleteSystem(id, row) {
|
||||
if (!confirm('¿Eliminar este sistema?')) return;
|
||||
fetch(`/systems/${id}/delete`, {method:'DELETE'})
|
||||
.then(() => row.remove());
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if user %}Editar{% else %}Nuevo{% endif %} Usuario{% endblock %}
|
||||
{% block page_title %}{% if user %}Editar Usuario{% else %}Nuevo Usuario{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('users') }}" class="btn btn-secondary">← Volver</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:560px">
|
||||
<form method="POST">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Usuario *</label>
|
||||
<input type="text" name="username" value="{{ user.username if user else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nombre Completo</label>
|
||||
<input type="text" name="full_name" value="{{ user.full_name if user else '' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Email *</label>
|
||||
<input type="email" name="email" value="{{ user.email if user else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Contraseña {% if user %}(dejar vacío = no cambiar){% endif %}</label>
|
||||
<input type="password" name="password" {% if not user %}required{% endif %}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Rol</label>
|
||||
<select name="role">
|
||||
{% if current_user.role == 'superadmin' %}
|
||||
<option value="superadmin" {% if user and user.role=='superadmin' %}selected{% endif %}>Super Admin</option>
|
||||
<option value="admin" {% if user and user.role=='admin' %}selected{% endif %}>Admin</option>
|
||||
{% endif %}
|
||||
<option value="technician" {% if user and user.role=='technician' %}selected{% endif %}>Técnico</option>
|
||||
</select>
|
||||
</div>
|
||||
{% if current_user.role == 'superadmin' %}
|
||||
<div class="form-group full">
|
||||
<label>Compañía (vacío = acceso a todas)</label>
|
||||
<select name="company_id">
|
||||
<option value="">-- Todas las compañías --</option>
|
||||
{% for c in companies %}
|
||||
<option value="{{ c.id }}" {% if user and user.company_id==c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if user %}
|
||||
<div class="form-group">
|
||||
<label>Estado</label>
|
||||
<select name="is_active">
|
||||
<option value="1" {% if user.is_active %}selected{% endif %}>Activo</option>
|
||||
<option value="0" {% if not user.is_active %}selected{% endif %}>Inactivo</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar</button>
|
||||
<a href="{{ url_for('users') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Usuarios{% endblock %}
|
||||
{% block page_title %}Usuarios del Sistema{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
{% if current_user.role == 'superadmin' or current_user.role == 'admin' %}
|
||||
<a href="{{ url_for('user_new') }}" class="btn btn-primary">+ Nuevo Usuario</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Usuario</th><th>Nombre</th><th>Email</th><th>Compañía</th><th>Rol</th><th>Estado</th><th>Último Login</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td><strong>{{ u.username }}</strong></td>
|
||||
<td>{{ u.full_name or '—' }}</td>
|
||||
<td class="text-gray">{{ u.email }}</td>
|
||||
<td>{{ u.company_name or '<span class="text-cyan">Todas</span>'|safe }}</td>
|
||||
<td>
|
||||
{% if u.role == 'superadmin' %}
|
||||
<span class="badge" style="background:rgba(0,180,216,0.2);color:var(--cyan)">Super Admin</span>
|
||||
{% elif u.role == 'admin' %}
|
||||
<span class="badge" style="background:rgba(46,196,182,0.2);color:var(--success)">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge" style="background:rgba(255,255,255,0.1);color:var(--gray)">Técnico</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.is_active %}
|
||||
<span class="badge badge-completed">Activo</span>
|
||||
{% else %}
|
||||
<span class="badge badge-cancelled">Inactivo</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-gray" style="font-size:12px">{{ u.last_login[:16] if u.last_login else '—' }}</td>
|
||||
<td>
|
||||
{% if current_user.role == 'superadmin' or (current_user.role == 'admin' and u.role == 'technician') %}
|
||||
<a href="{{ url_for('user_edit', uid=u.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">Sin usuarios.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ vessel.name }}{% endblock %}
|
||||
{% block page_title %}{{ vessel.name }}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-primary">+ Nueva Orden</a>
|
||||
<a href="{{ url_for('vessel_edit', vid=vessel.id) }}" class="btn btn-secondary">✏️ Editar</a>
|
||||
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">← Volver</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">📋 Información</div>
|
||||
<table style="font-size:13px">
|
||||
<tr><td style="color:var(--gray);padding:6px 0;width:140px">Matrícula</td><td>{{ vessel.registration or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Tipo</td><td>{{ vessel.vessel_type or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Marca / Modelo</td><td>{{ vessel.make or '' }} {{ vessel.model or '' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Año</td><td>{{ vessel.year or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Eslora</td><td>{{ vessel.length_ft or '—' }} ft</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Motor</td><td>{{ vessel.engine_type or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Horas Motor</td><td class="text-cyan">{{ vessel.engine_hours or 0 }} h</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">👤 Propietario</div>
|
||||
<table style="font-size:13px">
|
||||
<tr><td style="color:var(--gray);padding:6px 0;width:100px">Nombre</td><td>{{ vessel.owner_name or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Teléfono</td><td>{{ vessel.owner_phone or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Email</td><td>{{ vessel.owner_email or '—' }}</td></tr>
|
||||
</table>
|
||||
{% if vessel.captain_name or vessel.captain_phone %}
|
||||
<div style="margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.05)">
|
||||
<div style="font-size:11px;color:var(--cyan);text-transform:uppercase;letter-spacing:1.5px;font-weight:600;margin-bottom:8px">⚓ Capitán</div>
|
||||
<table style="font-size:13px">
|
||||
<tr><td style="color:var(--gray);padding:4px 0;width:100px">Nombre</td><td>{{ vessel.captain_name or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:4px 0">Teléfono</td><td>{{ vessel.captain_phone or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:4px 0">Email</td><td>{{ vessel.captain_email or '—' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if vessel.notes %}
|
||||
<div class="mt-4" style="font-size:13px;color:var(--gray);border-top:1px solid rgba(255,255,255,0.05);padding-top:12px">
|
||||
{{ vessel.notes }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">🔧 Historial de Órdenes de Trabajo</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Orden</th><th>Tipo</th><th>Descripción</th><th>Técnico</th><th>Fecha</th><th>Estado</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for o in orders %}
|
||||
<tr>
|
||||
<td class="text-cyan">{{ o.order_number }}</td>
|
||||
<td>{{ o.work_type or '—' }}</td>
|
||||
<td>{{ o.description[:60] }}{% if o.description|length > 60 %}...{% endif %}</td>
|
||||
<td>{{ o.technician or '—' }}</td>
|
||||
<td class="text-gray">{{ o.start_date or o.created_at[:10] }}</td>
|
||||
<td><span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span></td>
|
||||
<td><a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-gray" style="text-align:center;padding:20px">Sin órdenes de trabajo.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if vessel %}Editar{% else %}Nueva{% endif %} Embarcación{% endblock %}
|
||||
{% block page_title %}{% if vessel %}Editar Embarcación{% else %}Nueva Embarcación{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">← Volver</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:820px">
|
||||
<form method="POST">
|
||||
|
||||
{% if current_user.role in ('superadmin','admin') and companies %}
|
||||
<div class="section-title">Compañía</div>
|
||||
<div class="form-grid mb-4">
|
||||
<div class="form-group full">
|
||||
<label>Compañía *</label>
|
||||
<select name="company_id" required>
|
||||
<option value="">-- Seleccionar --</option>
|
||||
{% for c in companies %}
|
||||
<option value="{{ c.id }}" {% if vessel and vessel.company_id==c.id %}selected{% elif current_user.company_id==c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-title">Datos de la Embarcación</div>
|
||||
<div class="form-grid cols-3">
|
||||
<div class="form-group full">
|
||||
<label>Nombre *</label>
|
||||
<input type="text" name="name" value="{{ vessel.name if vessel else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Matrícula</label>
|
||||
<input type="text" name="registration" value="{{ vessel.registration if vessel else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tipo</label>
|
||||
<select name="vessel_type">
|
||||
<option value="">-- Seleccionar --</option>
|
||||
{% for t in ['Motor Yacht','Sailing Yacht','Sport Fisherman','Center Console','Catamaran','Trawler','Pontoon','PWC','Commercial','Other'] %}
|
||||
<option value="{{ t }}" {% if vessel and vessel.vessel_type==t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Año</label>
|
||||
<input type="number" name="year" value="{{ vessel.year if vessel else '' }}" min="1900" max="2030">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Marca</label>
|
||||
<input type="text" name="make" value="{{ vessel.make if vessel else '' }}" placeholder="Azimut, Sunseeker...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Modelo</label>
|
||||
<input type="text" name="model" value="{{ vessel.model if vessel else '' }}" placeholder="80, 68S...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Eslora (pies)</label>
|
||||
<input type="number" step="0.1" name="length_ft" value="{{ vessel.length_ft if vessel else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bandera</label>
|
||||
<input type="text" name="flag" value="{{ vessel.flag if vessel else '' }}" placeholder="USA, Panama...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Puerto de Matrícula</label>
|
||||
<input type="text" name="port_of_registry" value="{{ vessel.port_of_registry if vessel else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title mt-6">Propietario</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Nombre Propietario</label>
|
||||
<input type="text" name="owner_name" value="{{ vessel.owner_name if vessel else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Teléfono</label>
|
||||
<input type="tel" name="owner_phone" value="{{ vessel.owner_phone if vessel else '' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Email</label>
|
||||
<input type="email" name="owner_email" value="{{ vessel.owner_email if vessel else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title mt-6">⚓ Capitán / Contacto Operativo</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Nombre Capitán</label>
|
||||
<input type="text" name="captain_name" value="{{ vessel.captain_name if vessel else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Teléfono</label>
|
||||
<input type="tel" name="captain_phone" value="{{ vessel.captain_phone if vessel else '' }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Email</label>
|
||||
<input type="email" name="captain_email" value="{{ vessel.captain_email if vessel else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group full mt-4">
|
||||
<label>Notas</label>
|
||||
<textarea name="notes">{{ vessel.notes if vessel else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar</button>
|
||||
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,333 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Historial — {{ vessel.name }}{% endblock %}
|
||||
{% block page_title %}{{ vessel.name }}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-primary">+ Nueva Orden</a>
|
||||
<a href="{{ url_for('vessel_edit', vid=vessel.id) }}" class="btn btn-secondary">✏️ Editar</a>
|
||||
<a href="{{ url_for('vessels') }}" class="btn btn-secondary">← Volver</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- STATS -->
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">🚢 Tipo</div>
|
||||
<div style="font-size:15px;font-weight:600;color:var(--white);margin-top:4px">{{ vessel.vessel_type or '—' }}</div>
|
||||
<div class="stat-sub">{{ vessel.make or '' }} {{ vessel.model or '' }} {{ vessel.year or '' }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">⚙️ Horas Motor</div>
|
||||
<div class="stat-value">{{ vessel.engine_hours or 0 }}</div>
|
||||
<div class="stat-sub">horas acumuladas</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">📋 Total WOs</div>
|
||||
<div class="stat-value">{{ orders|length }}</div>
|
||||
<div class="stat-sub">órdenes registradas</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">💰 Costo Total</div>
|
||||
<div class="stat-value" style="font-size:22px">${{ "%.0f"|format(total_cost) }}</div>
|
||||
<div class="stat-sub">histórico acumulado</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTACTOS -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px">
|
||||
<div class="card" style="padding:14px 18px">
|
||||
<div style="font-size:10px;color:var(--gray);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px">👤 Propietario</div>
|
||||
<div style="font-size:14px;font-weight:600">{{ vessel.owner_name or '—' }}</div>
|
||||
<div style="font-size:12px;color:var(--gray)">{{ vessel.owner_phone or '' }}{% if vessel.owner_email %} · {{ vessel.owner_email }}{% endif %}</div>
|
||||
</div>
|
||||
<div class="card" style="padding:14px 18px">
|
||||
<div style="font-size:10px;color:var(--cyan);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px">⚓ Capitán</div>
|
||||
<div style="font-size:14px;font-weight:600">{{ vessel.captain_name or '—' }}</div>
|
||||
<div style="font-size:12px;color:var(--gray)">{{ vessel.captain_phone or '' }}{% if vessel.captain_email %} · {{ vessel.captain_email }}{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ ÓRDENES DE TRABAJO ═══ -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header flex justify-between">
|
||||
<span>📋 Órdenes de Trabajo</span>
|
||||
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-sm btn-primary">+ Nueva Orden</a>
|
||||
</div>
|
||||
{% if orders %}
|
||||
<!-- Búsqueda -->
|
||||
<div style="margin-bottom:12px;display:flex;gap:10px">
|
||||
<input type="text" id="histSearch"
|
||||
placeholder="🔍 Buscar por scope, fecha, técnico..."
|
||||
style="flex:1;padding:8px 12px;font-size:13px;border-radius:6px;
|
||||
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.2);color:var(--white);"
|
||||
oninput="filterHistory(this.value)">
|
||||
<select id="statusFilter" onchange="filterHistory(document.getElementById('histSearch').value)"
|
||||
style="padding:8px 12px;border-radius:6px;background:rgba(255,255,255,0.06);
|
||||
border:1px solid rgba(0,180,216,0.2);color:var(--white);font-size:13px">
|
||||
<option value="">Todos</option>
|
||||
<option value="open">Abiertas</option>
|
||||
<option value="in_progress">En Progreso</option>
|
||||
<option value="completed">Completadas</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tabla desktop -->
|
||||
<div class="table-wrap" id="histTable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Orden</th><th>Fecha</th><th>Sistema</th><th>Scope</th><th>Técnico</th><th>Horas</th><th>Costo</th><th>Estado</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="woTableBody">
|
||||
{% for o in orders %}
|
||||
<tr class="history-row"
|
||||
data-search="{{ (o.scope or '' ~ ' ' ~ (o.description or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ (o.start_date or ''))|lower }}"
|
||||
data-status="{{ o.status }}">
|
||||
<td class="text-cyan" style="white-space:nowrap;font-size:12px">{{ o.order_number }}</td>
|
||||
<td class="text-gray" style="white-space:nowrap">{{ o.start_date or o.created_at[:10] }}</td>
|
||||
<td class="text-gray" style="font-size:12px">{{ o.system_name or '—' }}</td>
|
||||
<td style="max-width:280px">
|
||||
<div style="font-weight:600;font-size:13px">{{ o.scope or '—' }}</div>
|
||||
{% if o.description and o.scope and o.description != o.scope %}
|
||||
<div style="font-size:11px;color:var(--gray)">{{ o.description[:80] }}{% if o.description|length > 80 %}...{% endif %}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size:12px">{{ o.technician or '—' }}</td>
|
||||
<td style="font-size:12px">{{ o.labor_hours or 0 }} h</td>
|
||||
<td class="text-cyan" style="font-size:12px;white-space:nowrap">
|
||||
${{ "%.0f"|format((o.calc_labor_cost or 0) + (o.total_parts_cost or 0)) }}
|
||||
</td>
|
||||
<td><span class="badge badge-{{ o.status }}" style="font-size:10px">{{ o.status.replace('_',' ') }}</span></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a>
|
||||
{% if o.status != 'completed' %}
|
||||
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('work_order_pdf', woid=o.id) }}" target="_blank" class="btn btn-sm btn-primary">📄</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Tarjetas móvil -->
|
||||
<div id="histCards">
|
||||
{% for o in orders %}
|
||||
<div class="hist-card history-row"
|
||||
data-search="{{ (o.scope or '' ~ ' ' ~ (o.description or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ (o.start_date or ''))|lower }}"
|
||||
data-status="{{ o.status }}"
|
||||
style="background:var(--navy2);border:1px solid rgba(0,180,216,0.12);border-radius:10px;margin-bottom:10px;overflow:hidden;">
|
||||
<div style="padding:10px 14px;background:rgba(0,0,0,0.2);border-bottom:1px solid rgba(255,255,255,0.05);display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:12px;color:var(--cyan);font-family:monospace;font-weight:600">{{ o.order_number }}</span>
|
||||
<span class="badge badge-{{ o.status }}" style="font-size:11px">{{ o.status.replace('_',' ') }}</span>
|
||||
</div>
|
||||
<div style="padding:10px 14px">
|
||||
<div style="font-weight:600;font-size:14px;color:var(--white);margin-bottom:4px">{{ o.scope or '—' }}</div>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--gray);margin-bottom:6px">
|
||||
{% if o.system_name %}<span>🔩 {{ o.system_name }}</span>{% endif %}
|
||||
{% if o.technician %}<span>👤 {{ o.technician }}</span>{% endif %}
|
||||
<span>📅 {{ o.start_date or o.created_at[:10] }}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;font-size:13px">
|
||||
<span style="color:var(--gray)">{{ o.labor_hours or 0 }} h</span>
|
||||
<span style="color:var(--cyan);font-weight:600">${{ "%.0f"|format((o.calc_labor_cost or 0) + (o.total_parts_cost or 0)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;padding:8px 14px;border-top:1px solid rgba(255,255,255,0.05);background:rgba(0,0,0,0.1)">
|
||||
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-primary" style="flex:1;text-align:center">Ver detalle</a>
|
||||
{% if o.status != 'completed' %}
|
||||
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('work_order_pdf', woid=o.id) }}" target="_blank" class="btn btn-sm btn-secondary">📄</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="noHistResults" style="display:none;text-align:center;padding:20px;color:var(--gray)">
|
||||
No se encontraron órdenes.
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:40px;color:var(--gray)">
|
||||
<div style="font-size:36px;margin-bottom:10px">📋</div>
|
||||
<div style="margin-bottom:14px">Sin órdenes de trabajo para esta embarcación.</div>
|
||||
<a href="{{ url_for('work_order_new') }}?vessel={{ vessel.id }}" class="btn btn-primary">+ Crear Primera Orden</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- EQUIPOS -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header flex justify-between">
|
||||
<span>⚙️ Equipos Registrados</span>
|
||||
<a href="{{ url_for('equipment_new', vid=vessel.id) }}" class="btn btn-sm btn-primary">+ Agregar Equipo</a>
|
||||
</div>
|
||||
{% if equipment %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px">
|
||||
{% for e in equipment %}
|
||||
<div style="background:rgba(0,0,0,0.2);border:1px solid rgba(0,180,216,0.12);border-radius:8px;padding:12px 14px">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--white);margin-bottom:3px">{{ e.name }}</div>
|
||||
<div style="font-size:11px;color:var(--gray);margin-bottom:5px">
|
||||
{{ e.make or '' }} {{ e.model or '' }}
|
||||
{% if e.position %}· <span style="color:var(--cyan)">{{ e.position }}</span>{% endif %}
|
||||
</div>
|
||||
{% if e.serial_number %}
|
||||
<div style="font-size:11px;background:rgba(0,180,216,0.08);border:1px solid rgba(0,180,216,0.2);
|
||||
border-radius:4px;padding:3px 8px;color:var(--cyan);font-family:monospace;margin-bottom:5px">
|
||||
S/N: {{ e.serial_number }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="font-size:11px;color:var(--gray)">{{ e.engine_hours or 0 }} h</div>
|
||||
<a href="{{ url_for('equipment_edit', vid=vessel.id, eid=e.id) }}"
|
||||
class="btn btn-sm btn-secondary" style="margin-top:8px;font-size:11px">✏️</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray" style="font-size:13px">Sin equipos. Agrega motores, generadores, etc.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- DOCUMENTOS -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header flex justify-between">
|
||||
<span>📁 Documentos y Manuales</span>
|
||||
<button onclick="document.getElementById('docModal').style.display='flex'" class="btn btn-sm btn-primary">+ Adjuntar</button>
|
||||
</div>
|
||||
{% if documents %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Título</th><th>Tipo</th><th>Equipo</th><th>Tamaño</th><th>Fecha</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in documents %}
|
||||
<tr>
|
||||
<td>
|
||||
<span style="font-size:15px;margin-right:6px">
|
||||
{% if d.filename.endswith('.pdf') %}📄
|
||||
{% elif d.filename.endswith(('.doc','docx')) %}📝
|
||||
{% elif d.filename.endswith(('.xls','xlsx')) %}📊
|
||||
{% elif d.filename.endswith(('.jpg','jpeg','png','gif')) %}🖼️
|
||||
{% else %}📎{% endif %}
|
||||
</span>
|
||||
<strong>{{ d.title }}</strong>
|
||||
{% if d.description %}<br><span class="text-gray" style="font-size:11px">{{ d.description }}</span>{% endif %}
|
||||
</td>
|
||||
<td><span class="badge badge-open" style="font-size:10px">{{ d.doc_type }}</span></td>
|
||||
<td class="text-gray">{{ d.equipment_name or '—' }}</td>
|
||||
<td class="text-gray" style="font-size:12px">{% if d.file_size %}{{ "%.1f"|format(d.file_size/1024) }} KB{% else %}—{% endif %}</td>
|
||||
<td class="text-gray" style="font-size:12px">{{ d.created_at[:10] }}</td>
|
||||
<td class="flex gap-2">
|
||||
<a href="{{ url_for('document_download', doc_id=d.id) }}" class="btn btn-sm btn-primary">⬇️</a>
|
||||
<button onclick="deleteDoc({{ d.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray" style="font-size:13px">Sin documentos adjuntos.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- MODAL DOCUMENTO -->
|
||||
<div id="docModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
|
||||
<div class="card" style="width:480px;max-width:95vw">
|
||||
<div class="card-header">📁 Adjuntar Documento</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Título *</label>
|
||||
<input type="text" id="docTitle" placeholder="Ej: Manual MTU 12V2000">
|
||||
</div>
|
||||
<div class="form-grid mb-3">
|
||||
<div class="form-group">
|
||||
<label>Tipo</label>
|
||||
<select id="docType">
|
||||
<option value="manual">Manual</option>
|
||||
<option value="certificate">Certificado</option>
|
||||
<option value="warranty">Garantía</option>
|
||||
<option value="inspection">Inspección</option>
|
||||
<option value="drawing">Plano/Diagrama</option>
|
||||
<option value="other">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Equipo (opcional)</label>
|
||||
<select id="docEquipment">
|
||||
<option value="">— General —</option>
|
||||
{% for e in equipment %}
|
||||
<option value="{{ e.id }}">{{ e.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Descripción (opcional)</label>
|
||||
<input type="text" id="docDesc">
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Archivo</label>
|
||||
<input type="file" id="docFile" accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.png,.jpg,.jpeg" style="padding:8px">
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="uploadDoc()" class="btn btn-primary">📤 Subir</button>
|
||||
<button onclick="document.getElementById('docModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
|
||||
</div>
|
||||
<div id="docStatus" style="margin-top:10px;font-size:13px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
<style>
|
||||
@media (max-width:768px) {
|
||||
#histTable { display:none; }
|
||||
#histCards { display:block; }
|
||||
}
|
||||
@media (min-width:769px) {
|
||||
#histTable { display:block; }
|
||||
#histCards { display:none; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const VID = {{ vessel.id }};
|
||||
|
||||
function filterHistory(q) {
|
||||
q = (q || '').toLowerCase().trim();
|
||||
const status = document.getElementById('statusFilter').value;
|
||||
const rows = document.querySelectorAll('.history-row');
|
||||
let visible = 0;
|
||||
rows.forEach(r => {
|
||||
const show = (!q || r.dataset.search.includes(q)) && (!status || r.dataset.status === status);
|
||||
r.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
document.getElementById('noHistResults').style.display = visible === 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function uploadDoc() {
|
||||
const file = document.getElementById('docFile').files[0];
|
||||
const title = document.getElementById('docTitle').value.trim();
|
||||
if (!file) { alert('Selecciona un archivo'); return; }
|
||||
if (!title) { alert('Ingresa un título'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('title', title);
|
||||
fd.append('doc_type', document.getElementById('docType').value);
|
||||
fd.append('equipment_id', document.getElementById('docEquipment').value);
|
||||
fd.append('description', document.getElementById('docDesc').value);
|
||||
document.getElementById('docStatus').textContent = 'Subiendo...';
|
||||
fetch(`/vessels/${VID}/documents/upload`, { method:'POST', body: fd })
|
||||
.then(r => r.json()).then(d => {
|
||||
if (d.ok) location.reload();
|
||||
else document.getElementById('docStatus').textContent = 'Error: ' + d.error;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteDoc(id, row) {
|
||||
if (!confirm('¿Eliminar este documento?')) return;
|
||||
fetch(`/documents/${id}/delete`, {method:'DELETE'})
|
||||
.then(() => row.remove());
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,119 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Embarcaciones{% endblock %}
|
||||
{% block page_title %}Embarcaciones{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('vessel_new') }}" class="btn btn-primary">+ Nueva Embarcación</a>
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
<style>
|
||||
.vessel-cards { display:none; }
|
||||
.vessel-table { display:block; }
|
||||
@media (max-width:768px) {
|
||||
.vessel-table { display:none; }
|
||||
.vessel-cards { display:grid; grid-template-columns:1fr; gap:10px; }
|
||||
}
|
||||
.vcard {
|
||||
background:var(--navy2);border:1px solid rgba(0,180,216,0.12);
|
||||
border-radius:10px;overflow:hidden;
|
||||
}
|
||||
.vcard-header {
|
||||
padding:12px 14px;background:rgba(0,0,0,0.25);
|
||||
border-bottom:1px solid rgba(255,255,255,0.05);
|
||||
display:flex;justify-content:space-between;align-items:center;
|
||||
}
|
||||
.vcard-name { font-size:16px;font-weight:600;color:var(--white); }
|
||||
.vcard-type { font-size:12px;color:var(--cyan); }
|
||||
.vcard-body { padding:12px 14px; }
|
||||
.vcard-row { display:flex;gap:16px;flex-wrap:wrap;margin-bottom:8px;font-size:13px; }
|
||||
.vcard-row span { color:var(--gray); }
|
||||
.vcard-row strong { color:var(--white); }
|
||||
.vcard-actions {
|
||||
display:flex;gap:8px;padding:10px 14px;
|
||||
border-top:1px solid rgba(255,255,255,0.05);
|
||||
background:rgba(0,0,0,0.1);
|
||||
}
|
||||
.vcard-actions a { flex:1;text-align:center; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div style="margin-bottom:14px">
|
||||
<input type="text" id="vesselSearch"
|
||||
placeholder="🔍 Buscar embarcación..."
|
||||
oninput="filterVessels(this.value)"
|
||||
style="width:100%;max-width:400px;padding:8px 12px;border-radius:6px;
|
||||
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
|
||||
color:var(--white);font-size:13px">
|
||||
</div>
|
||||
|
||||
<!-- TABLA DESKTOP -->
|
||||
<div class="vessel-table card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Nombre</th><th>Tipo</th><th>Marca/Modelo</th><th>Año</th><th>Propietario</th><th>Capitán</th><th>WOs</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in vessels %}
|
||||
<tr class="vessel-row" data-search="{{ (v.name ~ ' ' ~ (v.owner_name or '') ~ ' ' ~ (v.captain_name or '') ~ ' ' ~ (v.vessel_type or ''))|lower }}">
|
||||
<td><strong>{{ v.name }}</strong></td>
|
||||
<td>{{ v.vessel_type or '—' }}</td>
|
||||
<td class="text-gray">{{ v.make or '' }} {{ v.model or '' }}</td>
|
||||
<td>{{ v.year or '—' }}</td>
|
||||
<td>{{ v.owner_name or '—' }}</td>
|
||||
<td>{{ v.captain_name or '—' }}</td>
|
||||
<td><span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">{{ v.wo_count or 0 }}</span></td>
|
||||
<td class="flex gap-2">
|
||||
<a href="{{ url_for('vessel_history', vid=v.id) }}" class="btn btn-sm btn-primary">Historial</a>
|
||||
<a href="{{ url_for('vessel_edit', vid=v.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">Sin embarcaciones.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TARJETAS MÓVIL -->
|
||||
<div class="vessel-cards" id="vesselCards">
|
||||
{% for v in vessels %}
|
||||
<div class="vcard vessel-row" data-search="{{ (v.name ~ ' ' ~ (v.owner_name or '') ~ ' ' ~ (v.captain_name or '') ~ ' ' ~ (v.vessel_type or ''))|lower }}">
|
||||
<div class="vcard-header">
|
||||
<div>
|
||||
<div class="vcard-name">🚢 {{ v.name }}</div>
|
||||
<div class="vcard-type">{{ v.vessel_type or '—' }} {% if v.year %}· {{ v.year }}{% endif %}</div>
|
||||
</div>
|
||||
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan);font-size:13px">
|
||||
{{ v.wo_count or 0 }} WOs
|
||||
</span>
|
||||
</div>
|
||||
<div class="vcard-body">
|
||||
<div class="vcard-row">
|
||||
{% if v.make or v.model %}<span>Marca/Modelo:</span><strong>{{ v.make or '' }} {{ v.model or '' }}</strong>{% endif %}
|
||||
</div>
|
||||
<div class="vcard-row">
|
||||
{% if v.owner_name %}<span>Propietario:</span><strong>{{ v.owner_name }}</strong>{% endif %}
|
||||
{% if v.captain_name %}<span>Capitán:</span><strong>{{ v.captain_name }}</strong>{% endif %}
|
||||
</div>
|
||||
{% if v.engine_hours %}<div style="font-size:12px;color:var(--gray)">⚙️ {{ v.engine_hours }} h motor</div>{% endif %}
|
||||
</div>
|
||||
<div class="vcard-actions">
|
||||
<a href="{{ url_for('vessel_history', vid=v.id) }}" class="btn btn-sm btn-primary">📋 Historial</a>
|
||||
<a href="{{ url_for('work_order_new') }}?vessel={{ v.id }}" class="btn btn-sm btn-secondary">+ WO</a>
|
||||
<a href="{{ url_for('vessel_edit', vid=v.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:40px;color:var(--gray)">Sin embarcaciones registradas.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function filterVessels(q) {
|
||||
q = q.toLowerCase().trim();
|
||||
document.querySelectorAll('.vessel-row').forEach(r => {
|
||||
r.style.display = (!q || r.dataset.search.includes(q)) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,999 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ order.order_number }}{% endblock %}
|
||||
{% block page_title %}{{ order.order_number }}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<div class="topbar-actions-desktop">
|
||||
{% if order.status != 'completed' %}
|
||||
<a href="{{ url_for('work_order_edit', woid=order.id) }}" class="btn btn-secondary btn-sm">✏️ Editar</a>
|
||||
<button onclick="setStatus('in_progress')" class="btn btn-warning btn-sm">▶ En Progreso</button>
|
||||
<button onclick="setStatus('completed')" class="btn btn-success btn-sm">✅ Completar</button>
|
||||
<button onclick="deleteThisWO()" class="btn btn-danger btn-sm">🗑️</button>
|
||||
{% else %}
|
||||
<button onclick="confirmReopen()" class="btn btn-warning btn-sm">🔓 Reabrir WO</button>
|
||||
{% endif %}
|
||||
<button onclick="openAssignModal()" class="btn btn-secondary btn-sm">👤 Asignar</button>
|
||||
<a href="{{ url_for('work_order_pdf', woid=order.id) }}?lang=es" target="_blank" class="btn btn-primary btn-sm">📄 ES</a>
|
||||
<a href="{{ url_for('work_order_pdf', woid=order.id) }}?lang=en" target="_blank" class="btn btn-secondary btn-sm">📄 EN</a>
|
||||
<button onclick="openShareModal()" class="btn btn-secondary btn-sm">📤 Enviar</button>
|
||||
<a href="{{ url_for('work_orders') }}" class="btn btn-secondary btn-sm">← Volver</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- BARRA DE ACCIONES MÓVIL -->
|
||||
<style>
|
||||
.mobile-action-bar { display:none; }
|
||||
.topbar-actions-desktop { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
@media (max-width:768px) {
|
||||
.topbar-actions-desktop { display:none !important; }
|
||||
.mobile-action-bar {
|
||||
display:flex;position:fixed;bottom:0;left:0;right:0;
|
||||
background:var(--navy2);border-top:2px solid rgba(0,180,216,0.4);
|
||||
padding:8px 10px;gap:6px;z-index:950;
|
||||
}
|
||||
.mobile-action-bar .mab-btn {
|
||||
flex:1;text-align:center;font-size:11px;padding:8px 4px;
|
||||
border-radius:8px;border:none;cursor:pointer;min-width:0;
|
||||
display:flex;flex-direction:column;align-items:center;gap:2px;
|
||||
font-weight:600;
|
||||
}
|
||||
.mobile-action-bar .mab-btn .icon { font-size:18px;line-height:1; }
|
||||
.mobile-action-bar .mab-btn .label { font-size:10px;line-height:1; }
|
||||
.content { padding-bottom:80px !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mobile-action-bar">
|
||||
{% if order.status != 'completed' %}
|
||||
<button onclick="setStatus('in_progress')" class="mab-btn btn-warning"
|
||||
style="background:rgba(244,162,97,0.2);color:var(--warning)">
|
||||
<span class="icon">▶</span><span class="label">Progreso</span>
|
||||
</button>
|
||||
<button onclick="setStatus('completed')" class="mab-btn btn-success"
|
||||
style="background:rgba(46,196,182,0.2);color:var(--success)">
|
||||
<span class="icon">✅</span><span class="label">Completar</span>
|
||||
</button>
|
||||
<a href="{{ url_for('work_order_edit', woid=order.id) }}" class="mab-btn"
|
||||
style="background:rgba(255,255,255,0.08);color:var(--white);text-decoration:none">
|
||||
<span class="icon">✏️</span><span class="label">Editar</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button onclick="confirmReopen()" class="mab-btn"
|
||||
style="background:rgba(244,162,97,0.2);color:var(--warning)">
|
||||
<span class="icon">🔓</span><span class="label">Reabrir</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="openShareModal()" class="mab-btn"
|
||||
style="background:rgba(255,255,255,0.08);color:var(--white)">
|
||||
<span class="icon">📤</span><span class="label">Enviar</span>
|
||||
</button>
|
||||
<a href="{{ url_for('work_order_pdf', woid=order.id) }}?lang=es" target="_blank"
|
||||
class="mab-btn" style="background:rgba(0,180,216,0.2);color:var(--cyan);text-decoration:none">
|
||||
<span class="icon">📄</span><span class="label">PDF</span>
|
||||
</a>
|
||||
<a href="{{ url_for('work_orders') }}" class="mab-btn"
|
||||
style="background:rgba(255,255,255,0.08);color:var(--white);text-decoration:none">
|
||||
<span class="icon">⬅</span><span class="label">Volver</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">📋 Detalles</div>
|
||||
<table style="font-size:13px">
|
||||
<tr><td style="color:var(--gray);padding:6px 0;width:140px">Embarcación</td><td><a href="{{ url_for('vessel_history', vid=order.vessel_id) }}" class="text-cyan">{{ order.vessel_name }}</a></td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Sistema</td><td><span class="badge badge-open">{{ order.system_name or '—' }}</span></td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Scope</td><td><strong>{{ order.scope or '—' }}</strong></td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Tipo</td><td>{{ order.work_type or '—' }}</td></tr>
|
||||
<tr>
|
||||
<td style="color:var(--gray);padding:6px 0">Facturación</td>
|
||||
<td>
|
||||
{% if order.billing_type == 'lump_sum' %}
|
||||
<span class="badge" style="background:rgba(244,162,97,0.15);color:var(--warning)">💰 A todo costo</span>
|
||||
{% elif order.billing_type == 'labor_only' %}
|
||||
<span class="badge" style="background:rgba(0,180,216,0.15);color:var(--cyan)">🔧 Solo M.O.</span>
|
||||
{% else %}
|
||||
<span class="badge" style="background:rgba(46,196,182,0.15);color:var(--success)">📋 M.O. + Materiales</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Técnico</td><td>{{ order.technician or '—' }}</td></tr>
|
||||
{% if order.assigned_to %}
|
||||
<tr>
|
||||
<td style="color:var(--gray);padding:6px 0">Asignado a</td>
|
||||
<td>
|
||||
<span style="color:var(--cyan);font-size:12px">📨 {{ order.assigned_to }}</span>
|
||||
{% if order.assigned_by %}<br><span style="font-size:11px;color:var(--gray)">por {{ order.assigned_by }}</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Fecha inicio</td><td>{{ order.start_date or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Fecha fin</td><td>{{ order.end_date or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">H. Motor inicio</td><td>{{ order.engine_hours_start or '—' }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Estado</td><td><span class="badge badge-{{ order.status }}">{{ order.status.replace('_',' ') }}</span></td></tr>
|
||||
</table>
|
||||
<div style="margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.05);font-size:13px">
|
||||
<div style="color:var(--gray);font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px">Descripción</div>
|
||||
{{ order.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">💰 Costos</div>
|
||||
<table style="font-size:13px">
|
||||
<tr><td style="color:var(--gray);padding:6px 0;width:160px">Horas Trabajadas</td><td>{{ order.labor_hours or 0 }} h</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Tarifa M.O.</td><td>${{ order.labor_rate or 0 }}/h</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Costo M.O.</td><td>${{ "%.2f"|format(order.total_labor_cost or 0) }}</td></tr>
|
||||
<tr><td style="color:var(--gray);padding:6px 0">Costo Repuestos</td><td>${{ "%.2f"|format(order.total_parts_cost or 0) }}</td></tr>
|
||||
<tr style="font-weight:600"><td style="color:var(--cyan);padding:8px 0">TOTAL</td><td class="text-cyan">${{ "%.2f"|format((order.total_labor_cost or 0) + (order.total_parts_cost or 0)) }}</td></tr>
|
||||
</table>
|
||||
<div class="mt-4" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<label style="font-size:12px;color:var(--gray)">Horas trabajadas:</label>
|
||||
<input type="number" id="labor_hours" step="0.01" value="{{ order.labor_hours or 0 }}" style="width:80px;padding:6px">
|
||||
<label style="font-size:12px;color:var(--gray)">Tarifa $/h:</label>
|
||||
<input type="number" id="labor_rate" step="0.01" value="{{ order.labor_rate or 0 }}" style="width:80px;padding:6px">
|
||||
<button onclick="updateHours()" class="btn btn-sm btn-primary">💾 Guardar Horas</button>
|
||||
<span id="hoursStatus" style="font-size:12px;color:var(--success)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EQUIPOS TRABAJADOS EN ESTE WO -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header flex justify-between">
|
||||
<span>⚙️ Equipos Trabajados</span>
|
||||
{% if order.status != 'completed' %}
|
||||
<button onclick="openEquipModal()" class="btn btn-sm btn-primary">+ Agregar Equipo</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if wo_equipment %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Equipo</th><th>S/N</th><th>Trabajo Realizado</th><th>Notas</th><th>Horas</th><th>Costo M.O.</th>{% if order.status != 'completed' %}<th></th>{% endif %}</tr></thead>
|
||||
<tbody>
|
||||
{% for e in wo_equipment %}
|
||||
<tr>
|
||||
<td><strong>{{ e.equip_name or '—' }}</strong><br><span class="text-gray" style="font-size:11px">{{ e.make or '' }} {{ e.model or '' }}</span></td>
|
||||
<td class="text-gray" style="font-family:monospace;font-size:11px">{{ e.serial_number or '—' }}</td>
|
||||
<td>{{ e.description or '—' }}</td>
|
||||
<td class="text-gray" style="font-size:11px">{{ e.notes or '—' }}</td>
|
||||
<td>{{ e.labor_hours or 0 }} h</td>
|
||||
<td class="text-cyan">${{ "%.2f"|format((e.labor_hours or 0) * (e.labor_rate or 0)) }}</td>
|
||||
{% if order.status != 'completed' %}
|
||||
<td class="flex gap-2">
|
||||
<button onclick="editWoEquip({{ e.id }}, '{{ e.equip_name or '' }}', {{ e.labor_hours or 0 }}, {{ e.labor_rate or 0 }}, '{{ e.description|replace("'","\\'")|replace('"','\\"') or '' }}', '{{ e.notes|replace("'","\\'")|replace('"','\\"') or '' }}')" class="btn btn-sm btn-secondary">✏️</button>
|
||||
<button onclick="removeWoEquip({{ e.id }}, this.closest('tr'))" class="btn btn-sm btn-danger">🗑️</button>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="background:rgba(0,180,216,0.06);font-weight:600">
|
||||
<td colspan="4" style="padding:10px 14px;color:var(--gray)">TOTAL MANO DE OBRA</td>
|
||||
<td style="padding:10px 14px;color:var(--cyan)">{{ wo_equipment|sum(attribute='labor_hours') or 0 }} h</td>
|
||||
<td style="padding:10px 14px;color:var(--cyan)">${{ "%.2f"|format(wo_equipment|sum(attribute='labor_cost') or 0) }}</td>
|
||||
{% if order.status != 'completed' %}<td></td>{% endif %}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray" style="font-size:13px">Sin equipos agregados.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- MODAL AGREGAR/EDITAR EQUIPO WO -->
|
||||
<div id="woEquipModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
|
||||
<div class="card" style="width:500px;max-width:95vw;position:relative">
|
||||
<button onclick="closeEquipModal()"
|
||||
style="position:absolute;top:10px;right:12px;background:none;border:none;
|
||||
color:var(--gray);font-size:20px;cursor:pointer">×</button>
|
||||
<div class="card-header" id="equipModalTitle">⚙️ Agregar Equipo a esta Orden</div>
|
||||
<input type="hidden" id="woEquipEditId" value="">
|
||||
<div class="form-group mb-3">
|
||||
<label>Equipo de la Embarcación</label>
|
||||
<select id="woEquipSelect">
|
||||
<option value="">— Sin equipo registrado (manual) —</option>
|
||||
{% for e in vessel_equipment %}
|
||||
<option value="{{ e.id }}">{{ e.name }} {% if e.serial_number %}· S/N: {{ e.serial_number }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Trabajo Realizado en este Equipo *</label>
|
||||
<textarea id="woEquipDesc" rows="3" placeholder="Ej: Cambio de filtros de aceite y combustible..."></textarea>
|
||||
</div>
|
||||
<div class="form-grid mb-3">
|
||||
<div class="form-group">
|
||||
<label>Horas Trabajadas</label>
|
||||
<input type="number" id="woEquipHours" step="0.01" value="0" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tarifa $/h</label>
|
||||
<input type="number" id="woEquipRate" step="0.01" value="{{ order.labor_rate or 0 }}" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Notas adicionales</label>
|
||||
<input type="text" id="woEquipNotes" placeholder="Observaciones, proximo servicio, etc.">
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="saveWoEquip()" class="btn btn-primary" id="btnSaveEquip">+ Agregar</button>
|
||||
<button onclick="closeEquipModal()" class="btn btn-secondary">Cancelar</button>
|
||||
</div>
|
||||
<div id="woEquipStatus" style="margin-top:10px;font-size:13px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAUSA TÉCNICA Y REPARACIONES -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">⚠️ Causa Técnica y Reparaciones</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>Causa técnica de la falla</label>
|
||||
<textarea id="root_cause" rows="3" placeholder="Describe la causa raíz del problema...">{{ order.root_cause or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Reparaciones realizadas</label>
|
||||
<textarea id="repairs_done" rows="3" placeholder="Describe detalladamente las reparaciones efectuadas...">{{ order.repairs_done or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="saveTechFields()" class="btn btn-secondary btn-sm mt-4">💾 Guardar</button>
|
||||
<span id="saveStatus" style="font-size:12px;color:var(--success);margin-left:10px"></span>
|
||||
</div>
|
||||
|
||||
<!-- FOTOS -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header flex justify-between">
|
||||
<span>📸 Evidencia Fotográfica</span>
|
||||
{% if order.status != 'cancelled' %}
|
||||
<button onclick="document.getElementById('photoModal').style.display='flex'" class="btn btn-sm btn-primary">+ Agregar Foto</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% set before_photos = photos|selectattr('photo_type','eq','before')|list %}
|
||||
{% set after_photos = photos|selectattr('photo_type','eq','after')|list %}
|
||||
|
||||
{% if before_photos %}
|
||||
<div style="margin-bottom:16px">
|
||||
<div style="font-size:12px;color:var(--warn);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:10px;font-weight:600">⬅ Antes</div>
|
||||
<div class="photo-grid">
|
||||
{% for p in before_photos %}
|
||||
<div class="photo-card before">
|
||||
<img src="/static/uploads/photos/{{ p.filename }}" onclick="openPhoto(this.src)" style="cursor:pointer">
|
||||
<div class="photo-label" style="display:flex;align-items:center;gap:4px">
|
||||
<span style="font-size:11px;color:var(--gray)">Antes</span>
|
||||
<input type="text" value="{{ p.caption or '' }}"
|
||||
onchange="updateCaption({{ p.id }}, this.value)"
|
||||
placeholder="Agregar descripción..."
|
||||
style="flex:1;font-size:11px;background:transparent;border:none;
|
||||
border-bottom:1px dashed rgba(255,255,255,0.2);color:var(--white);
|
||||
padding:2px 4px;outline:none;min-width:0">
|
||||
</div>
|
||||
<button class="photo-del" onclick="deletePhoto({{ p.id }}, this.closest('.photo-card'))">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if after_photos %}
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--success);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:10px;font-weight:600">➡ Después</div>
|
||||
<div class="photo-grid">
|
||||
{% for p in after_photos %}
|
||||
<div class="photo-card after">
|
||||
<img src="/static/uploads/photos/{{ p.filename }}" onclick="openPhoto(this.src)" style="cursor:pointer">
|
||||
<div class="photo-label" style="display:flex;align-items:center;gap:4px">
|
||||
<span style="font-size:11px;color:var(--gray)">Después</span>
|
||||
<input type="text" value="{{ p.caption or '' }}"
|
||||
onchange="updateCaption({{ p.id }}, this.value)"
|
||||
placeholder="Agregar descripción..."
|
||||
style="flex:1;font-size:11px;background:transparent;border:none;
|
||||
border-bottom:1px dashed rgba(255,255,255,0.2);color:var(--white);
|
||||
padding:2px 4px;outline:none;min-width:0">
|
||||
</div>
|
||||
<button class="photo-del" onclick="deletePhoto({{ p.id }}, this.closest('.photo-card'))">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not photos %}
|
||||
<p class="text-gray" style="font-size:13px">Sin fotos aún. Agrega evidencia fotográfica del trabajo.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- REPUESTOS USADOS -->
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between">
|
||||
<span>🔩 Repuestos Utilizados</span>
|
||||
{% if order.status != 'cancelled' %}
|
||||
<button onclick="document.getElementById('partModal').style.display='flex'" class="btn btn-sm btn-primary">+ Agregar Repuesto</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if parts_used %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Repuesto</th><th>Descripción</th><th>Cantidad</th><th>Precio Unit.</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in parts_used %}
|
||||
<tr>
|
||||
<td>{{ p.part_name or '—' }}{% if p.part_number %} <span class="text-gray">({{ p.part_number }})</span>{% endif %}</td>
|
||||
<td class="text-gray">{{ p.description or '' }}</td>
|
||||
<td>{{ p.quantity }}</td>
|
||||
<td>${{ "%.2f"|format(p.unit_cost) }}</td>
|
||||
<td class="text-cyan">${{ "%.2f"|format(p.total_cost) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray" style="font-size:13px">Sin repuestos registrados.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- MODAL FOTO -->
|
||||
<div id="photoModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
|
||||
<div class="card" style="width:420px;max-width:95vw">
|
||||
<div class="card-header">📸 Agregar Foto</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Tipo</label>
|
||||
<select id="photoType">
|
||||
<option value="before">Antes</option>
|
||||
<option value="after">Después</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Foto (cámara o galería)</label>
|
||||
<input type="file" id="photoFile" accept="image/*" capture="environment" style="padding:8px">
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Descripción (opcional)</label>
|
||||
<input type="text" id="photoCaption" placeholder="Ej: Válvula hidráulica antes de cambio">
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="uploadPhoto()" class="btn btn-primary">📤 Subir</button>
|
||||
<button onclick="document.getElementById('photoModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
|
||||
</div>
|
||||
<div id="uploadStatus" style="margin-top:10px;font-size:13px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL REPUESTO -->
|
||||
<div id="partModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
|
||||
<div class="card" style="width:460px;max-width:95vw">
|
||||
<div class="card-header">🔩 Agregar Repuesto</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Repuesto del Inventario</label>
|
||||
<select id="partSelect" onchange="fillPartInfo()">
|
||||
<option value="">-- Sin inventario (manual) --</option>
|
||||
{% for p in available_parts %}
|
||||
<option value="{{ p.id }}" data-price="{{ p.sale_price }}" data-stock="{{ p.quantity }}">
|
||||
{{ p.name }} {% if p.part_number %}({{ p.part_number }}){% endif %} — Stock: {{ p.quantity }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Descripción (si es manual)</label>
|
||||
<input type="text" id="partDesc" placeholder="Descripción del material">
|
||||
</div>
|
||||
<div class="form-grid mb-4">
|
||||
<div class="form-group">
|
||||
<label>Cantidad</label>
|
||||
<input type="number" id="partQty" value="1" min="0.01" step="0.01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Precio Unitario ($)</label>
|
||||
<input type="number" id="partCost" value="0" step="0.01">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="addPart()" class="btn btn-primary">➕ Agregar</button>
|
||||
<button onclick="document.getElementById('partModal').style.display='none'" class="btn btn-secondary">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EMAIL LOG -->
|
||||
{% if email_log %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">📧 Historial de Envíos</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Fecha</th><th>Destinatario</th><th>Idioma</th><th>Estado</th><th>Enviado por</th><th>PDF</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in email_log %}
|
||||
<tr>
|
||||
<td class="text-gray" style="font-size:12px;white-space:nowrap">{{ e.sent_at[:16] }}</td>
|
||||
<td>
|
||||
<div style="font-weight:600;font-size:13px">{{ e.to_name or '—' }}</div>
|
||||
<div style="font-size:11px;color:var(--gray)">{{ e.to_email }}</div>
|
||||
</td>
|
||||
<td><span class="badge badge-open" style="font-size:10px">{{ e.lang.upper() }}</span></td>
|
||||
<td>
|
||||
{% if e.status == 'sent' %}
|
||||
<span class="badge badge-completed">✅ Enviado</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger" style="cursor:help"
|
||||
title="{{ e.error_msg or 'Error desconocido' }}">❌ Fallido</span>
|
||||
{% if e.error_msg %}
|
||||
<div style="font-size:10px;color:var(--danger);max-width:200px;margin-top:2px">
|
||||
{{ e.error_msg[:80] }}{% if e.error_msg|length > 80 %}...{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-gray" style="font-size:12px">{{ e.sent_by or '—' }}</td>
|
||||
<td>
|
||||
{% if e.pdf_filename %}
|
||||
<a href="/static/uploads/pdfs/{{ e.pdf_filename }}" target="_blank" class="btn btn-sm btn-secondary">📄</a>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- FIRMAS -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">✍️ Firmas</div>
|
||||
<div class="grid-2">
|
||||
|
||||
<!-- Firma Técnico -->
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">
|
||||
Técnico: {{ order.technician or '—' }}
|
||||
</div>
|
||||
{% if order.signature_tech %}
|
||||
<img src="/static/uploads/signatures/{{ order.signature_tech }}"
|
||||
style="background:white;border-radius:8px;border:1px solid rgba(0,180,216,0.2);max-width:100%;height:120px;object-fit:contain;display:block">
|
||||
<button onclick="clearSignature('tech')" class="btn btn-sm btn-secondary" style="margin-top:8px">🗑 Borrar</button>
|
||||
{% else %}
|
||||
<canvas id="sigTech" width="340" height="120"
|
||||
style="background:white;border-radius:8px;border:2px solid rgba(0,180,216,0.3);
|
||||
touch-action:none;cursor:crosshair;display:block;max-width:100%"></canvas>
|
||||
<div class="flex gap-2" style="margin-top:8px">
|
||||
<button onclick="saveSignature('tech')" class="btn btn-sm btn-primary">💾 Guardar</button>
|
||||
<button onclick="clearCanvas('sigTech')" class="btn btn-sm btn-secondary">🗑 Limpiar</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Firma Cliente/Capitán -->
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">
|
||||
Capitán / Cliente
|
||||
</div>
|
||||
{% if order.signature_client %}
|
||||
<img src="/static/uploads/signatures/{{ order.signature_client }}"
|
||||
style="background:white;border-radius:8px;border:1px solid rgba(0,180,216,0.2);max-width:100%;height:120px;object-fit:contain;display:block">
|
||||
<button onclick="clearSignature('client')" class="btn btn-sm btn-secondary" style="margin-top:8px">🗑 Borrar</button>
|
||||
{% else %}
|
||||
<canvas id="sigClient" width="340" height="120"
|
||||
style="background:white;border-radius:8px;border:2px solid rgba(0,180,216,0.3);
|
||||
touch-action:none;cursor:crosshair;display:block;max-width:100%"></canvas>
|
||||
<div class="flex gap-2" style="margin-top:8px">
|
||||
<button onclick="saveSignature('client')" class="btn btn-sm btn-primary">💾 Guardar</button>
|
||||
<button onclick="clearCanvas('sigClient')" class="btn btn-sm btn-secondary">🗑 Limpiar</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SHARE MODAL -->
|
||||
<div id="shareModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;align-items:center;justify-content:center">
|
||||
<div class="card" style="width:480px;max-width:95vw">
|
||||
<div class="card-header">📤 Enviar Reporte</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div style="display:flex;gap:4px;margin-bottom:16px;background:rgba(0,0,0,0.2);border-radius:8px;padding:4px">
|
||||
<button onclick="showTab('email')" id="tabEmail" class="btn btn-sm btn-primary" style="flex:1">✉️ Email</button>
|
||||
<button onclick="showTab('share')" id="tabShare" class="btn btn-sm btn-secondary" style="flex:1">📱 WhatsApp / SMS</button>
|
||||
</div>
|
||||
|
||||
<!-- EMAIL TAB -->
|
||||
<div id="tabEmailContent">
|
||||
<div class="form-group mb-3">
|
||||
<label>Para (nombre)</label>
|
||||
<input type="text" id="sendToName" value="{{ vessel_captain_name or vessel_owner_name or '' }}"
|
||||
placeholder="Nombre del destinatario">
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Email *</label>
|
||||
<input type="email" id="sendToEmail"
|
||||
value="{{ vessel_captain_email or vessel_owner_email or '' }}"
|
||||
placeholder="email@ejemplo.com">
|
||||
{% if vessel_captain_email or vessel_owner_email %}
|
||||
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
{% if vessel_captain_email %}
|
||||
<button onclick="document.getElementById('sendToEmail').value='{{ vessel_captain_email }}';document.getElementById('sendToName').value='{{ vessel_captain_name }}'"
|
||||
class="btn btn-sm btn-secondary" style="font-size:11px">⚓ {{ vessel_captain_name or 'Capitán' }}</button>
|
||||
{% endif %}
|
||||
{% if vessel_owner_email %}
|
||||
<button onclick="document.getElementById('sendToEmail').value='{{ vessel_owner_email }}';document.getElementById('sendToName').value='{{ vessel_owner_name }}'"
|
||||
class="btn btn-sm btn-secondary" style="font-size:11px">👤 {{ vessel_owner_name or 'Propietario' }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label>Idioma del Reporte</label>
|
||||
<select id="sendLang" style="width:100%;padding:8px;border-radius:6px;background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.2);color:var(--white)">
|
||||
<option value="es">Español</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<label>Asunto</label>
|
||||
<input type="text" id="sendSubject"
|
||||
value="Reporte de Mantenimiento — {{ order.order_number }}">
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="sendEmail()" class="btn btn-primary">📧 Enviar Email</button>
|
||||
<button onclick="closeShareModal()" class="btn btn-secondary">Cancelar</button>
|
||||
</div>
|
||||
<div id="emailStatus" style="margin-top:10px;font-size:13px"></div>
|
||||
</div>
|
||||
|
||||
<!-- SHARE TAB -->
|
||||
<div id="tabShareContent" style="display:none">
|
||||
<p style="font-size:13px;color:var(--gray);margin-bottom:16px">
|
||||
Primero guardamos el PDF en el servidor para generar un link compartible.
|
||||
</p>
|
||||
<div id="shareLinks" style="display:none">
|
||||
<div class="form-group mb-3">
|
||||
<label>Link del PDF</label>
|
||||
<input type="text" id="pdfLink" readonly
|
||||
style="font-size:12px;cursor:pointer"
|
||||
onclick="this.select();document.execCommand('copy')">
|
||||
<div style="font-size:11px;color:var(--gray);margin-top:3px">Toca para copiar</div>
|
||||
</div>
|
||||
<div class="flex gap-3" style="flex-wrap:wrap">
|
||||
<a id="waLink" href="#" target="_blank"
|
||||
style="background:#25D366;color:white;padding:10px 20px;border-radius:8px;
|
||||
text-decoration:none;font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:20px">💬</span> WhatsApp
|
||||
</a>
|
||||
<a id="smsLink" href="#"
|
||||
style="background:#0a84ff;color:white;padding:10px 20px;border-radius:8px;
|
||||
text-decoration:none;font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:20px">💬</span> SMS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="shareLoading" style="font-size:13px;color:var(--gray)"></div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button onclick="generateShareLinks()" class="btn btn-primary">🔗 Generar Links</button>
|
||||
<button onclick="closeShareModal()" class="btn btn-secondary">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIGHTBOX -->
|
||||
<div id="lightbox" onclick="this.style.display='none'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.95);z-index:2000;align-items:center;justify-content:center;cursor:zoom-out">
|
||||
<img id="lightboxImg" style="max-width:95vw;max-height:95vh;border-radius:8px">
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const WO_ID = {{ order.id }};
|
||||
|
||||
function saveTechFields() {
|
||||
fetch(`/work-orders/${WO_ID}/update-fields`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
root_cause: document.getElementById('root_cause').value,
|
||||
repairs_done: document.getElementById('repairs_done').value
|
||||
})
|
||||
}).then(() => {
|
||||
document.getElementById('saveStatus').textContent = '✓ Guardado';
|
||||
setTimeout(() => document.getElementById('saveStatus').textContent = '', 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteThisWO() {
|
||||
if (!confirm('¿Eliminar esta orden de trabajo? Esta acción no se puede deshacer.')) return;
|
||||
fetch('/work-orders/' + WO_ID + '/delete', {method:'DELETE'})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) window.location.href = '/work-orders';
|
||||
else alert('Error: ' + d.error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function updateHours() {
|
||||
const h = parseFloat(document.getElementById('labor_hours').value) || 0;
|
||||
const r = parseFloat(document.getElementById('labor_rate').value) || 0;
|
||||
const st = document.getElementById('hoursStatus');
|
||||
st.textContent = 'Guardando...';
|
||||
st.style.color = 'var(--gray)';
|
||||
fetch(`/api/update-labor/${WO_ID}`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({labor_hours: h, labor_rate: r})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
st.textContent = 'Guardado';
|
||||
st.style.color = 'var(--success)';
|
||||
setTimeout(() => location.reload(), 600);
|
||||
} else {
|
||||
st.textContent = 'Error';
|
||||
st.style.color = 'var(--danger)';
|
||||
}
|
||||
})
|
||||
.catch(e => { st.textContent = 'Error: ' + e; st.style.color = 'var(--danger)'; });
|
||||
}
|
||||
|
||||
function uploadPhoto() {
|
||||
const file = document.getElementById('photoFile').files[0];
|
||||
if (!file) { alert('Selecciona una foto'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('photo', file);
|
||||
fd.append('photo_type', document.getElementById('photoType').value);
|
||||
fd.append('caption', document.getElementById('photoCaption').value);
|
||||
document.getElementById('uploadStatus').textContent = 'Subiendo...';
|
||||
fetch(`/work-orders/${WO_ID}/upload-photo`, { method:'POST', body: fd })
|
||||
.then(r => r.json()).then(d => {
|
||||
if (d.ok) location.reload();
|
||||
else document.getElementById('uploadStatus').textContent = 'Error: ' + d.error;
|
||||
});
|
||||
}
|
||||
|
||||
function deletePhoto(id, el) {
|
||||
if (!confirm('¿Eliminar esta foto?')) return;
|
||||
fetch(`/api/delete-photo/${id}`, {method:'DELETE'})
|
||||
.then(() => el.remove());
|
||||
}
|
||||
|
||||
function updateCaption(id, caption) {
|
||||
fetch(`/api/update-photo-caption/${id}`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({caption})
|
||||
});
|
||||
}
|
||||
|
||||
function fillPartInfo() {
|
||||
const sel = document.getElementById('partSelect');
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
if (opt.value) {
|
||||
document.getElementById('partCost').value = opt.dataset.price || 0;
|
||||
}
|
||||
}
|
||||
|
||||
function addPart() {
|
||||
const data = {
|
||||
part_id: document.getElementById('partSelect').value || null,
|
||||
description: document.getElementById('partDesc').value,
|
||||
quantity: document.getElementById('partQty').value,
|
||||
unit_cost: document.getElementById('partCost').value
|
||||
};
|
||||
fetch(`/work-orders/${WO_ID}/add-part`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
}).then(() => location.reload());
|
||||
}
|
||||
|
||||
function openEquipModal() {
|
||||
document.getElementById('woEquipEditId').value = '';
|
||||
document.getElementById('woEquipSelect').value = '';
|
||||
document.getElementById('woEquipDesc').value = '';
|
||||
document.getElementById('woEquipHours').value = '0';
|
||||
document.getElementById('woEquipNotes').value = '';
|
||||
document.getElementById('equipModalTitle').textContent = '⚙️ Agregar Equipo a esta Orden';
|
||||
document.getElementById('btnSaveEquip').textContent = '+ Agregar';
|
||||
document.getElementById('woEquipStatus').textContent = '';
|
||||
document.getElementById('woEquipModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeEquipModal() {
|
||||
document.getElementById('woEquipModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function editWoEquip(id, name, hours, rate, desc, notes) {
|
||||
document.getElementById('woEquipEditId').value = id;
|
||||
document.getElementById('woEquipDesc').value = desc;
|
||||
document.getElementById('woEquipHours').value = hours;
|
||||
document.getElementById('woEquipRate').value = rate;
|
||||
document.getElementById('woEquipNotes').value = notes;
|
||||
document.getElementById('equipModalTitle').textContent = '✏️ Editar Equipo: ' + name;
|
||||
document.getElementById('btnSaveEquip').textContent = '💾 Guardar';
|
||||
document.getElementById('woEquipStatus').textContent = '';
|
||||
document.getElementById('woEquipModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function saveWoEquip() {
|
||||
const editId = document.getElementById('woEquipEditId').value;
|
||||
const desc = document.getElementById('woEquipDesc').value.trim();
|
||||
const statusEl = document.getElementById('woEquipStatus');
|
||||
if (!desc) { alert('Describe el trabajo realizado'); return; }
|
||||
const payload = {
|
||||
equipment_id: document.getElementById('woEquipSelect').value || null,
|
||||
description: desc,
|
||||
notes: document.getElementById('woEquipNotes').value,
|
||||
labor_hours: parseFloat(document.getElementById('woEquipHours').value) || 0,
|
||||
labor_rate: parseFloat(document.getElementById('woEquipRate').value) || 0
|
||||
};
|
||||
statusEl.textContent = 'Guardando...';
|
||||
statusEl.style.color = 'var(--gray)';
|
||||
const url = editId
|
||||
? '/work-orders/' + WO_ID + '/update-equipment/' + editId
|
||||
: '/work-orders/' + WO_ID + '/add-equipment';
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) { statusEl.textContent = '✓'; setTimeout(() => location.reload(), 400); }
|
||||
else { statusEl.textContent = 'Error: ' + (d.error || 'desconocido'); statusEl.style.color = 'var(--danger)'; }
|
||||
})
|
||||
.catch(e => { statusEl.textContent = 'Error: ' + e; statusEl.style.color = 'var(--danger)'; });
|
||||
}
|
||||
|
||||
function removeWoEquip(id, row) {
|
||||
if (!confirm('Quitar este equipo de la orden?')) return;
|
||||
fetch(`/work-orders/${WO_ID}/remove-equipment/${id}`, {method:'DELETE'})
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.ok) row.remove(); })
|
||||
.catch(e => console.error(e));
|
||||
}
|
||||
|
||||
// ── SHARE & EMAIL ─────────────────────────────────────────────────────────────
|
||||
function openShareModal() {
|
||||
document.getElementById('shareModal').style.display = 'flex';
|
||||
showTab('email');
|
||||
}
|
||||
function closeShareModal() {
|
||||
document.getElementById('shareModal').style.display = 'none';
|
||||
}
|
||||
function showTab(tab) {
|
||||
document.getElementById('tabEmailContent').style.display = tab==='email' ? '' : 'none';
|
||||
document.getElementById('tabShareContent').style.display = tab==='share' ? '' : 'none';
|
||||
document.getElementById('tabEmail').className = 'btn btn-sm ' + (tab==='email' ? 'btn-primary' : 'btn-secondary');
|
||||
document.getElementById('tabShare').className = 'btn btn-sm ' + (tab==='share' ? 'btn-primary' : 'btn-secondary');
|
||||
document.getElementById('tabEmail').style.flex = '1';
|
||||
document.getElementById('tabShare').style.flex = '1';
|
||||
}
|
||||
function sendEmail() {
|
||||
const to = document.getElementById('sendToEmail').value.trim();
|
||||
const name = document.getElementById('sendToName').value.trim();
|
||||
const subj = document.getElementById('sendSubject').value.trim();
|
||||
const lang = document.getElementById('sendLang').value;
|
||||
const st = document.getElementById('emailStatus');
|
||||
if (!to) { st.textContent = 'Ingresa un email.'; st.style.color='var(--danger)'; return; }
|
||||
st.textContent = 'Generando PDF y enviando...'; st.style.color = 'var(--gray)';
|
||||
fetch(`/work-orders/${WO_ID}/send`, {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({to_email:to, to_name:name, subject:subj, lang:lang})
|
||||
}).then(r=>r.json()).then(d=>{
|
||||
if (d.ok) {
|
||||
st.textContent = '✅ Email enviado correctamente.';
|
||||
st.style.color = 'var(--success)';
|
||||
} else {
|
||||
st.textContent = '❌ ' + (d.error || 'Error desconocido');
|
||||
st.style.color = 'var(--danger)';
|
||||
}
|
||||
});
|
||||
}
|
||||
function generateShareLinks() {
|
||||
const loading = document.getElementById('shareLoading');
|
||||
loading.textContent = 'Guardando PDF...';
|
||||
// First save PDF
|
||||
fetch(`/work-orders/${WO_ID}/save-pdf`, {method:'POST'})
|
||||
.then(r=>r.json()).then(saved => {
|
||||
loading.textContent = 'Generando links...';
|
||||
return fetch(`/work-orders/${WO_ID}/share`);
|
||||
})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if (!d.ok) { loading.textContent = 'Error generando links.'; return; }
|
||||
document.getElementById('pdfLink').value = d.pdf_url;
|
||||
document.getElementById('waLink').href = d.wa_link;
|
||||
document.getElementById('smsLink').href = d.sms_link;
|
||||
document.getElementById('shareLinks').style.display = '';
|
||||
loading.textContent = '';
|
||||
});
|
||||
}
|
||||
|
||||
openPhoto = function(src) {
|
||||
document.getElementById('lightboxImg').src = src;
|
||||
document.getElementById('lightbox').style.display = 'flex';
|
||||
}
|
||||
function initSignaturePad(canvasId) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Scale for retina
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let drawing = false;
|
||||
let lastX = 0, lastY = 0;
|
||||
|
||||
function getPos(e) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const src = e.touches ? e.touches[0] : e;
|
||||
return [(src.clientX - r.left) * (canvas.width / r.width),
|
||||
(src.clientY - r.top) * (canvas.height / r.height)];
|
||||
}
|
||||
function start(e) { e.preventDefault(); drawing = true; [lastX, lastY] = getPos(e); }
|
||||
function draw(e) {
|
||||
if (!drawing) return; e.preventDefault();
|
||||
const [x, y] = getPos(e);
|
||||
ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(x, y);
|
||||
ctx.strokeStyle = '#0a1628'; ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
|
||||
[lastX, lastY] = [x, y];
|
||||
}
|
||||
function stop() { drawing = false; }
|
||||
|
||||
canvas.addEventListener('mousedown', start);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stop);
|
||||
canvas.addEventListener('mouseleave', stop);
|
||||
canvas.addEventListener('touchstart', start, {passive:false});
|
||||
canvas.addEventListener('touchmove', draw, {passive:false});
|
||||
canvas.addEventListener('touchend', stop);
|
||||
}
|
||||
|
||||
function clearCanvas(canvasId) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
function saveSignature(who) {
|
||||
const canvasId = who === 'tech' ? 'sigTech' : 'sigClient';
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
fetch(`/work-orders/${WO_ID}/save-signature`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({who, dataUrl})
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) location.reload();
|
||||
else alert('Error al guardar firma');
|
||||
});
|
||||
}
|
||||
|
||||
function clearSignature(who) {
|
||||
if (!confirm('¿Borrar esta firma?')) return;
|
||||
fetch(`/work-orders/${WO_ID}/clear-signature`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({who})
|
||||
}).then(() => location.reload());
|
||||
}
|
||||
|
||||
initSignaturePad('sigTech');
|
||||
initSignaturePad('sigClient');
|
||||
|
||||
function openPhoto(src) {
|
||||
document.getElementById('lightboxImg').src = src;
|
||||
document.getElementById('lightbox').style.display = 'flex';
|
||||
}
|
||||
|
||||
function addEmail(email) {
|
||||
const input = document.getElementById('assignEmails');
|
||||
const current = input.value.trim();
|
||||
if (current && !current.includes(email)) {
|
||||
input.value = current + '; ' + email;
|
||||
} else if (!current) {
|
||||
input.value = email;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ASSIGN MODAL ─────────────────────────────────────────────────────────────
|
||||
function openAssignModal() {
|
||||
document.getElementById('assignModal').style.display = 'flex';
|
||||
}
|
||||
function closeAssignModal() {
|
||||
document.getElementById('assignModal').style.display = 'none';
|
||||
}
|
||||
function sendAssignment() {
|
||||
const emails = document.getElementById('assignEmails').value.trim();
|
||||
const message = document.getElementById('assignMessage').value.trim();
|
||||
if (!emails) { alert('Ingresa al menos un email'); return; }
|
||||
const btn = document.getElementById('assignBtn');
|
||||
btn.disabled = true; btn.textContent = 'Enviando...';
|
||||
fetch('/work-orders/{{ order.id }}/assign', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({to_emails: emails, message: message})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
btn.disabled = false; btn.textContent = '📨 Asignar y Enviar';
|
||||
if (d.ok) {
|
||||
closeAssignModal();
|
||||
alert('✅ WO asignada y enviada con PDF adjunto.');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error al enviar: ' + (d.error || 'desconocido'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-notify on status change
|
||||
function setStatus(newStatus) {
|
||||
fetch(`/work-orders/${WO_ID}/update-status`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({status: newStatus})
|
||||
}).then(() => {
|
||||
{% if order.assigned_to %}
|
||||
fetch('/work-orders/{{ order.id }}/notify-status', {method:'POST'});
|
||||
{% endif %}
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReopen() {
|
||||
if (!confirm('¿Reabrir esta orden de trabajo? El estado volverá a "En Progreso" y podrás editarla nuevamente.')) return;
|
||||
setStatus('in_progress');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ASSIGN MODAL -->
|
||||
<div id="assignModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);
|
||||
z-index:1000;align-items:center;justify-content:center">
|
||||
<div style="background:var(--navy2);border:1px solid rgba(0,180,216,0.3);border-radius:12px;
|
||||
padding:24px;width:100%;max-width:500px;margin:20px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h3 style="margin:0;color:var(--white)">👤 Asignar Orden de Trabajo</h3>
|
||||
<button onclick="closeAssignModal()" style="background:none;border:none;color:var(--gray);font-size:20px;cursor:pointer">✕</button>
|
||||
</div>
|
||||
<div style="margin-bottom:8px;font-size:12px;color:var(--cyan)">{{ order.order_number }} — {{ order.vessel_name }}</div>
|
||||
{% if order.assigned_to %}
|
||||
<div style="background:rgba(0,180,216,0.08);border:1px solid rgba(0,180,216,0.2);border-radius:6px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:var(--gray)">
|
||||
Actualmente asignado a: <strong style="color:var(--cyan)">{{ order.assigned_to }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group" style="margin-bottom:12px">
|
||||
<label>Emails (separados por coma o punto y coma)</label>
|
||||
<input type="text" id="assignEmails"
|
||||
placeholder="tecnico@empresa.com; supervisor@empresa.com"
|
||||
style="width:100%;padding:8px 12px;border-radius:6px;
|
||||
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
|
||||
color:var(--white);font-size:13px">
|
||||
{% if users %}
|
||||
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
{% for u in users %}
|
||||
<span onclick="addEmail('{{ u.email }}')"
|
||||
style="background:rgba(0,180,216,0.1);border:1px solid rgba(0,180,216,0.2);
|
||||
color:var(--cyan);padding:3px 8px;border-radius:12px;font-size:11px;
|
||||
cursor:pointer" title="{{ u.full_name }}">
|
||||
+ {{ u.full_name or u.username }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>Mensaje adicional (opcional)</label>
|
||||
<textarea id="assignMessage" rows="3"
|
||||
style="width:100%;padding:8px 12px;border-radius:6px;
|
||||
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
|
||||
color:var(--white);font-size:13px"
|
||||
placeholder="Instrucciones especiales, prioridad, materiales necesarios..."></textarea>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--gray);margin-bottom:12px">
|
||||
📎 Se enviará el PDF de la WO adjunto al email
|
||||
</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button id="assignBtn" onclick="sendAssignment()" class="btn btn-primary" style="flex:1">📨 Asignar y Enviar</button>
|
||||
<button onclick="closeAssignModal()" class="btn btn-secondary">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Editar {{ order.order_number }}{% endblock %}
|
||||
{% block page_title %}✏️ Editar {{ order.order_number }} — {{ order.vessel_name }}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('work_order_detail', woid=order.id) }}" class="btn btn-secondary">← Cancelar</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:820px">
|
||||
<form method="POST">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Sistema Principal</label>
|
||||
<select name="system_id">
|
||||
<option value="">-- Sin sistema --</option>
|
||||
{% for s in systems %}
|
||||
<option value="{{ s.id }}" {% if order.system_id==s.id %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tipo de Facturación *</label>
|
||||
<select name="billing_type" required>
|
||||
<option value="labor_materials" {% if order.billing_type=='labor_materials' or not order.billing_type %}selected{% endif %}>
|
||||
Mano de obra + Materiales (discriminado)
|
||||
</option>
|
||||
<option value="lump_sum" {% if order.billing_type=='lump_sum' %}selected{% endif %}>
|
||||
A todo costo (precio fijo todo incluido)
|
||||
</option>
|
||||
<option value="labor_only" {% if order.billing_type=='labor_only' %}selected{% endif %}>
|
||||
Solo mano de obra
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tipo de Trabajo</label>
|
||||
<select name="work_type">
|
||||
<option value="">-- Seleccionar --</option>
|
||||
{% for wt in ['Preventive','Corrective','Inspection','Installation','Other'] %}
|
||||
<option value="{{ wt }}" {% if order.work_type==wt %}selected{% endif %}>{{ wt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Scope — Resumen del Trabajo *</label>
|
||||
<input type="text" name="scope" value="{{ order.scope or '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Técnico</label>
|
||||
<input type="text" name="technician" value="{{ order.technician or '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Fecha Inicio</label>
|
||||
<input type="date" name="start_date" value="{{ order.start_date or '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Horas Motor (inicio)</label>
|
||||
<input type="number" step="0.1" name="engine_hours_start" value="{{ order.engine_hours_start or '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tarifa M.O. ($/h)</label>
|
||||
<input type="number" step="0.01" name="labor_rate" value="{{ order.labor_rate or 0 }}">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Descripción Detallada</label>
|
||||
<textarea name="description" rows="4">{{ order.description or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Notas Internas</label>
|
||||
<textarea name="notes" rows="2">{{ order.notes or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar Cambios</button>
|
||||
<a href="{{ url_for('work_order_detail', woid=order.id) }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Nueva Orden de Trabajo{% endblock %}
|
||||
{% block page_title %}Nueva Orden de Trabajo{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('work_orders') }}" class="btn btn-secondary">← Volver</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card" style="max-width:820px">
|
||||
<form method="POST">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Sistema Principal *</label>
|
||||
<select name="system_id" id="systemSelect" required>
|
||||
<option value="">-- Seleccionar Sistema --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="color:var(--cyan)">🚢 Embarcación *</label>
|
||||
<select name="vessel_id" id="vesselSelect" required onchange="loadEquipment(this.value)"
|
||||
style="border-color:rgba(0,180,216,0.4);font-weight:600">
|
||||
<option value="">— Seleccionar Embarcación —</option>
|
||||
{% for v in vessels %}
|
||||
<option value="{{ v.id }}" {% if preselect_vessel and preselect_vessel==v.id|string %}selected{% endif %}>{{ v.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Equipo Específico (opcional)</label>
|
||||
<select name="equipment_id" id="equipmentSelect">
|
||||
<option value="">— General / Sin equipo específico —</option>
|
||||
{% for e in equipment_list %}
|
||||
<option value="{{ e.id }}">{{ e.name }} {% if e.serial_number %}· S/N: {{ e.serial_number }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Scope — Resumen del Trabajo *</label>
|
||||
<input type="text" name="scope" required
|
||||
placeholder="Ej: Diagnóstico y cambio de cargador de baterías estribor">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tipo de Facturación *</label>
|
||||
<select name="billing_type" required>
|
||||
<option value="labor_materials" {% if not order or order.billing_type=='labor_materials' %}selected{% endif %}>
|
||||
Mano de obra + Materiales (discriminado)
|
||||
</option>
|
||||
<option value="lump_sum" {% if order and order.billing_type=='lump_sum' %}selected{% endif %}>
|
||||
A todo costo (precio fijo todo incluido)
|
||||
</option>
|
||||
<option value="labor_only" {% if order and order.billing_type=='labor_only' %}selected{% endif %}>
|
||||
Solo mano de obra
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tipo de Trabajo</label>
|
||||
<select name="work_type">
|
||||
<option value="">-- Seleccionar --</option>
|
||||
<option value="Preventive">Preventivo</option>
|
||||
<option value="Corrective">Correctivo</option>
|
||||
<option value="Inspection">Inspección</option>
|
||||
<option value="Installation">Instalación</option>
|
||||
<option value="Other">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Técnico</label>
|
||||
<input type="text" name="technician">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Fecha Inicio</label>
|
||||
<input type="date" name="start_date" id="startDate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Horas Motor (inicio)</label>
|
||||
<input type="number" step="0.1" name="engine_hours_start">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tarifa Mano de Obra ($/h)</label>
|
||||
<input type="number" step="0.01" name="labor_rate" value="0">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Descripción Detallada</label>
|
||||
<textarea name="description" rows="4"
|
||||
placeholder="Describe en detalle el trabajo a realizar..."></textarea>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Notas Internas</label>
|
||||
<textarea name="notes" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">💾 Crear Orden</button>
|
||||
<a href="{{ url_for('work_orders') }}" class="btn btn-secondary">Cancelar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('startDate').value = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Load systems
|
||||
fetch('/api/systems').then(r=>r.json()).then(data => {
|
||||
const sel = document.getElementById('systemSelect');
|
||||
data.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = s.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
});
|
||||
|
||||
function loadEquipment(vesselId) {
|
||||
const sel = document.getElementById('equipmentSelect');
|
||||
sel.innerHTML = '<option value="">— General / Sin equipo específico —</option>';
|
||||
if (!vesselId) return;
|
||||
fetch(`/api/vessel-equipment/${vesselId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
data.forEach(e => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = e.id;
|
||||
opt.textContent = e.name + (e.serial_number ? ` · S/N: ${e.serial_number}` : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
});
|
||||
}
|
||||
{% if preselect_vessel %}
|
||||
loadEquipment('{{ preselect_vessel }}');
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,185 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Órdenes de Trabajo{% endblock %}
|
||||
{% block page_title %}Órdenes de Trabajo{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('work_order_new') }}" class="btn btn-primary">+ Nueva Orden</a>
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
<style>
|
||||
/* ── Filtros ── */
|
||||
.wo-filters {
|
||||
display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap;
|
||||
}
|
||||
.wo-filters input {
|
||||
flex:1;min-width:180px;padding:7px 12px;border-radius:6px;
|
||||
background:rgba(255,255,255,0.06);border:1px solid rgba(0,180,216,0.25);
|
||||
color:var(--white);font-size:13px;
|
||||
}
|
||||
|
||||
/* ── Tabla (desktop) ── */
|
||||
.wo-table-wrap { display:block; }
|
||||
.wo-cards-wrap { display:none; }
|
||||
|
||||
/* ── Tarjetas (móvil) ── */
|
||||
@media (max-width: 768px) {
|
||||
.wo-table-wrap { display:none; }
|
||||
.wo-cards-wrap { display:block; }
|
||||
}
|
||||
|
||||
.wo-card {
|
||||
background:var(--navy2);
|
||||
border:1px solid rgba(0,180,216,0.12);
|
||||
border-radius:10px;
|
||||
margin-bottom:10px;
|
||||
overflow:hidden;
|
||||
transition:border-color 0.2s;
|
||||
}
|
||||
.wo-card:active { border-color:rgba(0,180,216,0.4); }
|
||||
|
||||
.wo-card-header {
|
||||
display:flex;justify-content:space-between;align-items:center;
|
||||
padding:10px 14px;
|
||||
background:rgba(0,0,0,0.2);
|
||||
border-bottom:1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.wo-card-num { font-size:12px;color:var(--cyan);font-weight:600;font-family:monospace; }
|
||||
.wo-card-body { padding:10px 14px; }
|
||||
.wo-card-vessel {
|
||||
font-size:15px;font-weight:600;color:var(--white);margin-bottom:4px;
|
||||
}
|
||||
.wo-card-scope {
|
||||
font-size:13px;color:var(--gray);margin-bottom:6px;
|
||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
|
||||
}
|
||||
.wo-card-meta {
|
||||
display:flex;gap:10px;flex-wrap:wrap;font-size:12px;color:var(--gray);margin-bottom:10px;
|
||||
}
|
||||
.wo-card-meta span { display:flex;align-items:center;gap:4px; }
|
||||
.wo-card-actions {
|
||||
display:flex;gap:8px;padding:8px 14px;
|
||||
border-top:1px solid rgba(255,255,255,0.05);
|
||||
background:rgba(0,0,0,0.1);
|
||||
}
|
||||
.wo-card-actions a, .wo-card-actions button {
|
||||
flex:1;text-align:center;font-size:13px;padding:8px 4px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="wo-filters">
|
||||
<a href="{{ url_for('work_orders') }}" class="btn btn-sm {% if not status_filter %}btn-primary{% else %}btn-secondary{% endif %}">Todas</a>
|
||||
<a href="{{ url_for('work_orders', status='open') }}" class="btn btn-sm {% if status_filter=='open' %}btn-primary{% else %}btn-secondary{% endif %}">Abiertas</a>
|
||||
<a href="{{ url_for('work_orders', status='in_progress') }}" class="btn btn-sm {% if status_filter=='in_progress' %}btn-warning{% else %}btn-secondary{% endif %}">En Progreso</a>
|
||||
<a href="{{ url_for('work_orders', status='completed') }}" class="btn btn-sm {% if status_filter=='completed' %}btn-success{% else %}btn-secondary{% endif %}">Completadas</a>
|
||||
<input type="text" id="woSearch" placeholder="🔍 Buscar embarcación, scope, técnico..."
|
||||
oninput="filterWO(this.value)">
|
||||
</div>
|
||||
|
||||
<!-- TABLA DESKTOP -->
|
||||
<div class="wo-table-wrap card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Orden</th><th>🚢 Embarcación</th><th>Tipo</th><th>Scope</th><th>Técnico</th><th>Fecha</th><th>Estado</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for o in orders %}
|
||||
<tr class="wo-row" data-search="{{ (o.vessel_name ~ ' ' ~ (o.scope or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ o.order_number)|lower }}">
|
||||
<td class="text-cyan" style="white-space:nowrap;font-family:monospace;font-size:12px">{{ o.order_number }}</td>
|
||||
<td><a href="{{ url_for('vessel_history', vid=o.vessel_id) }}" class="text-cyan" style="font-weight:600">{{ o.vessel_name }}</a></td>
|
||||
<td>{{ o.work_type or '—' }}</td>
|
||||
<td style="max-width:240px">{{ o.scope or (o.description[:60] if o.description else '—') }}</td>
|
||||
<td>{{ o.technician or '—' }}</td>
|
||||
<td class="text-gray" style="white-space:nowrap">{{ o.start_date or o.created_at[:10] }}</td>
|
||||
<td><span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span></td>
|
||||
<td class="flex gap-2" style="white-space:nowrap">
|
||||
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-secondary">Ver</a>
|
||||
{% if o.status != 'completed' %}
|
||||
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️</a>
|
||||
<button onclick="deleteWO({{ o.id }})" class="btn btn-sm btn-danger">🗑️</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-gray" style="text-align:center;padding:30px">No hay órdenes.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="noWOResultsTable" style="display:none;text-align:center;padding:20px;color:var(--gray)">
|
||||
No se encontraron órdenes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TARJETAS MÓVIL -->
|
||||
<div class="wo-cards-wrap">
|
||||
{% for o in orders %}
|
||||
<div class="wo-card" id="card-{{ o.id }}"
|
||||
data-search="{{ (o.vessel_name ~ ' ' ~ (o.scope or '') ~ ' ' ~ (o.technician or '') ~ ' ' ~ o.order_number)|lower }}">
|
||||
<div class="wo-card-header">
|
||||
<span class="wo-card-num">{{ o.order_number }}</span>
|
||||
<span class="badge badge-{{ o.status }}">{{ o.status.replace('_',' ') }}</span>
|
||||
</div>
|
||||
<div class="wo-card-body">
|
||||
<div class="wo-card-vessel">
|
||||
<a href="{{ url_for('vessel_history', vid=o.vessel_id) }}" style="color:var(--cyan)">
|
||||
🚢 {{ o.vessel_name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="wo-card-scope">{{ o.scope or (o.description[:100] if o.description else 'Sin descripción') }}</div>
|
||||
<div class="wo-card-meta">
|
||||
{% if o.work_type %}<span>🔧 {{ o.work_type }}</span>{% endif %}
|
||||
{% if o.technician %}<span>👤 {{ o.technician }}</span>{% endif %}
|
||||
<span>📅 {{ o.start_date or o.created_at[:10] }}</span>
|
||||
{% if o.billing_type == 'lump_sum' %}<span>💰 Todo costo</span>
|
||||
{% elif o.billing_type == 'labor_only' %}<span>🔧 Solo M.O.</span>
|
||||
{% else %}<span>📋 M.O.+Mat.</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wo-card-actions">
|
||||
<a href="{{ url_for('work_order_detail', woid=o.id) }}" class="btn btn-sm btn-primary">Ver detalle</a>
|
||||
{% if o.status != 'completed' %}
|
||||
<a href="{{ url_for('work_order_edit', woid=o.id) }}" class="btn btn-sm btn-secondary">✏️ Editar</a>
|
||||
<button onclick="deleteWO({{ o.id }})" class="btn btn-sm btn-danger">🗑️</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:40px;color:var(--gray)">No hay órdenes.</div>
|
||||
{% endfor %}
|
||||
<div id="noWOResultsCards" style="display:none;text-align:center;padding:20px;color:var(--gray)">
|
||||
No se encontraron órdenes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function filterWO(q) {
|
||||
q = q.toLowerCase().trim();
|
||||
let visibleT = 0, visibleC = 0;
|
||||
document.querySelectorAll('.wo-row').forEach(r => {
|
||||
const show = !q || r.dataset.search.includes(q);
|
||||
r.style.display = show ? '' : 'none';
|
||||
if (show) visibleT++;
|
||||
});
|
||||
document.querySelectorAll('.wo-card').forEach(c => {
|
||||
const show = !q || c.dataset.search.includes(q);
|
||||
c.style.display = show ? '' : 'none';
|
||||
if (show) visibleC++;
|
||||
});
|
||||
document.getElementById('noWOResultsTable').style.display = visibleT === 0 ? 'block' : 'none';
|
||||
document.getElementById('noWOResultsCards').style.display = visibleC === 0 ? 'block' : 'none';
|
||||
}
|
||||
function deleteWO(id) {
|
||||
if (!confirm('¿Eliminar esta orden? Esta acción no se puede deshacer.')) return;
|
||||
fetch('/work-orders/' + id + '/delete', {method:'DELETE'})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) location.reload();
|
||||
else alert('Error: ' + d.error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user