commit 67a0e674ca033b499362d912dfaa8daad011a7c1 Author: aerom Date: Tue May 5 01:54:20 2026 -0400 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ba4dae --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..8b2eeb8 --- /dev/null +++ b/README.txt @@ -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. +================================================== diff --git a/Start.bat b/Start.bat new file mode 100644 index 0000000..48b30e7 --- /dev/null +++ b/Start.bat @@ -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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..f85eed1 --- /dev/null +++ b/app.py @@ -0,0 +1,2341 @@ +from flask import Flask, render_template, request, jsonify, redirect, url_for, send_file, session +import sqlite3, os, uuid +from datetime import datetime, date +from werkzeug.utils import secure_filename +from auth import (login_user, logout_user, current_user, is_logged_in, + is_superadmin, is_admin, login_required, admin_required, + superadmin_required, hash_password, verify_password, + create_initial_superadmin) + +app = Flask(__name__) +app.secret_key = 'marine_maint_secret_2026_xK9p' + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.path.join(BASE_DIR, 'marine_maintenance.db') +UPLOAD_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'photos') +LOGO_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'logos') +DOCS_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'docs') +SIG_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'signatures') +PDF_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'pdfs') +ALLOWED_IMG = {'png','jpg','jpeg','gif','webp'} +ALLOWED_DOCS = {'pdf','doc','docx','xls','xlsx','txt','png','jpg','jpeg','gif','webp'} +app.config['MAX_CONTENT_LENGTH'] = 32 * 1024 * 1024 + +for folder in [UPLOAD_FOLDER, LOGO_FOLDER, DOCS_FOLDER, SIG_FOLDER, PDF_FOLDER]: + os.makedirs(folder, exist_ok=True) + +# ── DB ──────────────────────────────────────────────────────────────────────── +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + +def init_db(): + with open(os.path.join(BASE_DIR, 'schema.sql'), 'r', encoding='utf-8') as f: + conn = get_db() + conn.executescript(f.read()) + conn.commit() + # Always run - safe, skips if column already exists + migrations = [ + "ALTER TABLE vessels ADD COLUMN company_id INTEGER", + "ALTER TABLE vessels ADD COLUMN engine_type TEXT", + "ALTER TABLE vessels ADD COLUMN engine_hours REAL DEFAULT 0", + "ALTER TABLE vessels ADD COLUMN captain_name TEXT", + "ALTER TABLE vessels ADD COLUMN captain_phone TEXT", + "ALTER TABLE vessels ADD COLUMN captain_email TEXT", + "ALTER TABLE vessels ADD COLUMN flag TEXT", + "ALTER TABLE vessels ADD COLUMN port_of_registry TEXT", + "ALTER TABLE work_orders ADD COLUMN scope TEXT", + "ALTER TABLE work_orders ADD COLUMN root_cause TEXT", + "ALTER TABLE work_orders ADD COLUMN repairs_done TEXT", + "ALTER TABLE work_orders ADD COLUMN equipment_id INTEGER", + "ALTER TABLE work_orders ADD COLUMN system_id INTEGER", + + """CREATE TABLE IF NOT EXISTS rfq ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER REFERENCES companies(id), + vessel_id INTEGER REFERENCES vessels(id), + work_order_id INTEGER REFERENCES work_orders(id), + rfq_number TEXT UNIQUE, status TEXT DEFAULT 'draft', + subject TEXT, notes TEXT, created_by TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + """CREATE TABLE IF NOT EXISTS rfq_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfq_id INTEGER NOT NULL REFERENCES rfq(id) ON DELETE CASCADE, + description TEXT NOT NULL, quantity REAL DEFAULT 1, + unit TEXT DEFAULT 'pcs', notes TEXT)""", + """CREATE TABLE IF NOT EXISTS rfq_quotes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfq_id INTEGER NOT NULL REFERENCES rfq(id) ON DELETE CASCADE, + supplier_id INTEGER NOT NULL REFERENCES suppliers(id), + status TEXT DEFAULT 'pending', received_date DATE, + pdf_filename TEXT, notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + """CREATE TABLE IF NOT EXISTS rfq_quote_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote_id INTEGER NOT NULL REFERENCES rfq_quotes(id) ON DELETE CASCADE, + rfq_item_id INTEGER NOT NULL REFERENCES rfq_items(id), + unit_price REAL DEFAULT 0, delivery_days INTEGER, + brand TEXT, notes TEXT, selected INTEGER DEFAULT 0, + selection_reason TEXT)""", + """CREATE TABLE IF NOT EXISTS purchase_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER REFERENCES companies(id), + vessel_id INTEGER REFERENCES vessels(id), + work_order_id INTEGER REFERENCES work_orders(id), + rfq_id INTEGER REFERENCES rfq(id), + supplier_id INTEGER NOT NULL REFERENCES suppliers(id), + po_number TEXT UNIQUE, status TEXT DEFAULT 'draft', + po_date DATE, expected_date DATE, + payment_terms TEXT DEFAULT 'Net 30', + shipping_address TEXT, notes TEXT, + total_amount REAL DEFAULT 0, invoice_filename TEXT, + created_by TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + """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)""", + """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, + quantity REAL NOT NULL, unit_cost REAL DEFAULT 0, + notes TEXT, created_by TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + + """CREATE TABLE IF NOT EXISTS swp ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER REFERENCES companies(id), + code TEXT NOT NULL, title TEXT NOT NULL, + category TEXT NOT NULL, status TEXT DEFAULT 'active', + current_version_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + """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, purpose TEXT, scope TEXT, + hazards TEXT, ppe TEXT, steps TEXT, emergency TEXT, + ref_standards TEXT, status TEXT DEFAULT 'draft', + change_reason TEXT, diff_summary TEXT, + created_by TEXT, approved_by TEXT, approved_at TIMESTAMP, + effective_date DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + """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, + acknowledged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + notes TEXT)""", + """CREATE TABLE IF NOT EXISTS msds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER REFERENCES companies(id), + part_id INTEGER REFERENCES parts(id), + product_name TEXT NOT NULL, manufacturer TEXT, + version TEXT DEFAULT 'v1.0', hazard_class TEXT, + hazards TEXT, first_aid TEXT, ppe_required TEXT, + handling TEXT, storage TEXT, spill_procedure TEXT, + disposal TEXT, ref_standards TEXT, pdf_filename TEXT, + created_by TEXT, updated_by TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + + 'ALTER TABLE work_orders ADD COLUMN assigned_to TEXT', + 'ALTER TABLE work_orders ADD COLUMN assigned_by TEXT', + 'ALTER TABLE work_orders ADD COLUMN assigned_at TIMESTAMP', + """CREATE TABLE IF NOT EXISTS wo_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_order_id INTEGER NOT NULL REFERENCES work_orders(id), + sent_to TEXT NOT NULL, + sent_by TEXT, + notification_type TEXT DEFAULT 'assignment', + message TEXT, + pdf_filename TEXT, + sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'sent')""", + "ALTER TABLE swp_versions ADD COLUMN tools TEXT", + "ALTER TABLE work_orders ADD COLUMN billing_type TEXT DEFAULT 'labor_materials'", + "ALTER TABLE work_order_equipment ADD COLUMN labor_hours REAL DEFAULT 0", + "ALTER TABLE work_order_equipment ADD COLUMN labor_rate REAL DEFAULT 0", + "ALTER TABLE purchases ADD COLUMN company_id INTEGER", + "ALTER TABLE purchases ADD COLUMN vessel_id INTEGER", + "ALTER TABLE purchases ADD COLUMN work_order_id INTEGER", + "ALTER TABLE purchases ADD COLUMN purchase_number TEXT", + "ALTER TABLE purchases ADD COLUMN delivery_date DATE", + "ALTER TABLE purchases ADD COLUMN received_date DATE", + "ALTER TABLE purchases ADD COLUMN status TEXT DEFAULT 'requested'", + "ALTER TABLE purchases ADD COLUMN requested_by TEXT", + "ALTER TABLE purchases ADD COLUMN approved_by TEXT", + "ALTER TABLE purchases ADD COLUMN payment_method TEXT", + "ALTER TABLE purchases ADD COLUMN payment_reference TEXT", + "ALTER TABLE purchases ADD COLUMN invoice_photo TEXT", + "ALTER TABLE purchases ADD COLUMN updated_at TIMESTAMP", + "ALTER TABLE purchase_items ADD COLUMN part_number TEXT", + "ALTER TABLE purchase_items ADD COLUMN quantity_received REAL DEFAULT 0", + "ALTER TABLE purchase_items ADD COLUMN notes TEXT", + """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, + quantity REAL NOT NULL, + reference_type TEXT, reference_id INTEGER, + vessel_id INTEGER REFERENCES vessels(id), + notes TEXT, created_by TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + """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)""", + "ALTER TABLE work_orders ADD COLUMN signature_tech TEXT", + """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)""", + """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, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (1,'Propulsión',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (2,'Generación',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (3,'Navegación y Comunicaciones',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (4,'Sistema Eléctrico',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (5,'Baterías y Carga',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (6,'Hidráulico',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (7,'HVAC / Climatización',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (8,'Plomería / Agua',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (9,'Seguridad',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (10,'Casco y Estructura',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (11,'Otro',1)", + "ALTER TABLE work_orders ADD COLUMN signature_client TEXT", + "ALTER TABLE work_orders ADD COLUMN saved_pdf TEXT", + ] + for sql in migrations: + try: + conn.execute(sql) + conn.commit() + except Exception: + pass + conn.close() + +def allowed_file(filename, allowed): + return '.' in filename and filename.rsplit('.',1)[1].lower() in allowed + +def generate_order_number(): + today = date.today() + conn = get_db() + count = conn.execute( + "SELECT COUNT(*) as c FROM work_orders WHERE date(created_at)=?", + (today.isoformat(),)).fetchone()['c'] + conn.close() + return f"WO-{today.strftime('%Y%m%d')}-{count+1:03d}" + +# ── context processor ───────────────────────────────────────────────────────── +@app.context_processor +def inject_user(): + return dict(current_user=current_user()) + +# ── company filter helper ───────────────────────────────────────────────────── +def cid(): + """Current user's company_id, or None if superadmin.""" + u = current_user() + return u['company_id'] if u and u['role'] != 'superadmin' else None + +# ── AUTH ────────────────────────────────────────────────────────────────────── +@app.route('/login', methods=['GET','POST']) +def auth_login(): + if is_logged_in(): + return redirect(url_for('dashboard')) + error = None + username = '' + if request.method == 'POST': + username = request.form.get('username','').strip() + password = request.form.get('password','') + conn = get_db() + user = conn.execute(""" + SELECT u.*, c.name as company_name FROM users u + LEFT JOIN companies c ON u.company_id = c.id + WHERE u.username=? AND u.is_active=1 + """, (username,)).fetchone() + conn.close() + if user and verify_password(password, user['password_hash']): + login_user(user) + return redirect(request.args.get('next') or url_for('dashboard')) + error = 'Usuario o contraseña incorrectos.' + return render_template('login.html', error=error, username=username) + +@app.route('/logout') +def auth_logout(): + logout_user() + return redirect(url_for('auth_login')) + +# ── DASHBOARD ───────────────────────────────────────────────────────────────── +@app.route('/') +@login_required +def dashboard(): + conn = get_db() + company_id = cid() + if company_id: + stats = { + 'vessels': conn.execute("SELECT COUNT(*) as c FROM vessels WHERE company_id=?", (company_id,)).fetchone()['c'], + 'open_orders': conn.execute("""SELECT COUNT(*) as c FROM work_orders wo + JOIN vessels v ON wo.vessel_id=v.id + WHERE wo.status IN ('open','in_progress') AND v.company_id=?""", (company_id,)).fetchone()['c'], + 'low_stock': conn.execute("SELECT COUNT(*) as c FROM parts WHERE quantity<=min_quantity AND min_quantity>0 AND (company_id=? OR company_id IS NULL)", (company_id,)).fetchone()['c'], + 'completed_this_month': conn.execute("""SELECT COUNT(*) as c FROM work_orders wo + JOIN vessels v ON wo.vessel_id=v.id + WHERE wo.status='completed' AND strftime('%Y-%m',wo.updated_at)=strftime('%Y-%m','now') AND v.company_id=?""", (company_id,)).fetchone()['c'], + } + recent_orders = conn.execute(""" + SELECT wo.*, v.name as vessel_name FROM work_orders wo + JOIN vessels v ON wo.vessel_id=v.id + WHERE v.company_id=? ORDER BY wo.created_at DESC LIMIT 8""", (company_id,)).fetchall() + low_stock_parts = conn.execute(""" + SELECT * FROM parts WHERE quantity<=min_quantity AND min_quantity>0 + AND (company_id=? OR company_id IS NULL) ORDER BY quantity ASC LIMIT 6""", (company_id,)).fetchall() + else: + stats = { + 'vessels': conn.execute("SELECT COUNT(*) as c FROM vessels").fetchone()['c'], + 'open_orders': conn.execute("SELECT COUNT(*) as c FROM work_orders WHERE status IN ('open','in_progress')").fetchone()['c'], + 'low_stock': conn.execute("SELECT COUNT(*) as c FROM parts WHERE quantity<=min_quantity AND min_quantity>0").fetchone()['c'], + 'completed_this_month': conn.execute("SELECT COUNT(*) as c FROM work_orders WHERE status='completed' AND strftime('%Y-%m',updated_at)=strftime('%Y-%m','now')").fetchone()['c'], + } + recent_orders = conn.execute(""" + SELECT wo.*, v.name as vessel_name, c.name as company_name FROM work_orders wo + JOIN vessels v ON wo.vessel_id=v.id + LEFT JOIN companies c ON v.company_id=c.id + ORDER BY wo.created_at DESC LIMIT 8""").fetchall() + low_stock_parts = conn.execute(""" + SELECT * FROM parts WHERE quantity<=min_quantity AND min_quantity>0 + ORDER BY quantity ASC LIMIT 6""").fetchall() + conn.close() + return render_template('dashboard.html', stats=stats, recent_orders=recent_orders, + low_stock_parts=low_stock_parts) + +# ── COMPANIES ───────────────────────────────────────────────────────────────── +@app.route('/companies') +@admin_required +def companies(): + conn = get_db() + companies = conn.execute(""" + SELECT c.*, COUNT(v.id) as vessel_count FROM companies c + LEFT JOIN vessels v ON v.company_id=c.id GROUP BY c.id ORDER BY c.name""").fetchall() + conn.close() + return render_template('companies.html', companies=companies) + +@app.route('/companies/new', methods=['GET','POST']) +@admin_required +def company_new(): + if request.method == 'POST': + data = request.form + logo_path = None + if 'logo' in request.files and request.files['logo'].filename: + f = request.files['logo'] + if allowed_file(f.filename, ALLOWED_IMG): + fname = f"logo_{uuid.uuid4().hex[:8]}.{f.filename.rsplit('.',1)[1].lower()}" + f.save(os.path.join(LOGO_FOLDER, fname)) + logo_path = fname + conn = get_db() + conn.execute("INSERT INTO companies (name,address,phone,email,website,logo_path,notes) VALUES (?,?,?,?,?,?,?)", + (data['name'],data.get('address'),data.get('phone'),data.get('email'), + data.get('website'),logo_path,data.get('notes'))) + conn.commit(); conn.close() + return redirect(url_for('companies')) + return render_template('company_form.html', company=None) + +@app.route('/companies//edit', methods=['GET','POST']) +@admin_required +def company_edit(coid): + conn = get_db() + company = conn.execute("SELECT * FROM companies WHERE id=?", (coid,)).fetchone() + if request.method == 'POST': + data = request.form + logo_path = company['logo_path'] + if 'logo' in request.files and request.files['logo'].filename: + f = request.files['logo'] + if allowed_file(f.filename, ALLOWED_IMG): + fname = f"logo_{uuid.uuid4().hex[:8]}.{f.filename.rsplit('.',1)[1].lower()}" + f.save(os.path.join(LOGO_FOLDER, fname)) + logo_path = fname + conn.execute("""UPDATE companies SET name=?,address=?,phone=?,email=?,website=?,logo_path=?,notes=? WHERE id=?""", + (data['name'],data.get('address'),data.get('phone'),data.get('email'), + data.get('website'),logo_path,data.get('notes'),coid)) + conn.commit(); conn.close() + return redirect(url_for('companies')) + conn.close() + return render_template('company_form.html', company=company) + +# ── USERS ───────────────────────────────────────────────────────────────────── +@app.route('/users') +@admin_required +def users(): + conn = get_db() + company_id = cid() + if company_id: + user_list = conn.execute(""" + SELECT u.*, c.name as company_name FROM users u + LEFT JOIN companies c ON u.company_id=c.id + WHERE u.company_id=? ORDER BY u.username""", (company_id,)).fetchall() + else: + user_list = conn.execute(""" + SELECT u.*, c.name as company_name FROM users u + LEFT JOIN companies c ON u.company_id=c.id ORDER BY u.username""").fetchall() + conn.close() + return render_template('users.html', users=user_list) + +@app.route('/users/new', methods=['GET','POST']) +@admin_required +def user_new(): + conn = get_db() + if request.method == 'POST': + data = request.form + pw = data.get('password','') + role = data.get('role','technician') + if not is_superadmin() and role == 'superadmin': + role = 'admin' + company_id_val = data.get('company_id') or cid() + conn.execute("""INSERT INTO users (company_id,username,email,password_hash,full_name,role) VALUES (?,?,?,?,?,?)""", + (company_id_val or None, data['username'], data['email'], + hash_password(pw), data.get('full_name'), role)) + conn.commit(); conn.close() + return redirect(url_for('users')) + companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall() + conn.close() + return render_template('user_form.html', user=None, companies=companies_list) + +@app.route('/users//edit', methods=['GET','POST']) +@admin_required +def user_edit(uid): + conn = get_db() + user = conn.execute(""" + SELECT u.*, c.name as company_name FROM users u + LEFT JOIN companies c ON u.company_id=c.id WHERE u.id=?""", (uid,)).fetchone() + if request.method == 'POST': + data = request.form + pw = data.get('password','').strip() + role = data.get('role','technician') + if not is_superadmin() and role == 'superadmin': + role = user['role'] + company_id_val = data.get('company_id') or user['company_id'] + if pw: + conn.execute("""UPDATE users SET username=?,email=?,password_hash=?,full_name=?,role=?,company_id=?,is_active=? WHERE id=?""", + (data['username'],data['email'],hash_password(pw),data.get('full_name'),role,company_id_val or None,data.get('is_active',1),uid)) + else: + conn.execute("""UPDATE users SET username=?,email=?,full_name=?,role=?,company_id=?,is_active=? WHERE id=?""", + (data['username'],data['email'],data.get('full_name'),role,company_id_val or None,data.get('is_active',1),uid)) + conn.commit(); conn.close() + return redirect(url_for('users')) + companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall() + conn.close() + return render_template('user_form.html', user=user, companies=companies_list) + +# ── VESSELS ─────────────────────────────────────────────────────────────────── +@app.route('/vessels') +@login_required +def vessels(): + conn = get_db() + company_id = cid() + if company_id: + vessel_list = conn.execute(""" + SELECT v.*, c.name as company_name, + COUNT(DISTINCT wo.id) as total_orders, + SUM(CASE WHEN wo.status IN ('open','in_progress') THEN 1 ELSE 0 END) as open_orders + FROM vessels v LEFT JOIN companies c ON v.company_id=c.id + LEFT JOIN work_orders wo ON wo.vessel_id=v.id + WHERE v.company_id=? GROUP BY v.id ORDER BY v.name""", (company_id,)).fetchall() + else: + vessel_list = conn.execute(""" + SELECT v.*, c.name as company_name, + COUNT(DISTINCT wo.id) as total_orders, + SUM(CASE WHEN wo.status IN ('open','in_progress') THEN 1 ELSE 0 END) as open_orders + FROM vessels v LEFT JOIN companies c ON v.company_id=c.id + LEFT JOIN work_orders wo ON wo.vessel_id=v.id + GROUP BY v.id ORDER BY c.name, v.name""").fetchall() + conn.close() + return render_template('vessels.html', vessels=vessel_list) + +@app.route('/vessels/new', methods=['GET','POST']) +@login_required +def vessel_new(): + conn = get_db() + if request.method == 'POST': + data = request.form + company_id_val = data.get('company_id') or cid() + conn.execute("""INSERT INTO vessels (company_id,name,registration,vessel_type,make,model,year,length_ft, + flag,port_of_registry,engine_hours,owner_name,owner_phone,owner_email, + captain_name,captain_phone,captain_email,notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (company_id_val or None,data['name'],data.get('registration'),data.get('vessel_type'), + data.get('make'),data.get('model'),data.get('year') or None, + data.get('length_ft') or None,data.get('flag'),data.get('port_of_registry'), + data.get('engine_hours') or 0,data.get('owner_name'),data.get('owner_phone'), + data.get('owner_email'),data.get('captain_name'),data.get('captain_phone'), + data.get('captain_email'),data.get('notes'))) + conn.commit(); conn.close() + return redirect(url_for('vessels')) + companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall() + conn.close() + return render_template('vessel_form.html', vessel=None, companies=companies_list) + +@app.route('/vessels//edit', methods=['GET','POST']) +@login_required +def vessel_edit(vid): + conn = get_db() + vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (vid,)).fetchone() + if request.method == 'POST': + data = request.form + company_id_val = data.get('company_id') or vessel['company_id'] + conn.execute("""UPDATE vessels SET company_id=?,name=?,registration=?,vessel_type=?,make=?,model=?,year=?, + length_ft=?,flag=?,port_of_registry=?,owner_name=?,owner_phone=?,owner_email=?, + captain_name=?,captain_phone=?,captain_email=?,notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""", + (company_id_val or None,data['name'],data.get('registration'),data.get('vessel_type'), + data.get('make'),data.get('model'),data.get('year') or None, + data.get('length_ft') or None,data.get('flag'),data.get('port_of_registry'), + data.get('owner_name'),data.get('owner_phone'),data.get('owner_email'), + data.get('captain_name'),data.get('captain_phone'),data.get('captain_email'), + data.get('notes'),vid)) + conn.commit(); conn.close() + return redirect(url_for('vessel_history', vid=vid)) + companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall() + conn.close() + return render_template('vessel_form.html', vessel=vessel, companies=companies_list) + +@app.route('/vessels/') +@login_required +def vessel_detail(vid): + return redirect(url_for('vessel_history', vid=vid)) + +@app.route('/vessels//history') +@login_required +def vessel_history(vid): + conn = get_db() + vessel = conn.execute(""" + SELECT v.*, c.name as company_name, c.logo_path as company_logo + FROM vessels v LEFT JOIN companies c ON v.company_id=c.id WHERE v.id=?""", (vid,)).fetchone() + orders = conn.execute(""" + SELECT wo.*, ve.name as equipment_name, s.name as system_name, + COALESCE(wo.total_labor_cost, wo.labor_hours*wo.labor_rate, 0) as calc_labor_cost, + (SELECT COUNT(*) FROM work_order_photos WHERE work_order_id=wo.id) as photo_count + FROM work_orders wo + LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id + LEFT JOIN systems s ON wo.system_id=s.id + WHERE wo.vessel_id=? ORDER BY wo.start_date DESC, wo.created_at DESC""", (vid,)).fetchall() + equipment = conn.execute(""" + SELECT * FROM vessel_equipment WHERE vessel_id=? AND is_active=1 + ORDER BY equipment_type, name""", (vid,)).fetchall() + documents = conn.execute(""" + SELECT vd.*, ve.name as equipment_name FROM vessel_documents vd + LEFT JOIN vessel_equipment ve ON vd.equipment_id=ve.id + WHERE vd.vessel_id=? ORDER BY vd.created_at DESC""", (vid,)).fetchall() + total_cost = sum(float(o['calc_labor_cost'] or 0) + float(o['total_parts_cost'] or 0) for o in orders) + conn.close() + return render_template('vessel_history.html', vessel=vessel, orders=orders, + equipment=equipment, documents=documents, total_cost=total_cost) + +# ── EQUIPMENT ───────────────────────────────────────────────────────────────── +@app.route('/vessels//equipment/new', methods=['GET','POST']) +@login_required +def equipment_new(vid): + conn = get_db() + vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (vid,)).fetchone() + if request.method == 'POST': + data = request.form + conn.execute("""INSERT INTO vessel_equipment + (vessel_id,name,equipment_type,make,model,serial_number,year,position, + engine_hours,last_service_date,last_service_hours,notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + (vid,data['name'],data.get('equipment_type'),data.get('make'),data.get('model'), + data.get('serial_number'),data.get('year') or None,data.get('position'), + data.get('engine_hours') or 0,data.get('last_service_date') or None, + data.get('last_service_hours') or None,data.get('notes'))) + conn.commit(); conn.close() + return redirect(url_for('vessel_history', vid=vid)) + conn.close() + return render_template('equipment_form.html', vessel=vessel, equip=None) + +@app.route('/vessels//equipment//edit', methods=['GET','POST']) +@login_required +def equipment_edit(vid, eid): + conn = get_db() + vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (vid,)).fetchone() + equip = conn.execute("SELECT * FROM vessel_equipment WHERE id=?", (eid,)).fetchone() + if request.method == 'POST': + data = request.form + conn.execute("""UPDATE vessel_equipment SET name=?,equipment_type=?,make=?,model=?, + serial_number=?,year=?,position=?,engine_hours=?,last_service_date=?, + last_service_hours=?,notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""", + (data['name'],data.get('equipment_type'),data.get('make'),data.get('model'), + data.get('serial_number'),data.get('year') or None,data.get('position'), + data.get('engine_hours') or 0,data.get('last_service_date') or None, + data.get('last_service_hours') or None,data.get('notes'),eid)) + conn.commit(); conn.close() + return redirect(url_for('vessel_history', vid=vid)) + conn.close() + return render_template('equipment_form.html', vessel=vessel, equip=equip) + +# ── DOCUMENTS ──────────────────────────────────────────────────────────────── +@app.route('/vessels//documents/upload', methods=['POST']) +@login_required +def document_upload(vid): + if 'file' not in request.files or not request.files['file'].filename: + return jsonify({'error': 'No file'}), 400 + f = request.files['file'] + if not allowed_file(f.filename, ALLOWED_DOCS): + return jsonify({'error': 'Tipo de archivo no permitido'}), 400 + ext = f.filename.rsplit('.',1)[1].lower() + fname = f"doc_{vid}_{uuid.uuid4().hex[:10]}.{ext}" + fpath = os.path.join(DOCS_FOLDER, fname) + f.save(fpath) + fsize = os.path.getsize(fpath) + u = current_user() + conn = get_db() + conn.execute("""INSERT INTO vessel_documents + (vessel_id,equipment_id,doc_type,title,description,filename,original_filename,file_size,uploaded_by) + VALUES (?,?,?,?,?,?,?,?,?)""", + (vid, request.form.get('equipment_id') or None, + request.form.get('doc_type','other'), + request.form.get('title') or f.filename, + request.form.get('description',''), + fname, f.filename, fsize, u['full_name'] if u else '')) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/documents//download') +@login_required +def document_download(doc_id): + conn = get_db() + doc = conn.execute("SELECT * FROM vessel_documents WHERE id=?", (doc_id,)).fetchone() + conn.close() + if not doc: + return "Not found", 404 + fpath = os.path.join(DOCS_FOLDER, doc['filename']) + return send_file(fpath, as_attachment=True, download_name=doc['original_filename'] or doc['filename']) + +@app.route('/documents//delete', methods=['DELETE']) +@login_required +def document_delete(doc_id): + conn = get_db() + doc = conn.execute("SELECT * FROM vessel_documents WHERE id=?", (doc_id,)).fetchone() + if doc: + fpath = os.path.join(DOCS_FOLDER, doc['filename']) + if os.path.exists(fpath): os.remove(fpath) + conn.execute("DELETE FROM vessel_documents WHERE id=?", (doc_id,)) + conn.commit() + conn.close() + return jsonify({'ok': True}) + +# ── WORK ORDERS ─────────────────────────────────────────────────────────────── +@app.route('/work-orders') +@login_required +def work_orders(): + status = request.args.get('status','') + conn = get_db() + company_id = cid() + base = """SELECT wo.*, v.name as vessel_name, v.id as vessel_id, ve.name as equipment_name + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id""" + conditions = [] + params = [] + if company_id: + conditions.append("v.company_id=?"); params.append(company_id) + if status: + conditions.append("wo.status=?"); params.append(status) + if conditions: + base += " WHERE " + " AND ".join(conditions) + base += " ORDER BY wo.created_at DESC" + orders = conn.execute(base, params).fetchall() + conn.close() + return render_template('work_orders.html', orders=orders, status_filter=status) + +@app.route('/work-orders/new', methods=['GET','POST']) +@login_required +def work_order_new(): + conn = get_db() + company_id = cid() + if request.method == 'POST': + data = request.form + order_num = generate_order_number() + conn.execute("""INSERT INTO work_orders + (vessel_id,equipment_id,system_id,order_number,status,work_type,scope,description, + technician,start_date,engine_hours_start,labor_rate,billing_type,notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (data['vessel_id'], data.get('equipment_id') or None, + data.get('system_id') or None, + order_num,'open',data.get('work_type'), + data.get('scope',''),data.get('description',''), + data.get('technician'),data.get('start_date'), + data.get('engine_hours_start') or None, + data.get('labor_rate') or 0, + data.get('billing_type','labor_materials'), + data.get('notes'))) + conn.commit() + woid = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id'] + conn.close() + return redirect(url_for('work_order_detail', woid=woid)) + if company_id: + vessel_list = conn.execute("SELECT id,name FROM vessels WHERE company_id=? ORDER BY name", (company_id,)).fetchall() + else: + vessel_list = conn.execute("""SELECT v.id, v.name || ' (' || COALESCE(c.name,'Sin compañía') || ')' as name + FROM vessels v LEFT JOIN companies c ON v.company_id=c.id ORDER BY c.name,v.name""").fetchall() + preselect_vessel = request.args.get('vessel') + equipment_list = [] + if preselect_vessel: + equipment_list = conn.execute( + "SELECT id,name,serial_number FROM vessel_equipment WHERE vessel_id=? AND is_active=1 ORDER BY name", + (preselect_vessel,)).fetchall() + conn.close() + return render_template('work_order_form.html', order=None, vessels=vessel_list, + equipment_list=equipment_list, preselect_vessel=preselect_vessel) + +@app.route('/work-orders//delete', methods=['DELETE']) +@login_required +def work_order_delete(woid): + conn = get_db() + try: + order = conn.execute("SELECT status FROM work_orders WHERE id=?", (woid,)).fetchone() + if not order: + return jsonify({'ok': False, 'error': 'No encontrada'}), 404 + if order['status'] == 'completed': + return jsonify({'ok': False, 'error': 'No se puede eliminar una orden completada'}), 403 + # Delete photos from disk + photos = conn.execute("SELECT filename FROM work_order_photos WHERE work_order_id=?", (woid,)).fetchall() + for p in photos: + fp = os.path.join(UPLOAD_FOLDER, p['filename']) + if os.path.exists(fp): os.remove(fp) + # Delete signatures + sigs = conn.execute("SELECT signature_tech, signature_client FROM work_orders WHERE id=?", (woid,)).fetchone() + for sig in [sigs['signature_tech'], sigs['signature_client']]: + if sig: + fp = os.path.join(SIG_FOLDER, sig) + if os.path.exists(fp): os.remove(fp) + conn.execute("DELETE FROM work_orders WHERE id=?", (woid,)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + except Exception as e: + conn.close() + return jsonify({'ok': False, 'error': str(e)}), 500 + +@app.route('/work-orders//edit', methods=['GET','POST']) +@login_required +def work_order_edit(woid): + conn = get_db() + order = conn.execute(""" + SELECT wo.*, v.name as vessel_name FROM work_orders wo + JOIN vessels v ON wo.vessel_id=v.id WHERE wo.id=?""", (woid,)).fetchone() + if order['status'] == 'completed': + conn.close() + return redirect(url_for('work_order_detail', woid=woid)) + if request.method == 'POST': + data = request.form + conn.execute("""UPDATE work_orders SET + system_id=?, work_type=?, scope=?, description=?, + technician=?, start_date=?, engine_hours_start=?, + labor_rate=?, billing_type=?, notes=?, updated_at=CURRENT_TIMESTAMP + WHERE id=?""", + (data.get('system_id') or None, data.get('work_type'), + data.get('scope',''), data.get('description',''), + data.get('technician'), data.get('start_date'), + data.get('engine_hours_start') or None, + data.get('labor_rate') or 0, + data.get('billing_type','labor_materials'), + data.get('notes'), woid)) + conn.commit(); conn.close() + return redirect(url_for('work_order_detail', woid=woid)) + company_id = cid() + if company_id: + vessels = conn.execute("SELECT id,name FROM vessels WHERE company_id=? ORDER BY name", (company_id,)).fetchall() + else: + vessels = conn.execute("SELECT id,name FROM vessels ORDER BY name").fetchall() + systems = conn.execute("SELECT * FROM systems ORDER BY is_default DESC, name").fetchall() + conn.close() + return render_template('work_order_edit.html', order=order, vessels=vessels, systems=systems) + +@app.route('/work-orders/') +@login_required +def work_order_detail(woid): + conn = get_db() + order = conn.execute(""" + SELECT wo.*, v.name as vessel_name, ve.name as equipment_name, + ve.make as equip_make, ve.model as equip_model, ve.serial_number as equip_serial, + s.name as system_name + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id + LEFT JOIN systems s ON wo.system_id=s.id + WHERE wo.id=?""", (woid,)).fetchone() + vessel_row = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone() + conn2_data = dict(vessel_row) if vessel_row else {} + photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type,taken_at", (woid,)).fetchall() + parts_used = conn.execute("""SELECT wop.*, p.name as part_name, p.part_number FROM work_order_parts wop + LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall() + available_parts = conn.execute("SELECT id,name,part_number,quantity,unit,sale_price FROM parts ORDER BY name").fetchall() + wo_equipment = conn.execute(""" + SELECT woe.*, ve.name as equip_name, ve.serial_number, ve.make, ve.model, + (woe.labor_hours * woe.labor_rate) as labor_cost + FROM work_order_equipment woe + LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id + WHERE woe.work_order_id=? ORDER BY woe.created_at + """, (woid,)).fetchall() + vessel_equipment = conn.execute( + "SELECT * FROM vessel_equipment WHERE vessel_id=? AND is_active=1 ORDER BY name", + (order['vessel_id'],)).fetchall() + email_log = conn.execute(""" + SELECT * FROM email_log WHERE work_order_id=? ORDER BY sent_at DESC + """, (woid,)).fetchall() + company_id = cid() + users = conn.execute("""SELECT username, full_name, email FROM users + WHERE company_id=? AND email IS NOT NULL AND email != '' AND is_active=1 + ORDER BY full_name""", (company_id,)).fetchall() if company_id else [] + conn.close() + return render_template('work_order_detail.html', order=order, photos=photos, + parts_used=parts_used, available_parts=available_parts, + wo_equipment=wo_equipment, vessel_equipment=vessel_equipment, + email_log=email_log, users=users, + vessel_captain_email=conn2_data.get('captain_email',''), + vessel_captain_name=conn2_data.get('captain_name',''), + vessel_owner_email=conn2_data.get('owner_email',''), + vessel_owner_name=conn2_data.get('owner_name','')) + +@app.route('/work-orders//update-status', methods=['POST']) +@login_required +def update_status(woid): + status = request.json.get('status') + conn = get_db() + extra = ", end_date=date('now')" if status=='completed' else "" + conn.execute(f"UPDATE work_orders SET status=?,updated_at=CURRENT_TIMESTAMP{extra} WHERE id=?", (status,woid)) + conn.commit(); conn.close() + # Auto-save PDF when completed + if status == 'completed': + try: + from report_generator import generate_work_order_pdf + conn2 = get_db() + order = conn2.execute(""" + SELECT wo.*, v.name as vessel_name, c.name as company_name, + c.logo_path, c.phone as company_phone, + c.email as company_email, c.address as company_address, + s.name as system_name + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + LEFT JOIN companies c ON v.company_id=c.id + LEFT JOIN systems s ON wo.system_id=s.id + WHERE wo.id=?""", (woid,)).fetchone() + vessel = conn2.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone() + photos = conn2.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type", (woid,)).fetchall() + parts = conn2.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop + LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall() + wo_equip = conn2.execute("""SELECT woe.*,ve.name as equip_name,ve.serial_number,ve.make,ve.model + FROM work_order_equipment woe LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id + WHERE woe.work_order_id=?""", (woid,)).fetchall() + conn2.close() + logo_path = None + if order['logo_path']: + lp = os.path.join(LOGO_FOLDER, order['logo_path']) + if os.path.exists(lp): logo_path = lp + company_info = ' | '.join(filter(None, [ + order['company_address'] or '', order['company_phone'] or '', order['company_email'] or '' + ])) + pdf_buf = generate_work_order_pdf( + order=dict(order), vessel=dict(vessel), + photos=[dict(p) for p in photos], parts_used=[dict(p) for p in parts], + wo_equipment=[dict(e) for e in wo_equip], + upload_folder=UPLOAD_FOLDER, sig_folder=SIG_FOLDER, + company_name=order['company_name'] or 'Marine Maintenance Pro', + company_logo=logo_path, company_info=company_info, lang='es') + fname = f"{order['order_number'].replace('/','_')}_FINAL.pdf" + fpath = os.path.join(PDF_FOLDER, fname) + with open(fpath, 'wb') as f: + f.write(pdf_buf.read()) + conn3 = get_db() + conn3.execute("UPDATE work_orders SET saved_pdf=? WHERE id=?", (fname, woid)) + conn3.commit(); conn3.close() + except Exception as e: + print(f"[auto-pdf] Error: {e}") + return jsonify({'ok': True}) + +@app.route('/work-orders//update-fields', methods=['POST']) +@login_required +def update_fields(woid): + data = request.json + conn = get_db() + conn.execute("""UPDATE work_orders SET root_cause=?,repairs_done=?, + updated_at=CURRENT_TIMESTAMP WHERE id=?""", + (data.get('root_cause',''),data.get('repairs_done',''),woid)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/api/update-labor/', methods=['POST']) +@login_required +def update_labor(woid): + data = request.json + conn = get_db() + conn.execute("""UPDATE work_orders SET labor_hours=?, labor_rate=?, + updated_at=CURRENT_TIMESTAMP WHERE id=?""", + (float(data.get('labor_hours', 0)), + float(data.get('labor_rate', 0)), woid)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/work-orders//upload-photo', methods=['POST']) +@login_required +def upload_photo(woid): + if 'photo' not in request.files: return jsonify({'error':'No file'}),400 + f = request.files['photo'] + if f and allowed_file(f.filename, ALLOWED_IMG): + ext = f.filename.rsplit('.',1)[1].lower() + fname = f"{woid}_{request.form.get('photo_type','before')}_{uuid.uuid4().hex[:8]}.{ext}" + f.save(os.path.join(UPLOAD_FOLDER, fname)) + conn = get_db() + conn.execute("INSERT INTO work_order_photos (work_order_id,photo_type,filename,caption) VALUES (?,?,?,?)", + (woid,request.form.get('photo_type','before'),fname,request.form.get('caption',''))) + conn.commit(); conn.close() + return jsonify({'ok':True,'filename':fname}) + return jsonify({'error':'Invalid file'}),400 + +@app.route('/work-orders//add-part', methods=['POST']) +@login_required +def add_part_to_order(woid): + data = request.json + part_id = data.get('part_id') + qty = float(data.get('quantity',1)) + cost = float(data.get('unit_cost',0)) + conn = get_db() + conn.execute("INSERT INTO work_order_parts (work_order_id,part_id,description,quantity,unit_cost) VALUES (?,?,?,?,?)", + (woid,part_id or None,data.get('description',''),qty,cost)) + if part_id: + conn.execute("UPDATE parts SET quantity=quantity-?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (qty, part_id)) + # Log movement + u = current_user() + order_info = conn.execute("SELECT vessel_id, order_number FROM work_orders WHERE id=?", (woid,)).fetchone() + conn.execute("""INSERT INTO inventory_movements + (part_id,movement_type,quantity,reference_type,reference_id,vessel_id,notes,created_by) + VALUES (?,?,?,?,?,?,?,?)""", + (part_id, 'out', -qty, 'work_order', woid, + order_info['vessel_id'] if order_info else None, + f"Usado en {order_info['order_number'] if order_info else 'WO'}", + u['full_name'] if u else '')) + conn.execute("""UPDATE work_orders SET total_parts_cost=( + SELECT COALESCE(SUM(total_cost),0) FROM work_order_parts WHERE work_order_id=? + ),updated_at=CURRENT_TIMESTAMP WHERE id=?""", (woid,woid)) + conn.commit(); conn.close() + return jsonify({'ok':True}) + +@app.route('/api/delete-photo/', methods=['DELETE']) +@login_required +def delete_photo(photo_id): + conn = get_db() + photo = conn.execute("SELECT * FROM work_order_photos WHERE id=?", (photo_id,)).fetchone() + if photo: + fp = os.path.join(UPLOAD_FOLDER, photo['filename']) + if os.path.exists(fp): os.remove(fp) + conn.execute("DELETE FROM work_order_photos WHERE id=?", (photo_id,)) + conn.commit() + conn.close() + return jsonify({'ok':True}) + +@app.route('/api/update-photo-caption/', methods=['POST']) +@login_required +def update_photo_caption(photo_id): + caption = request.json.get('caption', '') + conn = get_db() + conn.execute("UPDATE work_order_photos SET caption=? WHERE id=?", (caption, photo_id)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/work-orders//pdf') +@login_required +def work_order_pdf(woid): + from report_generator import generate_work_order_pdf + lang = request.args.get('lang', 'es') + conn = get_db() + order = conn.execute(""" + SELECT wo.*, v.name as vessel_name, c.name as company_name, + c.logo_path, c.phone as company_phone, + c.email as company_email, c.address as company_address, + ve.name as equip_name, ve.make as equip_make, + ve.model as equip_model, ve.serial_number as equip_serial, + s.name as system_name + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + LEFT JOIN companies c ON v.company_id=c.id + LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id + LEFT JOIN systems s ON wo.system_id=s.id + WHERE wo.id=?""", (woid,)).fetchone() + vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone() + photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type,taken_at", (woid,)).fetchall() + parts = conn.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop + LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall() + wo_equip = conn.execute(""" + SELECT woe.*, ve.name as equip_name, ve.serial_number, ve.make, ve.model + FROM work_order_equipment woe + LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id + WHERE woe.work_order_id=? ORDER BY woe.created_at""", (woid,)).fetchall() + conn.close() + logo_path = None + if order['logo_path']: + lp = os.path.join(LOGO_FOLDER, order['logo_path']) + if os.path.exists(lp): logo_path = lp + company_info = ' | '.join(filter(None, [ + order['company_address'] or '', order['company_phone'] or '', order['company_email'] or '' + ])) + buf = generate_work_order_pdf( + order=dict(order), vessel=dict(vessel), + photos=[dict(p) for p in photos], + parts_used=[dict(p) for p in parts], + wo_equipment=[dict(e) for e in wo_equip], + upload_folder=UPLOAD_FOLDER, + sig_folder=SIG_FOLDER, + company_name=order['company_name'] or 'Marine Maintenance Pro', + company_logo=logo_path, + company_info=company_info, + lang=lang) + return send_file(buf, mimetype='application/pdf', + as_attachment=False, download_name=f"{order['order_number']}.pdf") + +# ── INVENTORY ───────────────────────────────────────────────────────────────── +@app.route('/inventory') +@login_required +def inventory(): + conn = get_db() + company_id = cid() + if company_id: + parts = conn.execute("""SELECT p.*,pc.name as category_name FROM parts p + LEFT JOIN parts_categories pc ON p.category_id=pc.id + WHERE p.company_id=? OR p.company_id IS NULL ORDER BY pc.name,p.name""", (company_id,)).fetchall() + else: + parts = conn.execute("""SELECT p.*,pc.name as category_name FROM parts p + LEFT JOIN parts_categories pc ON p.category_id=pc.id ORDER BY pc.name,p.name""").fetchall() + categories = conn.execute("SELECT * FROM parts_categories ORDER BY name").fetchall() + conn.close() + return render_template('inventory.html', parts=parts, categories=categories) + +@app.route('/inventory/new', methods=['GET','POST']) +@login_required +def part_new(): + conn = get_db() + if request.method == 'POST': + data = request.form + company_id_val = cid() + conn.execute("""INSERT INTO parts (company_id,category_id,part_number,name,description,brand, + location,quantity,unit,min_quantity,cost_price,sale_price,notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (company_id_val,data.get('category_id') or None,data.get('part_number'), + data['name'],data.get('description'),data.get('brand'),data.get('location'), + data.get('quantity') or 0,data.get('unit','pcs'),data.get('min_quantity') or 0, + data.get('cost_price') or 0,data.get('sale_price') or 0,data.get('notes'))) + conn.commit(); conn.close() + return redirect(url_for('inventory')) + categories = conn.execute("SELECT * FROM parts_categories ORDER BY name").fetchall() + conn.close() + return render_template('part_form.html', part=None, categories=categories) + +@app.route('/inventory//edit', methods=['GET','POST']) +@login_required +def part_edit(pid): + conn = get_db() + part = conn.execute("SELECT * FROM parts WHERE id=?", (pid,)).fetchone() + if request.method == 'POST': + data = request.form + conn.execute("""UPDATE parts SET category_id=?,part_number=?,name=?,description=?,brand=?, + location=?,quantity=?,unit=?,min_quantity=?,cost_price=?,sale_price=?, + notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""", + (data.get('category_id') or None,data.get('part_number'),data['name'], + data.get('description'),data.get('brand'),data.get('location'), + data.get('quantity') or 0,data.get('unit','pcs'),data.get('min_quantity') or 0, + data.get('cost_price') or 0,data.get('sale_price') or 0,data.get('notes'),pid)) + conn.commit(); conn.close() + return redirect(url_for('inventory')) + categories = conn.execute("SELECT * FROM parts_categories ORDER BY name").fetchall() + conn.close() + return render_template('part_form.html', part=part, categories=categories) + +# ── PURCHASES ───────────────────────────────────────────────────────────────── +PURCHASE_STATUSES = { + 'requested': ('Solicitada', 'var(--gray)'), + 'approved': ('Aprobada', 'var(--cyan)'), + 'ordered': ('Ordenada', '#f4a261'), + 'received': ('Recibida', '#2ec4b6'), + 'paid': ('Pagada', '#2ec4b6'), + 'cancelled': ('Cancelada', 'var(--danger)'), +} + +@app.route('/purchases') +@login_required +def purchases(): + conn = get_db() + company_id = cid() + base = """SELECT pu.*, s.name as supplier_name, + v.name as vessel_name, wo.order_number as wo_number + FROM purchases pu + LEFT JOIN suppliers s ON pu.supplier_id=s.id + LEFT JOIN vessels v ON pu.vessel_id=v.id + LEFT JOIN work_orders wo ON pu.work_order_id=wo.id""" + if company_id: + purchases_list = conn.execute(base + " WHERE pu.company_id=? ORDER BY pu.purchase_date DESC", (company_id,)).fetchall() + else: + purchases_list = conn.execute(base + " ORDER BY pu.purchase_date DESC").fetchall() + conn.close() + return render_template('purchases.html', purchases=purchases_list, statuses=PURCHASE_STATUSES) + +@app.route('/purchases/new', methods=['GET','POST']) +@login_required +def purchase_new(): + conn = get_db() + company_id = cid() + if request.method == 'POST': + data = request.form + # Handle invoice photo upload + invoice_photo = None + if 'invoice_photo' in request.files: + f = request.files['invoice_photo'] + if f and f.filename: + ext = f.filename.rsplit('.',1)[-1].lower() + fname = f"invoice_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext}" + f.save(os.path.join(UPLOAD_FOLDER, fname)) + invoice_photo = fname + u = current_user() + conn.execute("""INSERT INTO purchases + (company_id,supplier_id,vessel_id,work_order_id,purchase_number,invoice_number, + purchase_date,delivery_date,status,requested_by,payment_method,notes,invoice_photo) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (company_id, data.get('supplier_id') or None, + data.get('vessel_id') or None, data.get('work_order_id') or None, + data.get('purchase_number'), data.get('invoice_number'), + data['purchase_date'], data.get('delivery_date') or None, + data.get('status','requested'), + data.get('requested_by') or (u['full_name'] if u else ''), + data.get('payment_method'), data.get('notes'), invoice_photo)) + conn.commit() + pid = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id'] + conn.close() + return redirect(url_for('purchase_detail', pid=pid)) + suppliers = conn.execute("SELECT * FROM suppliers ORDER BY name").fetchall() + if company_id: + vessels = conn.execute("SELECT id,name FROM vessels WHERE company_id=? ORDER BY name", (company_id,)).fetchall() + work_orders = conn.execute("""SELECT wo.id, wo.order_number, v.name as vessel_name + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + WHERE v.company_id=? AND wo.status!='cancelled' ORDER BY wo.created_at DESC""", (company_id,)).fetchall() + else: + vessels = conn.execute("SELECT id,name FROM vessels ORDER BY name").fetchall() + work_orders = conn.execute("""SELECT wo.id, wo.order_number, v.name as vessel_name + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + WHERE wo.status!='cancelled' ORDER BY wo.created_at DESC""").fetchall() + u = current_user() + conn.close() + return render_template('purchase_form.html', suppliers=suppliers, vessels=vessels, + work_orders=work_orders, statuses=PURCHASE_STATUSES, + default_requester=u['full_name'] if u else '') + +@app.route('/purchases/') +@login_required +def purchase_detail(pid): + conn = get_db() + purchase = conn.execute("""SELECT pu.*,s.name as supplier_name, + v.name as vessel_name, wo.order_number as wo_number + FROM purchases pu LEFT JOIN suppliers s ON pu.supplier_id=s.id + LEFT JOIN vessels v ON pu.vessel_id=v.id + LEFT JOIN work_orders wo ON pu.work_order_id=wo.id + WHERE pu.id=?""", (pid,)).fetchone() + items = conn.execute("""SELECT pi.*,p.name as part_name,p.part_number as catalog_number + FROM purchase_items pi LEFT JOIN parts p ON pi.part_id=p.id + WHERE pi.purchase_id=?""", (pid,)).fetchall() + parts = conn.execute("SELECT id,name,part_number FROM parts ORDER BY name").fetchall() + conn.close() + return render_template('purchase_detail.html', purchase=purchase, items=items, + parts=parts, statuses=PURCHASE_STATUSES) + +@app.route('/purchases//update-status', methods=['POST']) +@login_required +def purchase_update_status(pid): + data = request.json + new_status = data.get('status') + conn = get_db() + extra_fields = "" + extra_vals = [] + u = current_user() + if new_status == 'approved': + extra_fields = ", approved_by=?" + extra_vals.append(u['full_name'] if u else '') + elif new_status == 'received': + extra_fields = ", received_date=date('now')" + conn.execute(f"UPDATE purchases SET status=?,updated_at=CURRENT_TIMESTAMP{extra_fields} WHERE id=?", + ([new_status] + extra_vals + [pid])) + # If received — update inventory + if new_status == 'received': + items = conn.execute("SELECT * FROM purchase_items WHERE purchase_id=?", (pid,)).fetchall() + purchase = conn.execute("SELECT * FROM purchases WHERE id=?", (pid,)).fetchone() + for item in items: + if item['part_id']: + qty = float(item['quantity_received'] or item['quantity']) + conn.execute("UPDATE parts SET quantity=quantity+?,updated_at=CURRENT_TIMESTAMP WHERE id=?", + (qty, item['part_id'])) + conn.execute("""INSERT INTO inventory_movements + (part_id,movement_type,quantity,reference_type,reference_id,vessel_id,notes,created_by) + VALUES (?,?,?,?,?,?,?,?)""", + (item['part_id'], 'in', qty, 'purchase', pid, + purchase['vessel_id'] if purchase else None, + f"Compra #{purchase['purchase_number'] or pid}", + u['full_name'] if u else '')) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/purchases//upload-invoice', methods=['POST']) +@login_required +def upload_invoice(pid): + f = request.files.get('invoice') + if not f or not f.filename: + return jsonify({'ok': False, 'error': 'No file'}) + ext = f.filename.rsplit('.',1)[-1].lower() + fname = f"invoice_{pid}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext}" + f.save(os.path.join(UPLOAD_FOLDER, fname)) + conn = get_db() + conn.execute("UPDATE purchases SET invoice_photo=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (fname, pid)) + conn.commit(); conn.close() + return jsonify({'ok': True, 'filename': fname}) + +@app.route('/purchases//add-item', methods=['POST']) +@login_required +def add_purchase_item(pid): + data = request.json + part_id = data.get('part_id') + qty = float(data.get('quantity', 1)) + cost = float(data.get('unit_cost', 0)) + conn = get_db() + conn.execute("""INSERT INTO purchase_items + (purchase_id,part_id,description,part_number,quantity,unit_cost,quantity_received,notes) + VALUES (?,?,?,?,?,?,?,?)""", + (pid, part_id or None, data.get('description',''), + data.get('part_number',''), qty, cost, qty, data.get('notes',''))) + conn.execute("""UPDATE purchases SET + total_amount=(SELECT COALESCE(SUM(total_cost),0) FROM purchase_items WHERE purchase_id=?), + updated_at=CURRENT_TIMESTAMP WHERE id=?""", (pid, pid)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/purchases//delete-item/', methods=['DELETE']) +@login_required +def delete_purchase_item(pid, iid): + conn = get_db() + conn.execute("DELETE FROM purchase_items WHERE id=? AND purchase_id=?", (iid, pid)) + conn.execute("""UPDATE purchases SET + total_amount=(SELECT COALESCE(SUM(total_cost),0) FROM purchase_items WHERE purchase_id=?), + updated_at=CURRENT_TIMESTAMP WHERE id=?""", (pid, pid)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + + + +# ── SUPPLIERS ───────────────────────────────────────────────────────────────── +@app.route('/suppliers') +@login_required +def suppliers(): + conn = get_db() + suppliers = conn.execute("SELECT * FROM suppliers ORDER BY name").fetchall() + conn.close() + return render_template('suppliers.html', suppliers=suppliers) + +@app.route('/suppliers/new', methods=['GET','POST']) +@login_required +def supplier_new(): + if request.method == 'POST': + data = request.form + conn = get_db() + conn.execute("INSERT INTO suppliers (company_id,name,contact_name,phone,email,address,notes) VALUES (?,?,?,?,?,?,?)", + (cid(),data['name'],data.get('contact_name'),data.get('phone'),data.get('email'),data.get('address'),data.get('notes'))) + conn.commit(); conn.close() + return redirect(url_for('suppliers')) + return render_template('supplier_form.html', supplier=None) + +@app.route('/suppliers//edit', methods=['GET','POST']) +@login_required +def supplier_edit(sid): + conn = get_db() + supplier = conn.execute("SELECT * FROM suppliers WHERE id=?", (sid,)).fetchone() + if request.method == 'POST': + data = request.form + conn.execute("""UPDATE suppliers SET name=?,contact_name=?,phone=?,email=?, + address=?,notes=? WHERE id=?""", + (data['name'],data.get('contact_name'),data.get('phone'), + data.get('email'),data.get('address'),data.get('notes'),sid)) + conn.commit(); conn.close() + return redirect(url_for('suppliers')) + conn.close() + return render_template('supplier_form.html', supplier=supplier) + +@app.route('/suppliers//delete', methods=['DELETE']) +@login_required +def supplier_delete(sid): + conn = get_db() + try: + conn.execute("DELETE FROM suppliers WHERE id=?", (sid,)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + except Exception as e: + conn.close() + return jsonify({'ok': False, 'error': str(e)}), 500 + +# ── API MISC ────────────────────────────────────────────────────────────────── +@app.route('/api/parts/') +@login_required +def api_part(pid): + conn = get_db() + part = conn.execute("SELECT * FROM parts WHERE id=?", (pid,)).fetchone() + conn.close() + return jsonify(dict(part)) if part else (jsonify({}), 404) + +@app.route('/api/vessel-equipment/') +@login_required +def api_vessel_equipment(vid): + conn = get_db() + equip = conn.execute( + "SELECT id,name,serial_number,make,model FROM vessel_equipment WHERE vessel_id=? AND is_active=1 ORDER BY name", + (vid,)).fetchall() + conn.close() + return jsonify([dict(e) for e in equip]) + + +@app.route('/setup/migrate') +@login_required +def force_migrate(): + if not is_admin(): + return redirect(url_for('dashboard')) + conn = get_db() + results = [] + sqls = [ + """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)""", + """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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""", + "ALTER TABLE work_orders ADD COLUMN system_id INTEGER", + "ALTER TABLE work_orders ADD COLUMN scope TEXT", + "ALTER TABLE work_orders ADD COLUMN root_cause TEXT", + "ALTER TABLE work_orders ADD COLUMN repairs_done TEXT", + "ALTER TABLE work_orders ADD COLUMN signature_tech TEXT", + "ALTER TABLE work_orders ADD COLUMN signature_client TEXT", + "ALTER TABLE work_orders ADD COLUMN saved_pdf TEXT", + "ALTER TABLE vessels ADD COLUMN company_id INTEGER", + "ALTER TABLE vessels ADD COLUMN engine_hours REAL DEFAULT 0", + "ALTER TABLE vessels ADD COLUMN captain_name TEXT", + "ALTER TABLE vessels ADD COLUMN captain_phone TEXT", + "ALTER TABLE vessels ADD COLUMN captain_email TEXT", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (1,'Propulsion',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (2,'Generation',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (3,'Navigation and Communications',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (4,'Electrical System',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (5,'Batteries and Charging',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (6,'Hydraulic',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (7,'HVAC',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (8,'Plumbing and Water',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (9,'Safety',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (10,'Hull and Structure',1)", + "INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (11,'Other',1)", + ] + for sql in sqls: + try: + conn.execute(sql) + conn.commit() + results.append('OK: ' + sql[:60]) + except Exception as e: + results.append('Skip: ' + str(e)[:60]) + conn.close() + return '
'.join(results) + '

Back to Dashboard' + +# ── SYSTEMS ─────────────────────────────────────────────────────────────────── +@app.route('/systems') +@login_required +def systems(): + conn = get_db() + company_id = cid() + sys_list = conn.execute(""" + SELECT * FROM systems + WHERE is_default=1 OR company_id IS ? OR company_id=? + ORDER BY is_default DESC, name + """, (None if not company_id else company_id, company_id or -1)).fetchall() + conn.close() + return render_template('systems.html', systems=sys_list) + +@app.route('/systems/new', methods=['GET','POST']) +@login_required +def system_new(): + if request.method == 'POST': + data = request.form + conn = get_db() + conn.execute("INSERT INTO systems (company_id, name, description) VALUES (?,?,?)", + (cid(), data['name'], data.get('description',''))) + conn.commit(); conn.close() + return redirect(url_for('systems')) + return render_template('system_form.html', system=None) + +@app.route('/systems//edit', methods=['GET','POST']) +@login_required +def system_edit(sid): + conn = get_db() + system = conn.execute("SELECT * FROM systems WHERE id=?", (sid,)).fetchone() + if request.method == 'POST': + data = request.form + conn.execute("UPDATE systems SET name=?, description=? WHERE id=? AND is_default=0", + (data['name'], data.get('description',''), sid)) + conn.commit(); conn.close() + return redirect(url_for('systems')) + conn.close() + return render_template('system_form.html', system=system) + +@app.route('/systems//delete', methods=['DELETE']) +@login_required +def system_delete(sid): + conn = get_db() + conn.execute("DELETE FROM systems WHERE id=? AND is_default=0", (sid,)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/api/systems') +@login_required +def api_systems(): + conn = get_db() + company_id = cid() + sys_list = conn.execute(""" + SELECT * FROM systems + WHERE is_default=1 OR company_id IS NULL OR company_id=? + ORDER BY is_default DESC, name + """, (company_id or -1,)).fetchall() + conn.close() + return jsonify([dict(s) for s in sys_list]) + +# ── WORK ORDER EQUIPMENT ────────────────────────────────────────────────────── +@app.route('/work-orders//update-equipment/', methods=['POST']) +@login_required +def update_wo_equipment(woid, weid): + data = request.json + conn = get_db() + try: + labor_hours = float(data.get('labor_hours') or 0) + labor_rate = float(data.get('labor_rate') or 0) + conn.execute("""UPDATE work_order_equipment + SET description=?, notes=?, labor_hours=?, labor_rate=? + WHERE id=? AND work_order_id=?""", + (data.get('description',''), data.get('notes',''), + labor_hours, labor_rate, weid, woid)) + # Recalculate totals + conn.execute("""UPDATE work_orders SET + labor_hours = (SELECT COALESCE(SUM(labor_hours),0) FROM work_order_equipment WHERE work_order_id=?), + labor_rate = (SELECT CASE WHEN SUM(labor_hours)>0 THEN SUM(labor_hours*labor_rate)/SUM(labor_hours) ELSE ? END FROM work_order_equipment WHERE work_order_id=?), + updated_at = CURRENT_TIMESTAMP WHERE id=?""", + (woid, labor_rate, woid, woid)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + except Exception as e: + conn.close() + return jsonify({'ok': False, 'error': str(e)}), 500 + +@app.route('/work-orders//add-equipment', methods=['POST']) +@login_required +def add_wo_equipment(woid): + data = request.json + conn = get_db() + try: + labor_hours = float(data.get('labor_hours') or 0) + labor_rate = float(data.get('labor_rate') or 0) + conn.execute("""INSERT INTO work_order_equipment + (work_order_id, equipment_id, description, notes, labor_hours, labor_rate) + VALUES (?,?,?,?,?,?)""", + (woid, data.get('equipment_id') or None, + data.get('description',''), data.get('notes',''), + labor_hours, labor_rate)) + # Recalculate total labor on work order + conn.execute("""UPDATE work_orders SET + labor_hours = (SELECT COALESCE(SUM(labor_hours),0) FROM work_order_equipment WHERE work_order_id=?), + labor_rate = (SELECT CASE WHEN SUM(labor_hours)>0 THEN SUM(labor_hours*labor_rate)/SUM(labor_hours) ELSE ? END FROM work_order_equipment WHERE work_order_id=?), + updated_at = CURRENT_TIMESTAMP + WHERE id=?""", (woid, labor_rate, woid, woid)) + conn.commit() + conn.close() + return jsonify({'ok': True}) + except Exception as e: + conn.close() + return jsonify({'ok': False, 'error': str(e)}), 500 + +@app.route('/work-orders//remove-equipment/', methods=['DELETE']) +@login_required +def remove_wo_equipment(woid, weid): + conn = get_db() + conn.execute("DELETE FROM work_order_equipment WHERE id=?", (weid,)) + # Recalculate totals + conn.execute("""UPDATE work_orders SET + labor_hours = (SELECT COALESCE(SUM(labor_hours),0) FROM work_order_equipment WHERE work_order_id=?), + updated_at = CURRENT_TIMESTAMP WHERE id=?""", (woid, woid)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/api/wo-equipment/') +@login_required +def api_wo_equipment(woid): + conn = get_db() + items = conn.execute(""" + SELECT woe.*, ve.name as equip_name, ve.serial_number, ve.make, ve.model + FROM work_order_equipment woe + LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id + WHERE woe.work_order_id=? + ORDER BY woe.created_at + """, (woid,)).fetchall() + conn.close() + return jsonify([dict(i) for i in items]) + +# ── SIGNATURES ─────────────────────────────────────────────────────────────── +@app.route('/work-orders//save-signature', methods=['POST']) +@login_required +def save_signature(woid): + import base64, re + data = request.json + who = data.get('who') # 'tech' or 'client' + dataurl = data.get('dataUrl', '') + if not who or not dataurl: + return jsonify({'error': 'Missing data'}), 400 + # Strip header: data:image/png;base64,.... + match = re.match(r'data:image/\w+;base64,(.*)', dataurl, re.DOTALL) + if not match: + return jsonify({'error': 'Invalid image'}), 400 + img_bytes = base64.b64decode(match.group(1)) + fname = f"sig_{woid}_{who}_{uuid.uuid4().hex[:6]}.png" + with open(os.path.join(SIG_FOLDER, fname), 'wb') as f: + f.write(img_bytes) + col = 'signature_tech' if who == 'tech' else 'signature_client' + conn = get_db() + # Delete old file if exists + old = conn.execute(f"SELECT {col} FROM work_orders WHERE id=?", (woid,)).fetchone() + if old and old[col]: + op = os.path.join(SIG_FOLDER, old[col]) + if os.path.exists(op): os.remove(op) + conn.execute(f"UPDATE work_orders SET {col}=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (fname, woid)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/work-orders//clear-signature', methods=['POST']) +@login_required +def clear_signature(woid): + who = request.json.get('who') + col = 'signature_tech' if who == 'tech' else 'signature_client' + conn = get_db() + old = conn.execute(f"SELECT {col} FROM work_orders WHERE id=?", (woid,)).fetchone() + if old and old[col]: + fp = os.path.join(SIG_FOLDER, old[col]) + if os.path.exists(fp): os.remove(fp) + conn.execute(f"UPDATE work_orders SET {col}=NULL WHERE id=?", (woid,)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +# ── EMAIL CONFIG ────────────────────────────────────────────────────────────── +@app.route('/settings/email', methods=['GET','POST']) +@admin_required +def email_settings(): + conn = get_db() + company_id = cid() # None if superadmin + cfg = conn.execute("SELECT * FROM email_config WHERE company_id IS ?", (company_id,)).fetchone() + if request.method == 'POST': + data = request.form + if cfg: + conn.execute("""UPDATE email_config SET smtp_host=?,smtp_port=?,smtp_user=?, + smtp_password=?,from_name=?,from_email=?,use_tls=?,updated_at=CURRENT_TIMESTAMP + WHERE company_id IS ?""", + (data.get('smtp_host'),int(data.get('smtp_port',587)), + data.get('smtp_user'),data.get('smtp_password'), + data.get('from_name'),data.get('from_email'), + 1 if data.get('use_tls') else 0, company_id)) + else: + conn.execute("""INSERT INTO email_config + (company_id,smtp_host,smtp_port,smtp_user,smtp_password,from_name,from_email,use_tls) + VALUES (?,?,?,?,?,?,?,?)""", + (company_id,data.get('smtp_host'),int(data.get('smtp_port',587)), + data.get('smtp_user'),data.get('smtp_password'), + data.get('from_name'),data.get('from_email'), + 1 if data.get('use_tls') else 0)) + conn.commit(); conn.close() + return redirect(url_for('email_settings')) + companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall() + conn.close() + return render_template('email_settings.html', cfg=cfg, companies=companies_list) + +@app.route('/settings/email/test', methods=['POST']) +@admin_required +def email_test(): + from mailer import send_report_email + to = request.json.get('to','') + company_id = cid() + ok, err = send_report_email( + to_email=to, to_name='Test', + subject='✅ Marine Maintenance — Email configurado correctamente', + body_html='

¡Funciona!

Tu configuración de email está correcta.

', + pdf_bytes=b'', pdf_filename='test.pdf', + company_id=company_id + ) + return jsonify({'ok': ok, 'error': err}) + +@app.route('/settings/email/debug') +@login_required +def email_debug(): + """Debug: muestra qué config de email está activa.""" + from mailer import get_email_config + company_id = cid() + cfg = get_email_config(company_id) + if not cfg: + return jsonify({'error': 'No hay config de email', 'company_id': company_id}) + # Hide password + safe = {k: ('***' if 'password' in k.lower() else v) for k,v in cfg.items()} + return jsonify({'config': safe, 'company_id_used': company_id}) + +# ── SEND REPORT ─────────────────────────────────────────────────────────────── +@app.route('/work-orders//send', methods=['GET','POST']) +@login_required +def send_report(woid): + from mailer import send_report_email, build_email_body + from report_generator import generate_work_order_pdf + conn = get_db() + order = conn.execute(""" + SELECT wo.*, v.name as vessel_name, c.name as company_name, + c.logo_path, c.id as company_id_val + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + LEFT JOIN companies c ON v.company_id=c.id WHERE wo.id=?""", (woid,)).fetchone() + vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone() + photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type", (woid,)).fetchall() + parts = conn.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop + LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall() + wo_equip = conn.execute("""SELECT woe.*,ve.name as equip_name,ve.serial_number,ve.make,ve.model + FROM work_order_equipment woe LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id + WHERE woe.work_order_id=?""", (woid,)).fetchall() + conn.close() + + if request.method == 'POST': + data = request.form + to_email = data.get('to_email','').strip() + to_name = data.get('to_name','').strip() + subject = data.get('subject', f"Reporte de Mantenimiento — {order['order_number']}") + lang = data.get('lang', 'es') + company_id_val = order['company_id_val'] or cid() or 0 + + # ── Try to reuse existing saved PDF for this lang ───────────────────── + pdf_bytes = None + fname = None + fpath = None + + # Look for existing PDF in email_log for this WO and lang + conn3 = get_db() + existing_log = conn3.execute("""SELECT pdf_filename FROM email_log + WHERE work_order_id=? AND lang=? AND status='sent' AND pdf_filename IS NOT NULL + ORDER BY sent_at DESC LIMIT 1""", (woid, lang)).fetchone() + conn3.close() + + if existing_log and existing_log['pdf_filename']: + candidate = os.path.join(PDF_FOLDER, existing_log['pdf_filename']) + if os.path.exists(candidate): + with open(candidate, 'rb') as f: + pdf_bytes = f.read() + fname = existing_log['pdf_filename'] + fpath = candidate + + # Also check saved_pdf field + if not pdf_bytes and order.get('saved_pdf'): + candidate = os.path.join(PDF_FOLDER, order['saved_pdf']) + if os.path.exists(candidate): + with open(candidate, 'rb') as f: + pdf_bytes = f.read() + fname = order['saved_pdf'] + fpath = candidate + + # Regenerate only if no existing PDF found + if not pdf_bytes: + logo_path = None + if order['logo_path']: + lp = os.path.join(LOGO_FOLDER, order['logo_path']) + if os.path.exists(lp): logo_path = lp + + pdf_buf = generate_work_order_pdf( + order=dict(order), vessel=dict(vessel), + photos=[dict(p) for p in photos], parts_used=[dict(p) for p in parts], + wo_equipment=[dict(e) for e in wo_equip], + upload_folder=UPLOAD_FOLDER, sig_folder=SIG_FOLDER, + company_name=order['company_name'] or 'Marine Maintenance Pro', + company_logo=logo_path, company_info='', lang=lang) + pdf_bytes = pdf_buf.read() + + import datetime + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + fname = f"{order['order_number'].replace('/','_')}_{lang}_{timestamp}.pdf" + fpath = os.path.join(PDF_FOLDER, fname) + with open(fpath, 'wb') as f: + f.write(pdf_bytes) + conn2 = get_db() + if not order.get('saved_pdf'): + conn2.execute("UPDATE work_orders SET saved_pdf=? WHERE id=?", (fname, woid)) + conn2.commit() + conn2.close() + + body_html = build_email_body(dict(order), dict(vessel), + order['company_name'] or 'Marine Maintenance Pro') + + ok, err = send_report_email( + to_email=to_email, to_name=to_name, + subject=subject, body_html=body_html, + pdf_bytes=pdf_bytes, + pdf_filename=f"{order['order_number']}.pdf", + company_id=company_id_val) + + # Log the email attempt + u = current_user() + log_conn = get_db() + log_conn.execute("""INSERT INTO email_log + (work_order_id, to_email, to_name, subject, lang, pdf_filename, status, error_msg, sent_by) + VALUES (?,?,?,?,?,?,?,?,?)""", + (woid, to_email, to_name, subject, lang, fname, + 'sent' if ok else 'failed', err if not ok else None, + u['full_name'] if u else '')) + log_conn.commit(); log_conn.close() + + return jsonify({'ok': ok, 'error': err}) + + # GET — render send modal data + captain_email = vessel['captain_email'] or '' + captain_name = vessel['captain_name'] or '' + owner_email = vessel['owner_email'] or '' + owner_name = vessel['owner_name'] or '' + conn.close() + return render_template('send_report.html', order=order, vessel=vessel, + captain_email=captain_email, captain_name=captain_name, + owner_email=owner_email, owner_name=owner_name) + +# ── SAVE PDF & SHARE LINK ───────────────────────────────────────────────────── +@app.route('/work-orders//save-pdf', methods=['POST']) +@login_required +def save_pdf(woid): + from report_generator import generate_work_order_pdf + conn = get_db() + order = conn.execute(""" + SELECT wo.*, v.name as vessel_name, c.name as company_name, c.logo_path + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + LEFT JOIN companies c ON v.company_id=c.id WHERE wo.id=?""", (woid,)).fetchone() + vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone() + photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type", (woid,)).fetchall() + parts = conn.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop + LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall() + + logo_path = None + if order['logo_path']: + lp = os.path.join(LOGO_FOLDER, order['logo_path']) + if os.path.exists(lp): logo_path = lp + + pdf_buf = generate_work_order_pdf( + order=dict(order), vessel=dict(vessel), + photos=[dict(p) for p in photos], parts_used=[dict(p) for p in parts], + upload_folder=UPLOAD_FOLDER, + company_name=order['company_name'] or 'Marine Maintenance Pro', + company_logo=logo_path, company_info='') + + fname = f"{order['order_number'].replace('/','_')}.pdf" + fpath = os.path.join(PDF_FOLDER, fname) + with open(fpath, 'wb') as f: + f.write(pdf_buf.read()) + + conn.execute("UPDATE work_orders SET saved_pdf=? WHERE id=?", (fname, woid)) + conn.commit(); conn.close() + return jsonify({'ok': True, 'filename': fname, + 'url': f"/static/uploads/pdfs/{fname}"}) + +@app.route('/work-orders//share') +@login_required +def share_report(woid): + """Returns share info: PDF URL, WhatsApp link, SMS link.""" + conn = get_db() + order = conn.execute("SELECT * FROM work_orders WHERE id=?", (woid,)).fetchone() + conn.close() + + host = request.host_url.rstrip('/') + pdf_url = f"{host}/work-orders/{woid}/pdf" + + # If saved PDF exists, use static link (better for sharing) + if order['saved_pdf']: + pdf_url = f"{host}/static/uploads/pdfs/{order['saved_pdf']}" + + scope = order['scope'] or order['description'][:60] if order['description'] else '' + msg = f"Reporte de mantenimiento {order['order_number']} — {scope}\n{pdf_url}" + + import urllib.parse + wa_link = f"https://wa.me/?text={urllib.parse.quote(msg)}" + sms_link = f"sms:?body={urllib.parse.quote(msg)}" + + return jsonify({ + 'ok': True, + 'pdf_url': pdf_url, + 'wa_link': wa_link, + 'sms_link': sms_link, + 'message': msg + }) + +# ══════════════════════════════════════════════════════════════════════════════ +# ISM — PROCEDIMIENTOS DE TRABAJO SEGURO (SWP) +# ══════════════════════════════════════════════════════════════════════════════ + +SWP_CATEGORIES = [ + ('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'), +] + +def next_swp_code(company_id): + conn = get_db() + row = conn.execute("""SELECT code FROM swp WHERE company_id=? + ORDER BY code DESC LIMIT 1""", (company_id,)).fetchone() + conn.close() + if not row: + return "SWP-001" + try: + last_num = int(row['code'].split('-')[-1]) + return f"SWP-{(last_num+1):03d}" + except: + # Count as fallback + conn2 = get_db() + n = conn2.execute("SELECT COUNT(*) as n FROM swp WHERE company_id=?", (company_id,)).fetchone()['n'] + conn2.close() + return f"SWP-{(n+1):03d}" + +def next_version(swp_id): + conn = get_db() + row = conn.execute("""SELECT version FROM swp_versions WHERE swp_id=? + ORDER BY created_at DESC LIMIT 1""", (swp_id,)).fetchone() + conn.close() + if not row: return 'v1.0' + try: + parts = row['version'].lstrip('v').split('.') + return f"v{parts[0]}.{int(parts[1])+1}" + except: + return 'v1.1' + +@app.route('/ism') +@login_required +def ism_index(): + from auth import is_superadmin + conn = get_db() + company_id = cid() + # Superadmin can filter by company + filter_cid = request.args.get('company_id', type=int) + companies = [] + if is_superadmin(): + companies = conn.execute("SELECT id, name FROM companies ORDER BY name").fetchall() + if filter_cid: + company_id = filter_cid + else: + company_id = None # show all for superadmin by default + if company_id: + swps = conn.execute(""" + SELECT s.*, sv.version, sv.effective_date, sv.approved_by, + sv.status as ver_status, sv.id as ver_id, + c.name as company_name + FROM swp s + LEFT JOIN swp_versions sv ON s.current_version_id=sv.id + LEFT JOIN companies c ON s.company_id=c.id + WHERE s.company_id=? ORDER BY s.category, s.code""", (company_id,)).fetchall() + else: + swps = conn.execute(""" + SELECT s.*, sv.version, sv.effective_date, sv.approved_by, + sv.status as ver_status, sv.id as ver_id, + c.name as company_name + FROM swp s + LEFT JOIN swp_versions sv ON s.current_version_id=sv.id + LEFT JOIN companies c ON s.company_id=c.id + ORDER BY c.name, s.category, s.code""").fetchall() + conn.close() + return render_template('ism_index.html', swps=swps, categories=SWP_CATEGORIES, + companies=companies, current_company=company_id, + is_superadmin=is_superadmin()) + +@app.route('/ism/new', methods=['GET','POST']) +@login_required +def swp_new(): + from auth import is_superadmin + conn = get_db() + company_id = cid() + companies = [] + if is_superadmin(): + companies = conn.execute("SELECT id,name FROM companies ORDER BY name").fetchall() + if request.method == 'POST': + data = request.form + # Superadmin can pick company from form + if is_superadmin() and data.get('company_id'): + company_id = int(data['company_id']) + code = next_swp_code(company_id) + # Double-check uniqueness + while conn.execute("SELECT id FROM swp WHERE code=? AND company_id=?", + (code, company_id)).fetchone(): + code = next_swp_code(company_id) + conn.execute("""INSERT INTO swp (company_id,code,title,category,status) + VALUES (?,?,?,?,?)""", + (company_id, code, data['title'], data['category'], 'active')) + conn.commit() + swp_id = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id'] + import json + steps = [s.strip() for s in data.get('steps','').split('\n') if s.strip()] + hazards = [h.strip() for h in data.get('hazards','').split('\n') if h.strip()] + ppe = [p.strip() for p in data.get('ppe','').split('\n') if p.strip()] + refs = [r.strip() for r in data.get('ref_standards','').split('\n') if r.strip()] + u = current_user() + tools = [t.strip() for t in data.get('tools','').split('\n') if t.strip()] + conn.execute("""INSERT INTO swp_versions + (swp_id,version,purpose,scope,hazards,ppe,tools,steps,emergency, + ref_standards,status,change_reason,created_by,approved_by, + approved_at,effective_date) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,?)""", + (swp_id,'v1.0',data.get('purpose'),data.get('scope'), + json.dumps(hazards),json.dumps(ppe),json.dumps(tools),json.dumps(steps), + data.get('emergency'),json.dumps(refs), + 'draft','Versión inicial', + u['full_name'] if u else '', + u['full_name'] if u else '', + data.get('effective_date') or None)) + conn.commit() + ver_id = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id'] + conn.execute("UPDATE swp SET current_version_id=? WHERE id=?", (ver_id, swp_id)) + conn.commit(); conn.close() + return redirect(url_for('swp_detail', sid=swp_id)) + conn.close() + return render_template('swp_form.html', swp=None, categories=SWP_CATEGORIES, + code=next_swp_code(company_id or 0), + companies=companies, is_superadmin=is_superadmin()) + +@app.route('/ism/') +@login_required +def swp_detail(sid): + conn = get_db() + swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone() + versions = conn.execute("""SELECT * FROM swp_versions WHERE swp_id=? + ORDER BY created_at DESC""", (sid,)).fetchall() + current = conn.execute("""SELECT * FROM swp_versions WHERE id=?""", + (swp['current_version_id'],)).fetchone() if swp['current_version_id'] else None + conn.close() + import json + return render_template('swp_detail.html', swp=swp, versions=versions, + current=current, categories=dict(SWP_CATEGORIES), json=json) + +@app.route('/ism//versions//edit', methods=['GET','POST']) +@login_required +def swp_edit_version(sid, vid): + """Edit a DRAFT version — not allowed once approved/active.""" + conn = get_db() + swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone() + version = conn.execute("SELECT * FROM swp_versions WHERE id=? AND swp_id=?", (vid, sid)).fetchone() + if not version or version['status'] not in ('draft', 'active'): + conn.close() + return redirect(url_for('swp_detail', sid=sid)) + if request.method == 'POST': + import json + data = request.form + u = current_user() + steps = [s.strip() for s in data.get('steps','').split('\n') if s.strip()] + hazards = [h.strip() for h in data.get('hazards','').split('\n') if h.strip()] + ppe = [p.strip() for p in data.get('ppe','').split('\n') if p.strip()] + refs = [r.strip() for r in data.get('ref_standards','').split('\n') if r.strip()] + tools = [t.strip() for t in data.get('tools','').split('\n') if t.strip()] + conn.execute("""UPDATE swp_versions SET + purpose=?, scope=?, hazards=?, ppe=?, tools=?, steps=?, emergency=?, + ref_standards=?, change_reason=?, diff_summary=?, effective_date=?, + created_by=? + WHERE id=?""", + (data.get('purpose'), data.get('scope'), + json.dumps(hazards), json.dumps(ppe), json.dumps(tools), json.dumps(steps), + data.get('emergency'), json.dumps(refs), + data.get('change_reason'), data.get('diff_summary'), + data.get('effective_date') or None, + u['full_name'] if u else version['created_by'], + vid)) + conn.commit(); conn.close() + return redirect(url_for('swp_detail', sid=sid)) + conn.close() + import json + return render_template('swp_version_edit.html', swp=swp, version=version, + categories=SWP_CATEGORIES, json=json) + +@app.route('/ism//edit', methods=['GET','POST']) +@login_required +def swp_edit(sid): + from auth import is_superadmin + conn = get_db() + swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone() + companies = conn.execute("SELECT id,name FROM companies ORDER BY name").fetchall() + if request.method == 'POST': + data = request.form + new_cid = data.get('company_id') or cid() + conn.execute("""UPDATE swp SET code=?, title=?, category=?, company_id=? + WHERE id=?""", (data['code'], data['title'], data['category'], new_cid, sid)) + conn.commit(); conn.close() + return redirect(url_for('swp_detail', sid=sid)) + conn.close() + return render_template('swp_edit.html', swp=swp, categories=SWP_CATEGORIES, + companies=companies, is_superadmin=is_superadmin()) + +@app.route('/ism//new-version', methods=['GET','POST']) +@login_required +def swp_new_version(sid): + conn = get_db() + swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone() + current = conn.execute("""SELECT * FROM swp_versions WHERE id=?""", + (swp['current_version_id'],)).fetchone() if swp['current_version_id'] else None + if request.method == 'POST': + data = request.form + import json + u = current_user() + steps = [s.strip() for s in data.get('steps','').split('\n') if s.strip()] + hazards = [h.strip() for h in data.get('hazards','').split('\n') if h.strip()] + ppe = [p.strip() for p in data.get('ppe','').split('\n') if p.strip()] + refs = [r.strip() for r in data.get('ref_standards','').split('\n') if r.strip()] + new_ver = next_version(sid) + tools = [t.strip() for t in data.get('tools','').split('\n') if t.strip()] + conn.execute("""INSERT INTO swp_versions + (swp_id,version,purpose,scope,hazards,ppe,tools,steps,emergency, + ref_standards,status,change_reason,diff_summary,created_by, + effective_date) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (sid, new_ver, data.get('purpose'), data.get('scope'), + json.dumps(hazards), json.dumps(ppe), json.dumps(tools), json.dumps(steps), + data.get('emergency'), json.dumps(refs), + 'draft', data.get('change_reason'), data.get('diff_summary'), + u['full_name'] if u else '', + data.get('effective_date') or None)) + conn.commit() + # Update current_version_id to point to new draft + new_ver_id = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id'] + conn.execute("UPDATE swp SET current_version_id=? WHERE id=?", (new_ver_id, sid)) + conn.commit() + conn.close() + return redirect(url_for('swp_detail', sid=sid)) + conn.close() + import json + return render_template('swp_version_form.html', swp=swp, current=current, + categories=dict(SWP_CATEGORIES), json=json, + new_version=next_version(sid)) + +@app.route('/ism//versions//approve', methods=['POST']) +@login_required +def swp_approve_version(sid, vid): + if not is_admin(): + return jsonify({'ok': False, 'error': 'Sin permiso'}), 403 + u = current_user() + conn = get_db() + # Supersede current active version + conn.execute("""UPDATE swp_versions SET status='superseded' + WHERE swp_id=? AND status='active'""", (sid,)) + # Approve new version + conn.execute("""UPDATE swp_versions SET status='active', + approved_by=?, approved_at=CURRENT_TIMESTAMP + WHERE id=?""", (u['full_name'] if u else '', vid)) + # Update swp current_version_id + conn.execute("UPDATE swp SET current_version_id=? WHERE id=?", (vid, sid)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/ism//pdf') +@login_required +def swp_pdf(sid): + from swp_generator import generate_swp_pdf + lang = request.args.get('lang', 'es') + conn = get_db() + swp = conn.execute("""SELECT s.*, c.name as company_name, c.logo_path, + c.address as company_address, c.phone as company_phone, + c.email as company_email + FROM swp s LEFT JOIN companies c ON s.company_id=c.id + WHERE s.id=?""", (sid,)).fetchone() + version = conn.execute("SELECT * FROM swp_versions WHERE id=?", + (swp['current_version_id'],)).fetchone() + conn.close() + if not version: + return "Sin versión activa", 404 + logo_path = None + company_name = swp['company_name'] + company_address = swp['company_address'] or '' + company_phone = swp['company_phone'] or '' + company_email = swp['company_email'] or '' + + # Fallback: if SWP has no company, use current user's company + if not swp['company_id'] or not swp['company_name']: + fallback_cid = cid() + if fallback_cid: + fc = conn2.execute("""SELECT name,logo_path,address,phone,email + FROM companies WHERE id=?""", (fallback_cid,)).fetchone() if False else None + fb_conn = get_db() + fc = fb_conn.execute("SELECT name,logo_path,address,phone,email FROM companies WHERE id=?", + (fallback_cid,)).fetchone() + fb_conn.close() + if fc: + company_name = fc['name'] + company_address = fc['address'] or '' + company_phone = fc['phone'] or '' + company_email = fc['email'] or '' + if fc['logo_path']: + lp = os.path.join(LOGO_FOLDER, fc['logo_path']) + if os.path.exists(lp): logo_path = lp + + if swp['logo_path'] and not logo_path: + lp = os.path.join(LOGO_FOLDER, swp['logo_path']) + if os.path.exists(lp): logo_path = lp + + company_info = ' | '.join(filter(None, [company_address, company_phone, company_email])) + import json as json_module + swp_dict = dict(swp) + swp_dict['company_info'] = company_info + swp_dict['company_name'] = company_name or swp['company_name'] or '' + buf = generate_swp_pdf(swp_dict, dict(version), logo_path, json_module, lang=lang) + return send_file(buf, mimetype='application/pdf', as_attachment=False, + download_name=f"{swp['code']}_{version['version']}_{lang}.pdf") + +# ── MSDS ────────────────────────────────────────────────────────────────────── +@app.route('/ism/msds') +@login_required +def msds_index(): + conn = get_db() + company_id = cid() + if company_id: + msds_list = conn.execute("""SELECT m.*, p.name as part_name + FROM msds m LEFT JOIN parts p ON m.part_id=p.id + WHERE m.company_id=? ORDER BY m.product_name""", (company_id,)).fetchall() + else: + msds_list = conn.execute("""SELECT m.*, p.name as part_name + FROM msds m LEFT JOIN parts p ON m.part_id=p.id + ORDER BY m.product_name""").fetchall() + parts = conn.execute("SELECT id,name FROM parts ORDER BY name").fetchall() + conn.close() + return render_template('msds_index.html', msds_list=msds_list, parts=parts) + +@app.route('/ism/msds/new', methods=['GET','POST']) +@login_required +def msds_new(): + conn = get_db() + if request.method == 'POST': + data = request.form + u = current_user() + f = request.files.get('pdf_file') + pdf_fname = None + if f and f.filename: + import uuid + pdf_fname = f"msds_{uuid.uuid4().hex[:8]}.pdf" + f.save(os.path.join(DOCS_FOLDER, pdf_fname)) + conn.execute("""INSERT INTO msds + (company_id,part_id,product_name,manufacturer,version,hazard_class, + hazards,first_aid,ppe_required,handling,storage,spill_procedure, + disposal,ref_standards,pdf_filename,created_by) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (cid(), data.get('part_id') or None, data['product_name'], + data.get('manufacturer'), data.get('version','v1.0'), + data.get('hazard_class'), data.get('hazards'), + data.get('first_aid'), data.get('ppe_required'), + data.get('handling'), data.get('storage'), + data.get('spill_procedure'), data.get('disposal'), + data.get('ref_standards'), pdf_fname, + u['full_name'] if u else '')) + conn.commit(); conn.close() + return redirect(url_for('msds_index')) + parts = conn.execute("SELECT id,name FROM parts ORDER BY name").fetchall() + conn.close() + return render_template('msds_form.html', msds=None, parts=parts) + +@app.route('/ism/msds//edit', methods=['GET','POST']) +@login_required +def msds_edit(mid): + conn = get_db() + msds = conn.execute("SELECT * FROM msds WHERE id=?", (mid,)).fetchone() + if request.method == 'POST': + data = request.form + u = current_user() + f = request.files.get('pdf_file') + pdf_fname = msds['pdf_filename'] + if f and f.filename: + import uuid + pdf_fname = f"msds_{uuid.uuid4().hex[:8]}.pdf" + f.save(os.path.join(DOCS_FOLDER, pdf_fname)) + conn.execute("""UPDATE msds SET product_name=?,manufacturer=?,version=?, + hazard_class=?,hazards=?,first_aid=?,ppe_required=?,handling=?, + storage=?,spill_procedure=?,disposal=?,ref_standards=?, + pdf_filename=?,updated_by=?,updated_at=CURRENT_TIMESTAMP + WHERE id=?""", + (data['product_name'],data.get('manufacturer'),data.get('version','v1.0'), + data.get('hazard_class'),data.get('hazards'),data.get('first_aid'), + data.get('ppe_required'),data.get('handling'),data.get('storage'), + data.get('spill_procedure'),data.get('disposal'),data.get('ref_standards'), + pdf_fname, u['full_name'] if u else '', mid)) + conn.commit(); conn.close() + return redirect(url_for('msds_index')) + parts = conn.execute("SELECT id,name FROM parts ORDER BY name").fetchall() + conn.close() + return render_template('msds_form.html', msds=msds, parts=parts) + +# ══════════════════════════════════════════════════════════════════════════════ +# WO ASIGNACIÓN Y NOTIFICACIONES INTERNAS +# ══════════════════════════════════════════════════════════════════════════════ + +@app.route('/work-orders//assign', methods=['POST']) +@login_required +def wo_assign(woid): + """Assign WO to technician(s) and send email with PDF.""" + from mailer import send_wo_notification + from report_generator import generate_work_order_pdf + data = request.get_json() or request.form + to_emails = data.get('to_emails','') + message = data.get('message','') + u = current_user() + sender_name = u['full_name'] if u else 'Sistema' + company_id = cid() + + conn = get_db() + wo = conn.execute("""SELECT wo.*, v.name as vessel_name + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + WHERE wo.id=?""", (woid,)).fetchone() + + if not wo: + conn.close() + return jsonify({'ok': False, 'error': 'WO no encontrada'}), 404 + + # Update assigned_to + to_list = [e.strip() for e in to_emails.replace(',',';').split(';') if e.strip()] + conn.execute("""UPDATE work_orders SET assigned_to=?, assigned_by=?, + assigned_at=CURRENT_TIMESTAMP WHERE id=?""", + (', '.join(to_list), sender_name, woid)) + conn.commit() + + # Generate PDF + photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=?", (woid,)).fetchall() + parts = conn.execute("""SELECT wop.*, p.name as part_name FROM work_order_parts wop + LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall() + wo_equip = conn.execute("SELECT * FROM work_order_equipment WHERE work_order_id=?", (woid,)).fetchall() + vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (wo['vessel_id'],)).fetchone() + company = conn.execute("SELECT * FROM companies WHERE id=?", (company_id,)).fetchone() + conn.close() + + logo_path = None + if company and company['logo_path']: + lp = os.path.join(LOGO_FOLDER, company['logo_path']) + if os.path.exists(lp): logo_path = lp + + try: + company_info_str = ' | '.join(filter(None, [ + (company.get('address') or '') if company else '', + (company.get('phone') or '') if company else '', + (company.get('email') or '') if company else '', + ])) + buf = generate_work_order_pdf( + order=dict(wo), + vessel=dict(vessel) if vessel else {}, + photos=[dict(p) for p in photos], + parts_used=[dict(p) for p in parts], + wo_equipment=[dict(e) for e in wo_equip], + upload_folder=UPLOAD_FOLDER, + sig_folder=SIG_FOLDER, + company_name=(company['name'] if company else 'Marine Maintenance Pro'), + company_logo=logo_path, + company_info=company_info_str, + lang='es') + pdf_fname = f"WO_{wo['order_number']}_assign.pdf" + pdf_path = os.path.join(PDF_FOLDER, pdf_fname) + with open(pdf_path, 'wb') as f: + f.write(buf.read()) + except Exception as e: + pdf_path = None + pdf_fname = None + + # Build email HTML + msg_extra = f"

Mensaje: {message}

" if message else "" + app_url = request.host_url.rstrip('/') + body = f""" +
+
+

Nueva Orden de Trabajo Asignada

+
+
+

Orden: {wo['order_number']}

+

Embarcación: {wo['vessel_name']}

+

Scope: {wo.get('scope','—')}

+

Asignado por: {sender_name}

+ {msg_extra} +
+

Se adjunta el PDF de la orden de trabajo.

+ + Ver Orden en el Sistema + +
+
""" + + ok, err = send_wo_notification( + company_id, to_list, + f"WO Asignada: {wo['order_number']} — {wo['vessel_name']}", + body, pdf_path, pdf_fname) + + # Log notification + db2 = get_db() + db2.execute("""INSERT INTO wo_notifications + (work_order_id,sent_to,sent_by,notification_type,message,pdf_filename,status) + VALUES (?,?,?,?,?,?,?)""", + (woid, ', '.join(to_list), sender_name, 'assignment', + message, pdf_fname, 'sent' if ok else f'error: {err}')) + db2.commit(); db2.close() + + return jsonify({'ok': ok, 'error': err if not ok else None}) + +@app.route('/work-orders//notify-status', methods=['POST']) +@login_required +def wo_notify_status(woid): + """Notify assigned person of status change.""" + from mailer import send_wo_notification + conn = get_db() + wo = conn.execute("""SELECT wo.*, v.name as vessel_name + FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id + WHERE wo.id=?""", (woid,)).fetchone() + conn.close() + if not wo or not wo['assigned_to']: + return jsonify({'ok': False, 'error': 'Sin asignado'}) + + u = current_user() + status_labels = {'open':'Abierta','in_progress':'En Progreso', + 'completed':'Completada','cancelled':'Cancelada'} + status_label = status_labels.get(wo['status'], wo['status']) + app_url = request.host_url.rstrip('/') + body = f""" +
+
+

Actualización de Orden de Trabajo

+
+
+

Orden: {wo['order_number']}

+

Embarcación: {wo['vessel_name']}

+

Nuevo estado: {status_label}

+

Actualizado por: {u['full_name'] if u else '—'}

+ + Ver Orden + +
+
""" + + to_list = [e.strip() for e in wo['assigned_to'].replace(',',';').split(';') if e.strip()] + ok, err = send_wo_notification(cid(), to_list, + f"Estado actualizado: {wo['order_number']} → {status_label}", body) + return jsonify({'ok': ok, 'error': err if not ok else None}) + +if __name__ == '__main__': + if not os.path.exists(DB_PATH): + init_db() + else: + init_db() # safe: uses CREATE IF NOT EXISTS + create_initial_superadmin() + app.run(host='0.0.0.0', port=5500, debug=True) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..67b7bdf --- /dev/null +++ b/auth.py @@ -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() diff --git a/mailer.py b/mailer.py new file mode 100644 index 0000000..09c1d6d --- /dev/null +++ b/mailer.py @@ -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""" + + +
+
+
+
{company_name.upper()}
+
REPORTE DE MANTENIMIENTO
+
+
+
Orden de Trabajo
+
{order_num}
+ + + + + + + + + + + + + + + + + + + + + +
Embarcación{vessel_n}
Trabajo{scope}
Fecha{date_end}
Estado + {status} +
Total${total:.2f}
+

+ Adjunto encontrará el reporte completo en PDF con todos los detalles del trabajo realizado. +

+ {'' if pdf_link else ''} +
+
+ {company_name} · Reporte generado automáticamente por Marine Maintenance Pro +
+
+""" + + +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}" diff --git a/report_generator.py b/report_generator.py new file mode 100644 index 0000000..878f41f --- /dev/null +++ b/report_generator.py @@ -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'{company_name}', + ParagraphStyle('ch', fontName='Helvetica-Bold', fontSize=16, textColor=NAVY)) + else: + logo_cell = Paragraph(f'{company_name}', + ParagraphStyle('ch', fontName='Helvetica-Bold', fontSize=16, textColor=NAVY, leading=20)) + + hdr = Table([[logo_cell, + Paragraph(f'{t(lang,"work_order")}
' + f'{order.get("order_number","")}', + 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'{t(lang,"status")}',S['label']), + Paragraph(f'{t(lang,"start_date")}',S['label']), + Paragraph(f'{t(lang,"end_date")}',S['label']), + Paragraph(f'{t(lang,"technician")}',S['label'])], + [Paragraph(f'{status_label(lang,sk)}',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'{t(lang,"vessel")}',S['label']), + Paragraph(f'{t(lang,"registration")}',S['label']), + Paragraph(f'{t(lang,"type")}',S['label']), + Paragraph(f'{t(lang,"year")}',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'{t(lang,"make_model")}',S['label']), + Paragraph(f'{t(lang,"engine_hours")}',S['label']), + Paragraph(f'{t(lang,"captain")}',S['label']), + Paragraph(f'{t(lang,"owner")}',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'{t(lang,"system")}', S['label']), + Paragraph(sys_name, S['value'])]) + if scope: + rows.append([Paragraph(f'{t(lang,"scope")}', 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', '
'), 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', '
'), 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', '
'), S['body'])], W)]) + story.append(Spacer(1,4)) + + # ── EQUIPMENT WORKED ───────────────────────────────────────────────────── + if wo_equipment: + hdr_row = [ + Paragraph(f'{t(lang,"equip_name")}',S['label']), + Paragraph(f'{t(lang,"serial")}',S['label']), + Paragraph(f'{t(lang,"hrs")}',S['label']), + Paragraph(f'{t(lang,"work_done")}',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'
{brand}' + 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'{t(lang,"part")}',S['label']), + Paragraph(f'{t(lang,"desc")}',S['label']), + Paragraph(f'{t(lang,"qty")}',S['label']), + Paragraph(f'{t(lang,"unit_price")}',S['label']), + Paragraph(f'{t(lang,"total")}',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'{t(lang,"total").upper()}', + ParagraphStyle('tb',fontName='Helvetica-Bold',fontSize=10,textColor=WHITE)), + Paragraph('',S['value']), + Paragraph(f'${tot:.2f}', + 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'{t(lang,"total").upper()}', + ParagraphStyle('tb',fontName='Helvetica-Bold',fontSize=10,textColor=WHITE)), + Paragraph('',S['value']), + Paragraph(f'${lc:.2f}', + 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'{t(lang,"total").upper()}', + ParagraphStyle('tb',fontName='Helvetica-Bold',fontSize=10,textColor=WHITE)), + Paragraph('',S['value']), + Paragraph(f'${tot:.2f}', + 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}
{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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..693e0ad --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0.0 +reportlab>=4.0.0 +Pillow>=10.0.0 +werkzeug>=3.0.0 diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..529fc5b --- /dev/null +++ b/schema.sql @@ -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 +); diff --git a/swp_generator.py b/swp_generator.py new file mode 100644 index 0000000..9d8fe73 --- /dev/null +++ b/swp_generator.py @@ -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'{i+1}',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"{swp.get('company_name','')}",mk('co',fontSize=12,textColor=NAVY)) + else: + logo_cell = Paragraph(f"{swp.get('company_name','')}",mk('co',fontSize=12,textColor=NAVY)) + + right_txt = (f'{L["title"]}
' + f'{swp["code"]}
' + f'{version["version"]} · {L["effective"]}: {version.get("effective_date") or "—"}') + 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'{swp.get("title","")}',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'{L["purpose_lbl"]}',mk('pl',fontSize=7,textColor=GRAY)), + Paragraph(purpose,mk('pv',fontSize=8,leading=11))]) + if scope: rows.append([Paragraph(f'{L["scope_lbl"]}',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'{L["version"]}',mk('vh',fontSize=7,textColor=GRAY)), + Paragraph(f'{L["reason"]}',mk('vh',fontSize=7,textColor=GRAY)), + Paragraph(f'{L["diff"]}',mk('vh',fontSize=7,textColor=GRAY)), + Paragraph(f'{L["created_by"]}',mk('vh',fontSize=7,textColor=GRAY)), + Paragraph(f'{L["effective"]}',mk('vh',fontSize=7,textColor=GRAY))], + [Paragraph(f"{version['version']}",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 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d874b99 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,378 @@ + + + + + + {% block title %}Marine Maintenance{% endblock %} + + + + {% block head %}{% endblock %} + + + +
+
+
+
+ +

{% block page_title %}Dashboard{% endblock %}

+
+
{% block topbar_actions %}{% endblock %}
+
+
+ {% block content %}{% endblock %} +
+
+ + {% block scripts %}{% endblock %} + + diff --git a/templates/companies.html b/templates/companies.html new file mode 100644 index 0000000..f72751f --- /dev/null +++ b/templates/companies.html @@ -0,0 +1,43 @@ +{% extends 'base.html' %} +{% block title %}Compañías{% endblock %} +{% block page_title %}Compañías{% endblock %} +{% block topbar_actions %} + + Nueva Compañía +{% endblock %} +{% block content %} +
+{% for c in companies %} +
+
+ {% if c.logo_path %} + + {% else %} +
🏢
+ {% endif %} +
+
{{ c.name }}
+
{{ c.vessel_count }} embarcación{{ 'es' if c.vessel_count != 1 else '' }}
+
+
+
+ {% if c.phone %}📞 {{ c.phone }}{% endif %} + {% if c.email %}✉️ {{ c.email }}{% endif %} + {% if c.address %}📍 {{ c.address }}{% endif %} + {% if c.website %}🌐 {{ c.website }}{% endif %} +
+ +
+{% else %} +
+
🏢
+
No hay compañías registradas.
+
+{% endfor %} +
+{% endblock %} diff --git a/templates/company_form.html b/templates/company_form.html new file mode 100644 index 0000000..e09232a --- /dev/null +++ b/templates/company_form.html @@ -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 %}← Volver{% endblock %} +{% block content %} +
+
+ {% if company and company.logo_path %} +
+
Logo actual
+ +
+ {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..bea95ba --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,84 @@ +{% extends 'base.html' %} +{% block title %}Dashboard — Marine Maintenance{% endblock %} +{% block page_title %}Dashboard{% endblock %} +{% block topbar_actions %} + + Nueva Orden +{% endblock %} +{% block content %} +
+
+
🚢 Embarcaciones
+
{{ stats.vessels }}
+
registradas
+
+
+
🔧 Órdenes Activas
+
{{ stats.open_orders }}
+
abiertas / en progreso
+
+
+
📦 Stock Bajo
+
{{ stats.low_stock }}
+
repuestos bajo mínimo
+
+
+
✅ Completadas
+
{{ stats.completed_this_month }}
+
este mes
+
+
+ +
+
+
🔧 Órdenes Recientes
+ {% if recent_orders %} +
+ + + + {% for o in recent_orders %} + + + + + + + {% endfor %} + +
OrdenEmbarcaciónEstado
{{ o.order_number }}{{ o.vessel_name }}{{ o.status.replace('_',' ') }}Ver
+
+ {% else %} +

No hay órdenes aún.

+ {% endif %} +
+ +
+ {% if low_stock_parts %} +
+
⚠️ Stock Bajo
+ {% for p in low_stock_parts %} +
+ {{ p.name }} + {{ p.quantity }} {{ p.unit }} / mín {{ p.min_quantity }} +
+ {% endfor %} +
+ {% endif %} + + {% if upcoming %} +
+
📅 Mantenimientos Próximos
+ {% for s in upcoming %} +
+
+ {{ s.vessel_name }} + {{ s.next_due_date }} +
+
{{ s.task_name }}
+
+ {% endfor %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/email_settings.html b/templates/email_settings.html new file mode 100644 index 0000000..4cdd8a9 --- /dev/null +++ b/templates/email_settings.html @@ -0,0 +1,117 @@ +{% extends 'base.html' %} +{% block title %}Configuración Email{% endblock %} +{% block page_title %}Configuración de Email{% endblock %} +{% block content %} +
+ +
+
⚙️ Servidor SMTP
+

+ Configura el servidor de correo para enviar reportes por email. + Funciona con Gmail, Outlook, Yahoo o cualquier servidor SMTP. +

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ Para Gmail usa una App Password, no tu contraseña normal. +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
🧪 Probar Configuración
+

+ Envía un email de prueba para verificar que la configuración es correcta. +

+
+ + +
+ +
+
+ +
+
📋 Guías Rápidas
+
+
+ Gmail:
+ Host: smtp.gmail.com · Puerto: 587
+ Requiere App Password (2FA activado) +
+
+ Outlook / Hotmail:
+ Host: smtp-mail.outlook.com · Puerto: 587 +
+
+ Yahoo:
+ Host: smtp.mail.yahoo.com · Puerto: 587 +
+
+
+
+ +
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/equipment_form.html b/templates/equipment_form.html new file mode 100644 index 0000000..70062b4 --- /dev/null +++ b/templates/equipment_form.html @@ -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 %} +← Volver +{% endblock %} +{% block content %} +
+
+ Embarcación: {{ vessel.name }} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/inventory.html b/templates/inventory.html new file mode 100644 index 0000000..d55ba9d --- /dev/null +++ b/templates/inventory.html @@ -0,0 +1,140 @@ +{% extends 'base.html' %} +{% block title %}Inventario{% endblock %} +{% block page_title %}Inventario{% endblock %} +{% block topbar_actions %} + + Agregar Item +{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+ + +
+ + +
+
+ + + + {% for p in parts %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
NombreCategoríaN° ParteMarcaStockMín.Precio
+ {{ p.name }} + {% if p.description %}
{{ p.description[:60] }}{% endif %} +
{{ p.category_name or '—' }}{{ p.part_number or '—' }}{{ p.brand or '—' }} + + {{ p.quantity }} {{ p.unit }} + + {{ p.min_quantity }}${{ "%.2f"|format(p.cost_price or 0) }} + ✏️ +
Sin items en inventario.
+
+
+ + +
+{% for p in parts %} +
+
+
+
{{ p.name }}
+ {% if p.part_number %}
{{ p.part_number }}
{% endif %} +
+ {{ p.category_name or '—' }} +
+
+
+ {% if p.brand %}🏷️ {{ p.brand }}{% endif %} + {% if p.location %}📍 {{ p.location }}{% endif %} + 💲{{ "%.2f"|format(p.cost_price or 0) }} +
+
+
+
Stock actual
+
+ {{ p.quantity }} {{ p.unit }} +
+
+
+
Mínimo
+
{{ p.min_quantity }} {{ p.unit }}
+
+ {% if p.quantity <= p.min_quantity %} + ⚠️ Stock bajo + {% endif %} +
+
+ +
+{% else %} +
Sin items en inventario.
+{% endfor %} +
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/ism_index.html b/templates/ism_index.html new file mode 100644 index 0000000..d630539 --- /dev/null +++ b/templates/ism_index.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} +{% block title %}ISM — Procedimientos{% endblock %} +{% block page_title %}ISM — Procedimientos de Trabajo Seguro{% endblock %} +{% block topbar_actions %} + + Nuevo SWP + 📋 MSDS +{% endblock %} +{% block content %} + + +
+ {% if is_superadmin and companies %} + + {% endif %} + +
+ +
+
+ + + + {% if is_superadmin %}{% endif %} + + + + + + {% for s in swps %} + + {% if is_superadmin %} + + {% endif %} + + + + + + + + + + {% else %} + + {% endfor %} + +
CompañíaCódigoTítuloCategoríaVersiónEstado versiónVigente desdeAprobado por
{{ s.company_name or '—' }}{{ s.code }}{{ s.title }} + {% set cat_map = {'electrical':'⚡ Eléctrico','mechanical':'⚙️ Mecánico', + 'chemical':'🧪 Químicos','confined':'🔒 Esp. Confinado', + 'height':'⚓ Altura','welding':'🔥 Soldadura', + 'hull':'🚢 Casco','other':'📋 Otro'} %} + {{ cat_map.get(s.category, s.category) }} + + {% if s.version %} + {{ s.version }} + {% else %}—{% endif %} + + {% if s.ver_status == 'active' %} + ✅ Aprobada + {% elif s.ver_status == 'draft' %} + 📝 Borrador + {% elif s.ver_status == 'superseded' %} + Sup. + {% else %}—{% endif %} + {{ s.effective_date or '—' }}{{ s.approved_by or '—' }} + Ver + {% if s.ver_status == 'draft' and s.ver_id %} + ✏️ + {% endif %} + {% if s.ver_status == 'active' %} + 📄 ES + 📄 EN + {% endif %} +
+ Sin procedimientos. Crear el primero +
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..76dd992 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,153 @@ + + + + + + Marine Maintenance — Login + + + + + + + diff --git a/templates/msds_form.html b/templates/msds_form.html new file mode 100644 index 0000000..3578462 --- /dev/null +++ b/templates/msds_form.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 %} + ← Volver +{% endblock %} +{% block content %} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + {% if msds and msds.pdf_filename %} + + {% endif %} +
+
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/msds_index.html b/templates/msds_index.html new file mode 100644 index 0000000..e72d236 --- /dev/null +++ b/templates/msds_index.html @@ -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 %} + + Nueva MSDS + ← ISM +{% endblock %} +{% block content %} +
+
+ + + + {% for m in msds_list %} + + + + + + + + + + {% else %} + + {% endfor %} + +
ProductoFabricanteClase GHSVersiónRepuesto vinculadoPDF
{{ m.product_name }}{{ m.manufacturer or '—' }} + {% if m.hazard_class %} + {{ m.hazard_class }} + {% else %}—{% endif %} + {{ m.version }}{{ m.part_name or '—' }} + {% if m.pdf_filename %} + 📄 + {% else %}—{% endif %} + + ✏️ +
+ Sin fichas MSDS. Agregar primera +
+
+
+{% endblock %} diff --git a/templates/part_form.html b/templates/part_form.html new file mode 100644 index 0000000..2340a18 --- /dev/null +++ b/templates/part_form.html @@ -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 %}← Volver{% endblock %} +{% block content %} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/purchase_detail.html b/templates/purchase_detail.html new file mode 100644 index 0000000..5dce616 --- /dev/null +++ b/templates/purchase_detail.html @@ -0,0 +1,92 @@ +{% extends 'base.html' %} +{% block title %}Compra #{{ purchase.id }}{% endblock %} +{% block page_title %}Compra — {{ purchase.purchase_date }}{% endblock %} +{% block topbar_actions %}← Volver{% endblock %} +{% block content %} +
+
+
📋 Detalles
+ + + + + +
Proveedor{{ purchase.supplier_name or '—' }}
Factura{{ purchase.invoice_number or '—' }}
Fecha{{ purchase.purchase_date }}
Total${{ "%.2f"|format(purchase.total_amount or 0) }}
+ {% if purchase.notes %}

{{ purchase.notes }}

{% endif %} +
+
+ +
+
+ 📦 Ítems Comprados + +
+
+ + + + {% for i in items %} + + + + + + + + {% else %} + + {% endfor %} + +
RepuestoDescripciónCantidadCosto Unit.Total
{{ i.part_name or '—' }}{{ i.description or '' }}{{ i.quantity }}${{ "%.2f"|format(i.unit_cost) }}${{ "%.2f"|format(i.total_cost) }}
Sin ítems.
+
+
+ + +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/purchase_form.html b/templates/purchase_form.html new file mode 100644 index 0000000..7fda09e --- /dev/null +++ b/templates/purchase_form.html @@ -0,0 +1,40 @@ +{% extends 'base.html' %} +{% block title %}Nueva Compra{% endblock %} +{% block page_title %}Nueva Compra{% endblock %} +{% block topbar_actions %}← Volver{% endblock %} +{% block content %} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancelar +
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/purchases.html b/templates/purchases.html new file mode 100644 index 0000000..d77d3f3 --- /dev/null +++ b/templates/purchases.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% block title %}Compras{% endblock %} +{% block page_title %}Compras de Materiales{% endblock %} +{% block topbar_actions %}+ Nueva Compra{% endblock %} +{% block content %} +
+
+ + + + {% for p in purchases %} + + + + + + + + {% else %} + + {% endfor %} + +
FechaProveedorFacturaTotal
{{ p.purchase_date }}{{ p.supplier_name or 'Sin proveedor' }}{{ p.invoice_number or '—' }}${{ "%.2f"|format(p.total_amount or 0) }}Ver
Sin compras registradas.
+
+
+{% endblock %} diff --git a/templates/supplier_form.html b/templates/supplier_form.html new file mode 100644 index 0000000..82da628 --- /dev/null +++ b/templates/supplier_form.html @@ -0,0 +1,40 @@ +{% extends 'base.html' %} +{% block title %}Nuevo Proveedor{% endblock %} +{% block page_title %}Nuevo Proveedor{% endblock %} +{% block topbar_actions %}← Volver{% endblock %} +{% block content %} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/suppliers.html b/templates/suppliers.html new file mode 100644 index 0000000..6ceba23 --- /dev/null +++ b/templates/suppliers.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} +{% block title %}Proveedores{% endblock %} +{% block page_title %}Proveedores{% endblock %} +{% block topbar_actions %} + + Nuevo Proveedor +{% endblock %} +{% block content %} +
+
+ + + + + + {% for s in suppliers %} + + + + + + + + + {% else %} + + {% endfor %} + +
NombreContactoTeléfonoEmailDirección
{{ s.name }}{{ s.contact_name or '—' }}{{ s.phone or '—' }}{{ s.email or '—' }}{{ s.address or '—' }} + ✏️ + +
Sin proveedores.
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/swp_detail.html b/templates/swp_detail.html new file mode 100644 index 0000000..84c5383 --- /dev/null +++ b/templates/swp_detail.html @@ -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 %} + 📄 PDF ES + 📄 PDF EN + {% if current and current.status == 'draft' %} + ✏️ Editar Borrador + {% else %} + ✏️ Editar + 📝 Nueva Versión + {% endif %} + ← Volver +{% endblock %} +{% block content %} + +{% if current and current.status == 'draft' %} +
+
+ 📝 Versión {{ current.version }} pendiente de aprobación + Creada por {{ current.created_by }} · {{ current.created_at[:10] }} +
+
+ ✏️ Editar + +
+
+{% 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 '[]') %} + + +
+
+
Información
+ + + + + + + +
Código{{ swp.code }}
Versión activa{{ current.version }}
Categoría{{ categories.get(swp.category, swp.category) }}
Vigente desde{{ current.effective_date or '—' }}
Aprobado por{{ current.approved_by or '—' }}
Creado por{{ current.created_by or '—' }}
+
+
+
Propósito y Alcance
+ {% if current.purpose %}

{{ current.purpose }}

{% endif %} + {% if current.scope %}

{{ current.scope }}

{% endif %} +
+
+ + +
+
+
⚠️ Riesgos Identificados
+ {% for h in hazards %} +
+ {{ h }} +
+ {% else %}

{% endfor %} +
+
+
🦺 EPP Requerido
+ {% for p in ppe %} +
+ {{ p }} +
+ {% else %}

{% endfor %} +
+
+ + +{% if tools %} +
+
🔧 Herramientas y Materiales
+
+ {% for t in tools %} + {{ t }} + {% endfor %} +
+
+{% endif %} + + +
+
📋 Pasos del Procedimiento
+ {% for step in steps %} +
+ {{ loop.index }}. + {{ step }} +
+ {% else %}

Sin pasos definidos.

{% endfor %} +
+ + +{% if current.emergency %} +
+
🚨 Medidas de Emergencia
+

{{ current.emergency }}

+
+{% endif %} + + +{% if refs %} +
+
📚 Referencias y Normativa
+
+ {% for r in refs %} + {{ r }} + {% endfor %} +
+
+{% endif %} + +{% else %} +
+ Sin versión activa. Crear primera versión +
+{% endif %} + + +
+
📚 Historial de Versiones
+
+ + + + {% for v in versions %} + + + + + + + + + + + {% endfor %} + +
VersiónEstadoMotivoDiferenciasCreado porAprobado porVigente desde
{{ v.version }} + {% if v.status == 'active' %}Activa + {% elif v.status == 'draft' %}Borrador + {% elif v.status == 'superseded' %}Supersedida + {% else %}Archivada{% endif %} + {{ v.change_reason or '—' }}{{ v.diff_summary or '—' }}{{ v.created_by or '—' }}{{ v.approved_by or '—' }}{{ v.effective_date or '—' }} + {% if v.status == 'draft' %} +
+ ✏️ + +
+ {% endif %} +
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/swp_edit.html b/templates/swp_edit.html new file mode 100644 index 0000000..70f0352 --- /dev/null +++ b/templates/swp_edit.html @@ -0,0 +1,50 @@ +{% extends 'base.html' %} +{% block title %}Editar — {{ swp.code }}{% endblock %} +{% block page_title %}Editar datos — {{ swp.code }}{% endblock %} +{% block topbar_actions %} + ← Cancelar +{% endblock %} +{% block content %} +
+
+ ℹ️ Edita código, título, categoría y compañía. Para cambiar el contenido (pasos, riesgos, EPP) usa + ✏️ Editar Borrador o + 📝 Nueva Versión desde el detalle del procedimiento. +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/swp_form.html b/templates/swp_form.html new file mode 100644 index 0000000..accaa8f --- /dev/null +++ b/templates/swp_form.html @@ -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 %} + ← Volver +{% endblock %} +{% block content %} +
+
+ {% if is_superadmin and companies %} +
+ + +
+ {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if not swp %} +
+
📝 Se creará como Borrador (v1.0)
+
Puedes editarlo libremente mientras sea borrador. Una vez aprobado por el admin, quedará activo y solo se podrá modificar creando una nueva versión.
+
+ {% endif %} +
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/swp_version_edit.html b/templates/swp_version_edit.html new file mode 100644 index 0000000..168cccc --- /dev/null +++ b/templates/swp_version_edit.html @@ -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 %} + ← Cancelar +{% endblock %} +{% block content %} + +{% if version.status == 'active' %} +
+
+ ⚠️ Esta versión ya está aprobada y activa. Los cambios se guardarán pero considera si es mejor crear una Nueva Versión para mantener el historial. +
+
+{% endif %} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/swp_version_form.html b/templates/swp_version_form.html new file mode 100644 index 0000000..03e8caf --- /dev/null +++ b/templates/swp_version_form.html @@ -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 %} + ← Cancelar +{% endblock %} +{% block content %} +
+
+ ⚠️ Estás creando la versión {{ new_version }} de {{ swp.code }}. + La versión actual quedará como "Supersedida" cuando apruebes esta nueva versión. +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ℹ️ Esta versión quedará en estado Borrador hasta que el admin la apruebe desde el detalle del procedimiento. +
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/system_form.html b/templates/system_form.html new file mode 100644 index 0000000..7298804 --- /dev/null +++ b/templates/system_form.html @@ -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 %}← Volver{% endblock %} +{% block content %} +
+ {% if system and system.is_default %} +
Los sistemas predefinidos no se pueden editar.
+ {% else %} +
+
+ + +
+
+ + +
+
+ + Cancelar +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/systems.html b/templates/systems.html new file mode 100644 index 0000000..50176ec --- /dev/null +++ b/templates/systems.html @@ -0,0 +1,45 @@ +{% extends 'base.html' %} +{% block title %}Sistemas{% endblock %} +{% block page_title %}Sistemas{% endblock %} +{% block topbar_actions %} + + Nuevo Sistema +{% endblock %} +{% block content %} +
+
+ + + + {% for s in systems %} + + + + + + + {% endfor %} + +
SistemaDescripciónTipo
{{ s.name }}{{ s.description or '—' }} + {% if s.is_default %} + Predefinido + {% else %} + Personalizado + {% endif %} + + {% if not s.is_default %} + ✏️ + + {% endif %} +
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/user_form.html b/templates/user_form.html new file mode 100644 index 0000000..bc81587 --- /dev/null +++ b/templates/user_form.html @@ -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 %}← Volver{% endblock %} +{% block content %} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if current_user.role == 'superadmin' %} +
+ + +
+ {% endif %} + {% if user %} +
+ + +
+ {% endif %} +
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..00d4300 --- /dev/null +++ b/templates/users.html @@ -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' %} + + Nuevo Usuario + {% endif %} +{% endblock %} +{% block content %} +
+
+ + + + + + {% for u in users %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
UsuarioNombreEmailCompañíaRolEstadoÚltimo Login
{{ u.username }}{{ u.full_name or '—' }}{{ u.email }}{{ u.company_name or 'Todas'|safe }} + {% if u.role == 'superadmin' %} + Super Admin + {% elif u.role == 'admin' %} + Admin + {% else %} + Técnico + {% endif %} + + {% if u.is_active %} + Activo + {% else %} + Inactivo + {% endif %} + {{ u.last_login[:16] if u.last_login else '—' }} + {% if current_user.role == 'superadmin' or (current_user.role == 'admin' and u.role == 'technician') %} + ✏️ + {% endif %} +
Sin usuarios.
+
+
+{% endblock %} diff --git a/templates/vessel_detail.html b/templates/vessel_detail.html new file mode 100644 index 0000000..16b1d25 --- /dev/null +++ b/templates/vessel_detail.html @@ -0,0 +1,71 @@ +{% extends 'base.html' %} +{% block title %}{{ vessel.name }}{% endblock %} +{% block page_title %}{{ vessel.name }}{% endblock %} +{% block topbar_actions %} + + Nueva Orden + ✏️ Editar + ← Volver +{% endblock %} +{% block content %} +
+
+
📋 Información
+ + + + + + + + +
Matrícula{{ vessel.registration or '—' }}
Tipo{{ vessel.vessel_type or '—' }}
Marca / Modelo{{ vessel.make or '' }} {{ vessel.model or '' }}
Año{{ vessel.year or '—' }}
Eslora{{ vessel.length_ft or '—' }} ft
Motor{{ vessel.engine_type or '—' }}
Horas Motor{{ vessel.engine_hours or 0 }} h
+
+
+
👤 Propietario
+ + + + +
Nombre{{ vessel.owner_name or '—' }}
Teléfono{{ vessel.owner_phone or '—' }}
Email{{ vessel.owner_email or '—' }}
+ {% if vessel.captain_name or vessel.captain_phone %} +
+
⚓ Capitán
+ + + + +
Nombre{{ vessel.captain_name or '—' }}
Teléfono{{ vessel.captain_phone or '—' }}
Email{{ vessel.captain_email or '—' }}
+
+ {% endif %} + {% if vessel.notes %} +
+ {{ vessel.notes }} +
+ {% endif %} +
+
+ +
+
🔧 Historial de Órdenes de Trabajo
+
+ + + + {% for o in orders %} + + + + + + + + + + {% else %} + + {% endfor %} + +
OrdenTipoDescripciónTécnicoFechaEstado
{{ o.order_number }}{{ o.work_type or '—' }}{{ o.description[:60] }}{% if o.description|length > 60 %}...{% endif %}{{ o.technician or '—' }}{{ o.start_date or o.created_at[:10] }}{{ o.status.replace('_',' ') }}Ver
Sin órdenes de trabajo.
+
+
+{% endblock %} diff --git a/templates/vessel_form.html b/templates/vessel_form.html new file mode 100644 index 0000000..413eec2 --- /dev/null +++ b/templates/vessel_form.html @@ -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 %} + ← Volver +{% endblock %} +{% block content %} +
+
+ + {% if current_user.role in ('superadmin','admin') and companies %} +
Compañía
+
+
+ + +
+
+ {% endif %} + +
Datos de la Embarcación
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
Propietario
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
⚓ Capitán / Contacto Operativo
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/vessel_history.html b/templates/vessel_history.html new file mode 100644 index 0000000..a5b86a7 --- /dev/null +++ b/templates/vessel_history.html @@ -0,0 +1,333 @@ +{% extends 'base.html' %} +{% block title %}Historial — {{ vessel.name }}{% endblock %} +{% block page_title %}{{ vessel.name }}{% endblock %} +{% block topbar_actions %} + + Nueva Orden + ✏️ Editar + ← Volver +{% endblock %} +{% block content %} + + +
+
+
🚢 Tipo
+
{{ vessel.vessel_type or '—' }}
+
{{ vessel.make or '' }} {{ vessel.model or '' }} {{ vessel.year or '' }}
+
+
+
⚙️ Horas Motor
+
{{ vessel.engine_hours or 0 }}
+
horas acumuladas
+
+
+
📋 Total WOs
+
{{ orders|length }}
+
órdenes registradas
+
+
+
💰 Costo Total
+
${{ "%.0f"|format(total_cost) }}
+
histórico acumulado
+
+
+ + +
+
+
👤 Propietario
+
{{ vessel.owner_name or '—' }}
+
{{ vessel.owner_phone or '' }}{% if vessel.owner_email %} · {{ vessel.owner_email }}{% endif %}
+
+
+
⚓ Capitán
+
{{ vessel.captain_name or '—' }}
+
{{ vessel.captain_phone or '' }}{% if vessel.captain_email %} · {{ vessel.captain_email }}{% endif %}
+
+
+ + +
+
+ 📋 Órdenes de Trabajo + + Nueva Orden +
+ {% if orders %} + +
+ + +
+ + +
+ + + + + + {% for o in orders %} + + + + + + + + + + + + {% endfor %} + +
OrdenFechaSistemaScopeTécnicoHorasCostoEstado
{{ o.order_number }}{{ o.start_date or o.created_at[:10] }}{{ o.system_name or '—' }} +
{{ o.scope or '—' }}
+ {% if o.description and o.scope and o.description != o.scope %} +
{{ o.description[:80] }}{% if o.description|length > 80 %}...{% endif %}
+ {% endif %} +
{{ o.technician or '—' }}{{ o.labor_hours or 0 }} h + ${{ "%.0f"|format((o.calc_labor_cost or 0) + (o.total_parts_cost or 0)) }} + {{ o.status.replace('_',' ') }} + Ver + {% if o.status != 'completed' %} + ✏️ + {% endif %} + 📄 +
+
+ + +
+ {% for o in orders %} +
+
+ {{ o.order_number }} + {{ o.status.replace('_',' ') }} +
+
+
{{ o.scope or '—' }}
+
+ {% if o.system_name %}🔩 {{ o.system_name }}{% endif %} + {% if o.technician %}👤 {{ o.technician }}{% endif %} + 📅 {{ o.start_date or o.created_at[:10] }} +
+
+ {{ o.labor_hours or 0 }} h + ${{ "%.0f"|format((o.calc_labor_cost or 0) + (o.total_parts_cost or 0)) }} +
+
+
+ Ver detalle + {% if o.status != 'completed' %} + ✏️ + {% endif %} + 📄 +
+
+ {% endfor %} +
+ + + {% else %} +
+
📋
+
Sin órdenes de trabajo para esta embarcación.
+ + Crear Primera Orden +
+ {% endif %} +
+ + +
+
+ ⚙️ Equipos Registrados + + Agregar Equipo +
+ {% if equipment %} +
+ {% for e in equipment %} +
+
{{ e.name }}
+
+ {{ e.make or '' }} {{ e.model or '' }} + {% if e.position %}· {{ e.position }}{% endif %} +
+ {% if e.serial_number %} +
+ S/N: {{ e.serial_number }} +
+ {% endif %} +
{{ e.engine_hours or 0 }} h
+ ✏️ +
+ {% endfor %} +
+ {% else %} +

Sin equipos. Agrega motores, generadores, etc.

+ {% endif %} +
+ + +
+
+ 📁 Documentos y Manuales + +
+ {% if documents %} +
+ + + + {% for d in documents %} + + + + + + + + + {% endfor %} + +
TítuloTipoEquipoTamañoFecha
+ + {% 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 %} + + {{ d.title }} + {% if d.description %}
{{ d.description }}{% endif %} +
{{ d.doc_type }}{{ d.equipment_name or '—' }}{% if d.file_size %}{{ "%.1f"|format(d.file_size/1024) }} KB{% else %}—{% endif %}{{ d.created_at[:10] }} + ⬇️ + +
+
+ {% else %} +

Sin documentos adjuntos.

+ {% endif %} +
+ + + + +{% endblock %} +{% block head %} + +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/vessels.html b/templates/vessels.html new file mode 100644 index 0000000..5dd7098 --- /dev/null +++ b/templates/vessels.html @@ -0,0 +1,119 @@ +{% extends 'base.html' %} +{% block title %}Embarcaciones{% endblock %} +{% block page_title %}Embarcaciones{% endblock %} +{% block topbar_actions %} + + Nueva Embarcación +{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+ +
+ + +
+
+ + + + {% for v in vessels %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
NombreTipoMarca/ModeloAñoPropietarioCapitánWOs
{{ v.name }}{{ v.vessel_type or '—' }}{{ v.make or '' }} {{ v.model or '' }}{{ v.year or '—' }}{{ v.owner_name or '—' }}{{ v.captain_name or '—' }}{{ v.wo_count or 0 }} + Historial + ✏️ +
Sin embarcaciones.
+
+
+ + +
+{% for v in vessels %} +
+
+
+
🚢 {{ v.name }}
+
{{ v.vessel_type or '—' }} {% if v.year %}· {{ v.year }}{% endif %}
+
+ + {{ v.wo_count or 0 }} WOs + +
+
+
+ {% if v.make or v.model %}Marca/Modelo:{{ v.make or '' }} {{ v.model or '' }}{% endif %} +
+
+ {% if v.owner_name %}Propietario:{{ v.owner_name }}{% endif %} + {% if v.captain_name %}Capitán:{{ v.captain_name }}{% endif %} +
+ {% if v.engine_hours %}
⚙️ {{ v.engine_hours }} h motor
{% endif %} +
+ +
+{% else %} +
Sin embarcaciones registradas.
+{% endfor %} +
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/work_order_detail.html b/templates/work_order_detail.html new file mode 100644 index 0000000..c198f6f --- /dev/null +++ b/templates/work_order_detail.html @@ -0,0 +1,999 @@ +{% extends 'base.html' %} +{% block title %}{{ order.order_number }}{% endblock %} +{% block page_title %}{{ order.order_number }}{% endblock %} +{% block topbar_actions %} +
+ {% if order.status != 'completed' %} + ✏️ Editar + + + + {% else %} + + {% endif %} + + 📄 ES + 📄 EN + + ← Volver +
+{% endblock %} +{% block content %} + + + + +
+ {% if order.status != 'completed' %} + + + + ✏️Editar + + {% else %} + + {% endif %} + + + 📄PDF + + + Volver + +
+
+
+
📋 Detalles
+ + + + + + + + + + + {% if order.assigned_to %} + + + + + {% endif %} + + + + +
Embarcación{{ order.vessel_name }}
Sistema{{ order.system_name or '—' }}
Scope{{ order.scope or '—' }}
Tipo{{ order.work_type or '—' }}
Facturación + {% if order.billing_type == 'lump_sum' %} + 💰 A todo costo + {% elif order.billing_type == 'labor_only' %} + 🔧 Solo M.O. + {% else %} + 📋 M.O. + Materiales + {% endif %} +
Técnico{{ order.technician or '—' }}
Asignado a + 📨 {{ order.assigned_to }} + {% if order.assigned_by %}
por {{ order.assigned_by }}{% endif %} +
Fecha inicio{{ order.start_date or '—' }}
Fecha fin{{ order.end_date or '—' }}
H. Motor inicio{{ order.engine_hours_start or '—' }}
Estado{{ order.status.replace('_',' ') }}
+
+
Descripción
+ {{ order.description }} +
+
+ +
+
💰 Costos
+ + + + + + +
Horas Trabajadas{{ order.labor_hours or 0 }} h
Tarifa M.O.${{ order.labor_rate or 0 }}/h
Costo M.O.${{ "%.2f"|format(order.total_labor_cost or 0) }}
Costo Repuestos${{ "%.2f"|format(order.total_parts_cost or 0) }}
TOTAL${{ "%.2f"|format((order.total_labor_cost or 0) + (order.total_parts_cost or 0)) }}
+
+ + + + + + +
+
+
+ + +
+
+ ⚙️ Equipos Trabajados + {% if order.status != 'completed' %} + + {% endif %} +
+ {% if wo_equipment %} +
+ + {% if order.status != 'completed' %}{% endif %} + + {% for e in wo_equipment %} + + + + + + + + {% if order.status != 'completed' %} + + {% endif %} + + {% endfor %} + + + + + + + {% if order.status != 'completed' %}{% endif %} + + +
EquipoS/NTrabajo RealizadoNotasHorasCosto M.O.
{{ e.equip_name or '—' }}
{{ e.make or '' }} {{ e.model or '' }}
{{ e.serial_number or '—' }}{{ e.description or '—' }}{{ e.notes or '—' }}{{ e.labor_hours or 0 }} h${{ "%.2f"|format((e.labor_hours or 0) * (e.labor_rate or 0)) }} + + +
TOTAL MANO DE OBRA{{ wo_equipment|sum(attribute='labor_hours') or 0 }} h${{ "%.2f"|format(wo_equipment|sum(attribute='labor_cost') or 0) }}
+
+ {% else %} +

Sin equipos agregados.

+ {% endif %} +
+ + + + + +
+
⚠️ Causa Técnica y Reparaciones
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ 📸 Evidencia Fotográfica + {% if order.status != 'cancelled' %} + + {% endif %} +
+ {% set before_photos = photos|selectattr('photo_type','eq','before')|list %} + {% set after_photos = photos|selectattr('photo_type','eq','after')|list %} + + {% if before_photos %} +
+
⬅ Antes
+
+ {% for p in before_photos %} +
+ +
+ Antes + +
+ +
+ {% endfor %} +
+
+ {% endif %} + + {% if after_photos %} +
+
➡ Después
+
+ {% for p in after_photos %} +
+ +
+ Después + +
+ +
+ {% endfor %} +
+
+ {% endif %} + + {% if not photos %} +

Sin fotos aún. Agrega evidencia fotográfica del trabajo.

+ {% endif %} +
+ + +
+
+ 🔩 Repuestos Utilizados + {% if order.status != 'cancelled' %} + + {% endif %} +
+ {% if parts_used %} +
+ + + + {% for p in parts_used %} + + + + + + + + {% endfor %} + +
RepuestoDescripciónCantidadPrecio Unit.Total
{{ p.part_name or '—' }}{% if p.part_number %} ({{ p.part_number }}){% endif %}{{ p.description or '' }}{{ p.quantity }}${{ "%.2f"|format(p.unit_cost) }}${{ "%.2f"|format(p.total_cost) }}
+
+ {% else %} +

Sin repuestos registrados.

+ {% endif %} +
+ + + + + + + + +{% if email_log %} +
+
📧 Historial de Envíos
+
+ + + + {% for e in email_log %} + + + + + + + + + {% endfor %} + +
FechaDestinatarioIdiomaEstadoEnviado porPDF
{{ e.sent_at[:16] }} +
{{ e.to_name or '—' }}
+
{{ e.to_email }}
+
{{ e.lang.upper() }} + {% if e.status == 'sent' %} + ✅ Enviado + {% else %} + ❌ Fallido + {% if e.error_msg %} +
+ {{ e.error_msg[:80] }}{% if e.error_msg|length > 80 %}...{% endif %} +
+ {% endif %} + {% endif %} +
{{ e.sent_by or '—' }} + {% if e.pdf_filename %} + 📄 + {% else %}—{% endif %} +
+
+
+{% endif %} + + +
+
✍️ Firmas
+
+ + +
+
+ Técnico: {{ order.technician or '—' }} +
+ {% if order.signature_tech %} + + + {% else %} + +
+ + +
+ {% endif %} +
+ + +
+
+ Capitán / Cliente +
+ {% if order.signature_client %} + + + {% else %} + +
+ + +
+ {% endif %} +
+ +
+
+ + + + + + + +{% endblock %} +{% block scripts %} + + + + +{% endblock %} diff --git a/templates/work_order_edit.html b/templates/work_order_edit.html new file mode 100644 index 0000000..86c0aeb --- /dev/null +++ b/templates/work_order_edit.html @@ -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 %} + ← Cancelar +{% endblock %} +{% block content %} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancelar +
+
+
+{% endblock %} diff --git a/templates/work_order_form.html b/templates/work_order_form.html new file mode 100644 index 0000000..1a41a0e --- /dev/null +++ b/templates/work_order_form.html @@ -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 %} + ← Volver +{% endblock %} +{% block content %} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Cancelar +
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/work_orders.html b/templates/work_orders.html new file mode 100644 index 0000000..b9c599d --- /dev/null +++ b/templates/work_orders.html @@ -0,0 +1,185 @@ +{% extends 'base.html' %} +{% block title %}Órdenes de Trabajo{% endblock %} +{% block page_title %}Órdenes de Trabajo{% endblock %} +{% block topbar_actions %} + + Nueva Orden +{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} + + + + +
+
+ + + + + + {% for o in orders %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
Orden🚢 EmbarcaciónTipoScopeTécnicoFechaEstado
{{ o.order_number }}{{ o.vessel_name }}{{ o.work_type or '—' }}{{ o.scope or (o.description[:60] if o.description else '—') }}{{ o.technician or '—' }}{{ o.start_date or o.created_at[:10] }}{{ o.status.replace('_',' ') }} + Ver + {% if o.status != 'completed' %} + ✏️ + + {% endif %} +
No hay órdenes.
+
+ +
+ + +
+{% for o in orders %} +
+
+ {{ o.order_number }} + {{ o.status.replace('_',' ') }} +
+
+ +
{{ o.scope or (o.description[:100] if o.description else 'Sin descripción') }}
+
+ {% if o.work_type %}🔧 {{ o.work_type }}{% endif %} + {% if o.technician %}👤 {{ o.technician }}{% endif %} + 📅 {{ o.start_date or o.created_at[:10] }} + {% if o.billing_type == 'lump_sum' %}💰 Todo costo + {% elif o.billing_type == 'labor_only' %}🔧 Solo M.O. + {% else %}📋 M.O.+Mat.{% endif %} +
+
+
+ Ver detalle + {% if o.status != 'completed' %} + ✏️ Editar + + {% endif %} +
+
+{% else %} +
No hay órdenes.
+{% endfor %} + +
+ +{% endblock %} +{% block scripts %} + +{% endblock %}