Initial commit: Fleet Management app with security hardening and background launcher

- Flask app with SQLAlchemy, Flask-Login, Flask-Mail
- Admin/owner roles, vessel management, charters, work orders
- Background launcher (Iniciar.vbs) runs server without terminal window
- Root redirect fixed: / → /login
- debug=False, use_reloader=False for pythonw.exe compatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 02:54:10 -04:00
parent 0b976a1ec0
commit 5b7b41aa50
23 changed files with 6375 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
*.egg-info/
.eggs/
dist/
build/
# Virtual environment
venv/
env/
.venv/
# Database
instance/
*.db
*.sqlite
*.sqlite3
# Logs
logs/
# Environment variables
.env
.env.*
# IDE / tools
.vscode/
.idea/
.claude/
*.code-workspace
# OS
Thumbs.db
Desktop.ini
.DS_Store
# Uploads / generated files
static/uploads/
static/pdfs/
static/logos/
# Misc
*.zip
*.bak
+7
View File
@@ -8,6 +8,13 @@ sLog = sDir & "\logs\server.log"
Set oShell = CreateObject("WScript.Shell") Set oShell = CreateObject("WScript.Shell")
' Crear carpeta de logs si no existe
Dim oFS
Set oFS = CreateObject("Scripting.FileSystemObject")
If Not oFS.FolderExists(sDir & "\logs") Then
oFS.CreateFolder(sDir & "\logs")
End If
Dim oExec, sOut Dim oExec, sOut
Set oExec = oShell.Exec("cmd /c netstat -aon | findstr :5010 | findstr LISTENING") Set oExec = oShell.Exec("cmd /c netstat -aon | findstr :5010 | findstr LISTENING")
sOut = oExec.StdOut.ReadAll() sOut = oExec.StdOut.ReadAll()
+49
View File
@@ -0,0 +1,49 @@
from app import create_app, db
from app.models import db
import sqlalchemy as sa
app = create_app()
with app.app_context():
# Verificar si la tabla vessel_systems existe
inspector = sa.inspect(db.engine)
if not inspector.has_table('vessel_systems'):
# Crear tabla de sistemas de embarcación
db.session.execute('''
CREATE TABLE vessel_systems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vessel_id INTEGER NOT NULL,
system_type TEXT NOT NULL,
component_name TEXT NOT NULL,
manufacturer TEXT,
model TEXT,
serial_number TEXT,
installation_date DATE,
last_maintenance_date DATE,
status TEXT DEFAULT 'active',
notes TEXT,
FOREIGN KEY (vessel_id) REFERENCES vessels(id)
)
''')
print("Tabla vessel_systems creada")
if not inspector.has_table('maintenance_logs'):
# Crear tabla de registros de mantenimiento
db.session.execute('''
CREATE TABLE maintenance_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
system_id INTEGER NOT NULL,
work_order_id INTEGER,
maintenance_date DATE NOT NULL,
maintenance_type TEXT NOT NULL,
description TEXT,
cost REAL,
technician TEXT,
next_maintenance_date DATE,
hours_accumulated INTEGER,
FOREIGN KEY (system_id) REFERENCES vessel_systems(id),
FOREIGN KEY (work_order_id) REFERENCES work_orders(id)
)
''')
print("Tabla maintenance_logs creada")
print("Estructura de historial por sistemas agregada")
+142
View File
@@ -0,0 +1,142 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_mail import Mail
from datetime import datetime
import os
db = SQLAlchemy()
login_manager = LoginManager()
mail = Mail()
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'tu-clave-secreta-cambia-esto')
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///fleet.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 587))
app.config['MAIL_USE_TLS'] = True
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME', '')
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD', '')
app.config['MAIL_DEFAULT_SENDER'] = os.environ.get('MAIL_DEFAULT_SENDER', os.environ.get('MAIL_USERNAME', ''))
db.init_app(app)
login_manager.init_app(app)
mail.init_app(app)
login_manager.login_view = 'auth.login'
# Registrar rutas
from app.routes import auth, admin, owner, api
app.register_blueprint(auth.bp)
app.register_blueprint(admin.bp)
app.register_blueprint(owner.bp)
app.register_blueprint(api.bp)
from flask import redirect, url_for
@app.route('/')
def index():
return redirect(url_for('auth.login'))
# Crear tablas y migrar columnas nuevas
with app.app_context():
db.create_all()
_run_migrations(db)
return app
def _run_migrations(db):
"""Agrega columnas nuevas a tablas existentes (SQLite safe)."""
from sqlalchemy import text, inspect
inspector = inspect(db.engine)
existing_tables = inspector.get_table_names()
with db.engine.connect() as conn:
# work_orders: priority, notified_at
if 'work_orders' in existing_tables:
cols = [c['name'] for c in inspector.get_columns('work_orders')]
if 'priority' not in cols:
conn.execute(text("ALTER TABLE work_orders ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'"))
conn.commit()
if 'notified_at' not in cols:
conn.execute(text("ALTER TABLE work_orders ADD COLUMN notified_at DATETIME"))
conn.commit()
if 'approved_at' not in cols:
conn.execute(text("ALTER TABLE work_orders ADD COLUMN approved_at DATETIME"))
conn.commit()
if 'approved_by_name' not in cols:
conn.execute(text("ALTER TABLE work_orders ADD COLUMN approved_by_name VARCHAR(100)"))
conn.commit()
if 'rejected_at' not in cols:
conn.execute(text("ALTER TABLE work_orders ADD COLUMN rejected_at DATETIME"))
conn.commit()
if 'rejection_reason' not in cols:
conn.execute(text("ALTER TABLE work_orders ADD COLUMN rejection_reason VARCHAR(300)"))
conn.commit()
if 'invoice_number' not in cols:
conn.execute(text("ALTER TABLE work_orders ADD COLUMN invoice_number VARCHAR(60)"))
conn.commit()
# charters: insurance + captain fields
if 'charters' in existing_tables:
cols = [c['name'] for c in inspector.get_columns('charters')]
for col, ddl in [
('insurance_rider_number', "ALTER TABLE charters ADD COLUMN insurance_rider_number VARCHAR(60)"),
('insurer_name', "ALTER TABLE charters ADD COLUMN insurer_name VARCHAR(100)"),
('coverage_amount', "ALTER TABLE charters ADD COLUMN coverage_amount FLOAT"),
('damage_waiver', "ALTER TABLE charters ADD COLUMN damage_waiver FLOAT DEFAULT 0"),
('captain_id', "ALTER TABLE charters ADD COLUMN captain_id INTEGER REFERENCES captains(id)"),
]:
if col not in cols:
conn.execute(text(ddl))
conn.commit()
# captains: license_type
if 'captains' in existing_tables:
cols = [c['name'] for c in inspector.get_columns('captains')]
if 'license_type' not in cols:
conn.execute(text("ALTER TABLE captains ADD COLUMN license_type VARCHAR(20) DEFAULT 'private'"))
conn.commit()
# accounting_entries, fuel_entries, documents → ya los crea db.create_all()
# vessels: management_company_id, max_passengers
if 'vessels' in existing_tables:
cols = [c['name'] for c in inspector.get_columns('vessels')]
if 'management_company_id' not in cols:
conn.execute(text("ALTER TABLE vessels ADD COLUMN management_company_id INTEGER REFERENCES companies(id)"))
conn.commit()
if 'max_passengers' not in cols:
conn.execute(text("ALTER TABLE vessels ADD COLUMN max_passengers INTEGER DEFAULT 12"))
conn.commit()
# users: is_super_admin
if 'users' in existing_tables:
cols = [c['name'] for c in inspector.get_columns('users')]
if 'is_super_admin' not in cols:
conn.execute(text("ALTER TABLE users ADD COLUMN is_super_admin BOOLEAN DEFAULT 0"))
conn.commit()
# Backfill management_company_id for existing vessels
with db.engine.connect() as conn:
from sqlalchemy import text
mgmt = conn.execute(text("SELECT id FROM companies WHERE type='management' LIMIT 1")).fetchone()
if mgmt:
conn.execute(text(f"UPDATE vessels SET management_company_id = {mgmt[0]} WHERE management_company_id IS NULL"))
conn.commit()
# Mark first admin as super_admin if none exists
with db.engine.connect() as conn:
from sqlalchemy import text
sup = conn.execute(text("SELECT id FROM users WHERE is_super_admin=1 LIMIT 1")).fetchone()
if not sup:
first_admin = conn.execute(text("SELECT id FROM users WHERE role='admin' LIMIT 1")).fetchone()
if first_admin:
conn.execute(text(f"UPDATE users SET is_super_admin=1 WHERE id={first_admin[0]}"))
conn.commit()
@login_manager.user_loader
def load_user(user_id):
from app.models import User
return User.query.get(int(user_id))
+191
View File
@@ -0,0 +1,191 @@
from flask_login import UserMixin
from app import db
from datetime import datetime
class Company(db.Model):
__tablename__ = 'companies'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
type = db.Column(db.String(20), nullable=False) # 'management', 'owner'
email = db.Column(db.String(100))
phone = db.Column(db.String(20))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
email = db.Column(db.String(100), unique=True, nullable=False)
password_hash = db.Column(db.String(200), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'))
role = db.Column(db.String(20), default='admin') # 'admin', 'captain'
is_active = db.Column(db.Boolean, default=True)
is_super_admin = db.Column(db.Boolean, default=False)
company = db.relationship('Company', backref='users')
class Vessel(db.Model):
__tablename__ = 'vessels'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
hin = db.Column(db.String(50))
make = db.Column(db.String(50))
model = db.Column(db.String(50))
length = db.Column(db.Float)
engines = db.Column(db.String(200))
fuel_consumption_14knots = db.Column(db.Float) # L/h
owner_company_id = db.Column(db.Integer, db.ForeignKey('companies.id'))
management_company_id = db.Column(db.Integer, db.ForeignKey('companies.id'))
management_company = db.relationship('Company', foreign_keys=[management_company_id], backref='managed_vessels')
plan_id = db.Column(db.Integer)
charter_percentage = db.Column(db.Float, default=25.0) # % para management
base_rate_4h = db.Column(db.Float)
hourly_rate_extra = db.Column(db.Float)
max_passengers = db.Column(db.Integer, default=12)
is_active = db.Column(db.Boolean, default=True)
class Captain(db.Model):
__tablename__ = 'captains'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
phone = db.Column(db.String(20))
license_number = db.Column(db.String(50))
hourly_rate = db.Column(db.Float)
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'))
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
license_type = db.Column(db.String(20), default='private') # 'private' | 'six_pack' | 'master'
class Route(db.Model):
__tablename__ = 'routes'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
distance_nm = db.Column(db.Float)
time_hours_14knots = db.Column(db.Float)
points_of_interest = db.Column(db.Text)
deviation_charge_usd = db.Column(db.Float, default=50)
class Charter(db.Model):
__tablename__ = 'charters'
id = db.Column(db.Integer, primary_key=True)
vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id'))
route_id = db.Column(db.Integer, db.ForeignKey('routes.id'))
charterer_name = db.Column(db.String(100))
charterer_phone = db.Column(db.String(20))
charterer_email = db.Column(db.String(100))
start_datetime = db.Column(db.DateTime)
hours = db.Column(db.Float)
total_base_rate = db.Column(db.Float)
management_percentage = db.Column(db.Float)
management_earnings = db.Column(db.Float)
owner_earnings = db.Column(db.Float)
status = db.Column(db.String(20), default='draft') # draft, signed, completed, paid
contract_pdf = db.Column(db.String(200))
completed_at = db.Column(db.DateTime)
insurance_rider_number = db.Column(db.String(60))
insurer_name = db.Column(db.String(100))
coverage_amount = db.Column(db.Float)
damage_waiver = db.Column(db.Float, default=0)
captain_id = db.Column(db.Integer, db.ForeignKey('captains.id'))
captain = db.relationship('Captain', backref='charters')
vessel = db.relationship('Vessel', backref='charters')
route = db.relationship('Route', backref='charters')
class WorkOrder(db.Model):
__tablename__ = 'work_orders'
id = db.Column(db.Integer, primary_key=True)
vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id'))
requested_by_company_id = db.Column(db.Integer, db.ForeignKey('companies.id'))
approved_by_owner_id = db.Column(db.Integer)
description = db.Column(db.Text)
estimated_cost = db.Column(db.Float)
actual_cost = db.Column(db.Float)
status = db.Column(db.String(20), default='pending') # pending, approved, done, rejected
priority = db.Column(db.String(20), default='normal') # normal, urgente, emergencia
invoice_number = db.Column(db.String(60)) # número de factura/invoice
notified_at = db.Column(db.DateTime)
approved_at = db.Column(db.DateTime) # timestamp de aprobacion del owner
approved_by_name = db.Column(db.String(100)) # nombre del owner que aprobó
rejected_at = db.Column(db.DateTime)
rejection_reason = db.Column(db.String(300))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
completed_at = db.Column(db.DateTime)
vessel = db.relationship('Vessel', backref='work_orders')
class Voucher(db.Model):
__tablename__ = 'vouchers'
id = db.Column(db.Integer, primary_key=True)
charter_id = db.Column(db.Integer, db.ForeignKey('charters.id'))
total_charged = db.Column(db.Float)
fuel_actual_liters = db.Column(db.Float)
deviation_charged = db.Column(db.Float, default=0)
speed_extra_charged = db.Column(db.Float, default=0)
tip_amount = db.Column(db.Float)
tip_percentage = db.Column(db.Float, default=18)
issued_at = db.Column(db.DateTime, default=datetime.utcnow)
paid_at = db.Column(db.DateTime)
charter = db.relationship('Charter', backref='voucher')
class AccountingVessel(db.Model):
__tablename__ = 'accounting_vessel'
id = db.Column(db.Integer, primary_key=True)
vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id'))
month = db.Column(db.Integer)
year = db.Column(db.Integer)
charter_revenue = db.Column(db.Float, default=0)
plan_revenue = db.Column(db.Float, default=0)
fuel_cost = db.Column(db.Float, default=0)
maintenance_cost = db.Column(db.Float, default=0)
cleaning_cost = db.Column(db.Float, default=0)
detailing_cost = db.Column(db.Float, default=0)
teak_cost = db.Column(db.Float, default=0)
net_profit = db.Column(db.Float, default=0)
# ── Ledger de transacciones por embarcación ──────────────────────────
class AccountingEntry(db.Model):
__tablename__ = 'accounting_entries'
id = db.Column(db.Integer, primary_key=True)
vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id'), nullable=False)
date = db.Column(db.Date, nullable=False)
entry_type = db.Column(db.String(10), nullable=False) # 'income' | 'expense'
category = db.Column(db.String(30), nullable=False)
# income: 'charter' | 'plan_subscription' | 'other_income'
# expense: 'work_order' | 'fuel' | 'cleaning' | 'detailing' | 'teak' | 'marina' | 'other_expense'
description = db.Column(db.String(300))
amount = db.Column(db.Float, nullable=False)
invoice_number = db.Column(db.String(60))
reference_type = db.Column(db.String(20)) # 'charter' | 'work_order' | 'fuel_entry' | None
reference_id = db.Column(db.Integer)
notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
vessel = db.relationship('Vessel', backref='accounting_entries')
# ── Registro de combustible ──────────────────────────────────────────
class FuelEntry(db.Model):
__tablename__ = 'fuel_entries'
id = db.Column(db.Integer, primary_key=True)
vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id'), nullable=False)
charter_id = db.Column(db.Integer, db.ForeignKey('charters.id'), nullable=True)
date = db.Column(db.Date, nullable=False)
liters = db.Column(db.Float)
price_per_liter = db.Column(db.Float)
total_cost = db.Column(db.Float, nullable=False)
supplier = db.Column(db.String(100))
invoice_number = db.Column(db.String(60))
notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
vessel = db.relationship('Vessel', backref='fuel_entries')
charter = db.relationship('Charter', backref='fuel_entries')
# ── Documentos adjuntos (invoices, contratos, fotos) ─────────────────
class Document(db.Model):
__tablename__ = 'documents'
id = db.Column(db.Integer, primary_key=True)
vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id'))
reference_type = db.Column(db.String(20)) # 'charter'|'work_order'|'fuel_entry'|'general'
reference_id = db.Column(db.Integer)
doc_type = db.Column(db.String(20)) # 'invoice'|'receipt'|'contract'|'photo'|'other'
filename = db.Column(db.String(200))
original_name = db.Column(db.String(200))
file_size = db.Column(db.Integer)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
notes = db.Column(db.Text)
vessel = db.relationship('Vessel', backref='documents')
+29
View File
@@ -0,0 +1,29 @@
class VesselSystem(db.Model):
__tablename__ = 'vessel_systems'
id = db.Column(db.Integer, primary_key=True)
vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id'))
system_type = db.Column(db.String(50), nullable=False) # propulsion, cubierta, puente, gobierno, generacion, electrico, etc
component_name = db.Column(db.String(100), nullable=False)
manufacturer = db.Column(db.String(100))
model = db.Column(db.String(100))
serial_number = db.Column(db.String(100))
installation_date = db.Column(db.Date)
last_maintenance_date = db.Column(db.Date)
status = db.Column(db.String(20), default='active')
notes = db.Column(db.Text)
vessel = db.relationship('Vessel', backref='systems')
class MaintenanceLog(db.Model):
__tablename__ = 'maintenance_logs'
id = db.Column(db.Integer, primary_key=True)
system_id = db.Column(db.Integer, db.ForeignKey('vessel_systems.id'))
work_order_id = db.Column(db.Integer, db.ForeignKey('work_orders.id'))
maintenance_date = db.Column(db.Date, nullable=False)
maintenance_type = db.Column(db.String(50), nullable=False) # preventivo, correctivo, inspeccion
description = db.Column(db.Text)
cost = db.Column(db.Float)
technician = db.Column(db.String(100))
next_maintenance_date = db.Column(db.Date)
hours_accumulated = db.Column(db.Integer)
system = db.relationship('VesselSystem', backref='maintenance_logs')
+39
View File
@@ -0,0 +1,39 @@
from flask import Blueprint, render_template, abort, redirect, url_for
from flask_login import login_required, current_user
from app.models import Vessel, Company
bp = Blueprint('admin', __name__, url_prefix='/admin')
@bp.route('/dashboard')
@login_required
def dashboard():
if current_user.role != 'admin':
return 'Acceso denegado', 403
return render_template('admin/dashboard.html', user=current_user)
@bp.route('/companies')
@login_required
def companies():
from app.models import Company, User
if not getattr(current_user, 'is_super_admin', False):
return redirect(url_for('admin.dashboard'))
mgmt_companies = Company.query.filter_by(type='management').all()
return render_template('admin/companies.html', companies=mgmt_companies, user=current_user)
@bp.route('/vessel/<int:vessel_id>/accounting')
@login_required
def vessel_accounting(vessel_id):
if current_user.role != 'admin':
return 'Acceso denegado', 403
vessel = Vessel.query.get_or_404(vessel_id)
owner = Company.query.get(vessel.owner_company_id)
plan_names = {1: 'Básico ($199/mes)', 2: 'Estándar ($399/mes)',
3: 'Mantenimiento ($299/mes)', 4: 'Plus ($599/mes)'}
plan_costs = {1: 199, 2: 399, 3: 299, 4: 599}
return render_template('admin/vessel_accounting.html',
vessel=vessel,
owner=owner,
plan_name=plan_names.get(vessel.plan_id, 'Sin plan'),
plan_cost=plan_costs.get(vessel.plan_id, 0),
user=current_user
)
+1334
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user
bp = Blueprint('owner', __name__, url_prefix='/owner')
@bp.route('/dashboard')
@login_required
def dashboard():
return render_template('owner/dashboard.html', user=current_user)
+453
View File
@@ -0,0 +1,453 @@
<!DOCTYPE html>
<html>
<head>
<title>Companies & Users — Fleet Management</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f0f2f5; }
.header { background: #0a2a3a; color: white; padding: 15px 30px; display: flex; justify-content: space-between; align-items: center; border-bottom: 3px solid #c4a747; }
.header h1 { font-size: 22px; }
.header h1 span { color: #c4a747; }
.header-actions { display: flex; gap: 12px; align-items: center; }
.back-btn { background: transparent; color: #c4a747; padding: 8px 18px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px; border: 1px solid #c4a747; }
.back-btn:hover { background: #c4a747; color: #0a2a3a; }
.logout-btn { background: #c4a747; color: #0a2a3a; padding: 8px 18px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px; }
.logout-btn:hover { background: #d4b757; }
.container { padding: 25px 30px; max-width: 1100px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px; flex-wrap: wrap; gap: 10px; }
.page-header h2 { color: #0a2a3a; font-size: 22px; }
.quota-badge { background: white; border: 2px solid #c4a747; border-radius: 20px; padding: 6px 16px; font-size: 13px; font-weight: 700; color: #0a2a3a; }
.quota-badge span { color: #c4a747; }
.company-block { background: white; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 1px 6px rgba(0,0,0,0.08); overflow: hidden; }
.company-header { background: #0a2a3a; color: white; padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; }
.company-header h3 { font-size: 16px; }
.company-header h3 .company-email { font-size: 12px; color: #aac4d0; font-weight: 400; margin-left: 10px; }
.company-meta { font-size: 12px; color: #aac4d0; margin-top: 2px; }
.company-actions { display: flex; gap: 8px; }
.users-section { padding: 18px 20px; }
.users-section h4 { font-size: 13px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; font-weight: 600; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { background: #f4f6f8; color: #0a2a3a; padding: 9px 12px; text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; border-bottom: 2px solid #e8e8e8; }
td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafaf8; }
.btn { background: #0a2a3a; color: white; padding: 7px 14px; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; font-weight: 600; text-decoration: none; display: inline-block; transition: background 0.2s; }
.btn:hover { background: #c4a747; color: #0a2a3a; }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-gold { background: #c4a747; color: #0a2a3a; }
.btn-gold:hover { background: #d4b757; }
.btn-danger { background: #e74c3c; color: white; }
.btn-danger:hover { background: #c0392b; }
.btn-success { background: #27ae60; color: white; }
.btn-success:hover { background: #219a52; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-super { background: #c4a747; color: #0a2a3a; }
.badge-admin { background: #d4edda; color: #155724; }
.badge-active { background: #d4edda; color: #155724; }
.badge-inactive { background: #f8d7da; color: #721c24; }
.vessel-count { background: #f0e8cc; color: #0a2a3a; border-radius: 10px; padding: 2px 10px; font-size: 12px; font-weight: 700; display: inline-block; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.55); justify-content: center; align-items: flex-start; z-index: 1000; padding-top: 60px; overflow-y: auto; }
.modal.open { display: flex; }
.modal-content { background: white; padding: 28px; border-radius: 10px; width: 520px; max-width: 95%; margin-bottom: 40px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 2px solid #f0e8cc; padding-bottom: 12px; }
.modal-header h3 { color: #0a2a3a; font-size: 17px; }
.close-btn { cursor: pointer; font-size: 26px; color: #999; line-height: 1; background: none; border: none; }
.close-btn:hover { color: #0a2a3a; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; margin-bottom: 5px; color: #0a2a3a; font-weight: 600; font-size: 13px; }
.form-group input, .form-group select { width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: #c4a747; }
.hint { font-size: 11px; color: #888; margin-top: 3px; }
.section-sep { font-size: 11px; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin: 16px 0 10px; padding-bottom: 5px; border-bottom: 1px dashed #e0d8b0; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.msg-bar { padding: 10px 16px; border-radius: 6px; font-size: 14px; font-weight: 600; margin-bottom: 16px; display: none; }
.msg-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; display: block; }
.msg-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; display: block; }
.empty-users { color: #999; font-size: 13px; font-style: italic; padding: 10px 0; }
</style>
</head>
<body>
<div class="header">
<h1>Fleet <span>Management</span></h1>
<div class="header-actions">
<a href="/admin/dashboard" class="back-btn">&#8592; Dashboard</a>
<a href="{{ url_for('auth.logout') }}" class="logout-btn">Cerrar Sesion</a>
</div>
</div>
<div class="container">
<div class="page-header">
<h2>&#9776; Companies &amp; Users</h2>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<div class="quota-badge">Companies: <span>{{ companies|length }}</span> / 10</div>
{% if companies|length < 10 %}
<button class="btn btn-gold" onclick="openCreateCompany()">+ New Company</button>
{% endif %}
</div>
</div>
<div id="msgBar" class="msg-bar"></div>
{% for c in companies %}
{% set vessel_count = c.managed_vessels | length %}
<div class="company-block">
<div class="company-header">
<div>
<h3>
{{ c.name }}
<span class="company-email">{{ c.email or '' }}</span>
</h3>
<div class="company-meta">
{% if c.phone %}&#128222; {{ c.phone }} &nbsp;&bull;&nbsp;{% endif %}
<span class="vessel-count">{{ vessel_count }} vessel{{ 's' if vessel_count != 1 else '' }}</span>
&nbsp;&bull;&nbsp; {{ c.users | length }} user{{ 's' if c.users|length != 1 else '' }}
</div>
</div>
<div class="company-actions">
<button class="btn btn-sm" onclick="openAddUser({{ c.id }}, {{ c.name | tojson }})">+ Add User</button>
<button class="btn btn-sm btn-gold" onclick='openEditCompany({{ c.id }}, {{ c.name|tojson }}, {{ (c.email or "")|tojson }}, {{ (c.phone or "")|tojson }})'>Edit</button>
</div>
</div>
<div class="users-section">
<h4>Users</h4>
{% if c.users %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in c.users %}
<tr>
<td>
<strong>{{ u.name }}</strong>
{% if u.is_super_admin %}<span class="badge badge-super">&#9733; Super Admin</span>{% endif %}
</td>
<td>{{ u.email }}</td>
<td><span class="badge badge-admin">{{ u.role | capitalize }}</span></td>
<td>
{% if u.is_active %}
<span class="badge badge-active">Active</span>
{% else %}
<span class="badge badge-inactive">Inactive</span>
{% endif %}
</td>
<td>
<button class="btn btn-sm" onclick='openEditUser({{ u.id }}, {{ c.id }}, {{ u.name|tojson }}, {{ u.email|tojson }}, {{ u.role|tojson }}, {{ u.is_super_admin|tojson }}, {{ u.is_active|tojson }})'>Edit</button>
{% if not u.is_super_admin %}
<button class="btn btn-sm btn-danger" onclick="deleteUser({{ u.id }}, {{ c.id }})">Delete</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-users">No users assigned to this company yet. Click "+ Add User" to create one.</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- MODAL: New Company -->
<div id="createCompanyModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>New Management Company</h3>
<button class="close-btn" onclick="closeModal('createCompanyModal')">&times;</button>
</div>
<div class="section-sep">Company Info</div>
<div class="form-group"><label>Company Name *</label><input type="text" id="cc_name" placeholder="Miami Marine Management LLC"></div>
<div class="form-row">
<div class="form-group"><label>Company Email</label><input type="email" id="cc_email" placeholder="info@company.com"></div>
<div class="form-group"><label>Phone</label><input type="text" id="cc_phone" placeholder="305-000-0000"></div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px;">
<button class="btn" onclick="closeModal('createCompanyModal')" style="background:#ccc;color:#333;">Cancel</button>
<button class="btn btn-gold" onclick="createCompany()">Create Company</button>
</div>
</div>
</div>
<!-- MODAL: Edit Company -->
<div id="editCompanyModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Edit Company</h3>
<button class="close-btn" onclick="closeModal('editCompanyModal')">&times;</button>
</div>
<input type="hidden" id="ec_id">
<div class="form-group"><label>Company Name *</label><input type="text" id="ec_name"></div>
<div class="form-row">
<div class="form-group"><label>Company Email</label><input type="email" id="ec_email"></div>
<div class="form-group"><label>Phone</label><input type="text" id="ec_phone"></div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px;">
<button class="btn" onclick="closeModal('editCompanyModal')" style="background:#ccc;color:#333;">Cancel</button>
<button class="btn btn-gold" onclick="updateCompany()">Save Changes</button>
</div>
</div>
</div>
<!-- MODAL: Add User -->
<div id="addUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Add User to <span id="addUserCompanyName" style="color:#c4a747;"></span></h3>
<button class="close-btn" onclick="closeModal('addUserModal')">&times;</button>
</div>
<input type="hidden" id="au_company_id">
<div class="form-row">
<div class="form-group"><label>Full Name *</label><input type="text" id="au_name" placeholder="John Smith"></div>
<div class="form-group"><label>Email *</label><input type="email" id="au_email" placeholder="user@company.com"></div>
</div>
<div class="form-row">
<div class="form-group">
<label>Password *</label>
<input type="password" id="au_password" placeholder="Min. 6 characters">
</div>
<div class="form-group">
<label>Role</label>
<select id="au_role">
<option value="admin">Admin</option>
<option value="operator">Operator</option>
</select>
</div>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400;">
<input type="checkbox" id="au_super" style="width:auto;">
Grant Super Admin privileges (can manage all companies and users)
</label>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px;">
<button class="btn" onclick="closeModal('addUserModal')" style="background:#ccc;color:#333;">Cancel</button>
<button class="btn btn-success" onclick="addUser()">Add User</button>
</div>
</div>
</div>
<!-- MODAL: Edit User -->
<div id="editUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Edit User</h3>
<button class="close-btn" onclick="closeModal('editUserModal')">&times;</button>
</div>
<input type="hidden" id="eu_id">
<input type="hidden" id="eu_company_id">
<div class="form-row">
<div class="form-group"><label>Full Name *</label><input type="text" id="eu_name"></div>
<div class="form-group"><label>Email *</label><input type="email" id="eu_email"></div>
</div>
<div class="form-row">
<div class="form-group">
<label>New Password <span style="font-weight:400;color:#888;">(leave blank to keep)</span></label>
<input type="password" id="eu_password" placeholder="New password...">
</div>
<div class="form-group">
<label>Role</label>
<select id="eu_role">
<option value="admin">Admin</option>
<option value="operator">Operator</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400;">
<input type="checkbox" id="eu_super" style="width:auto;">
Super Admin privileges
</label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400;">
<input type="checkbox" id="eu_active" style="width:auto;" checked>
Active account
</label>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px;">
<button class="btn" onclick="closeModal('editUserModal')" style="background:#ccc;color:#333;">Cancel</button>
<button class="btn btn-gold" onclick="updateUser()">Save Changes</button>
</div>
</div>
</div>
<script>
function showMsg(msg, type) {
const bar = document.getElementById('msgBar');
bar.textContent = msg;
bar.className = 'msg-bar msg-' + type;
bar.scrollIntoView({behavior:'smooth', block:'nearest'});
setTimeout(() => { bar.className = 'msg-bar'; }, 4000);
}
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
document.querySelectorAll('.modal').forEach(m => {
m.addEventListener('click', e => { if (e.target === m) m.classList.remove('open'); });
});
// ---- COMPANY ----
function openCreateCompany() {
['cc_name','cc_email','cc_phone'].forEach(id => document.getElementById(id).value = '');
document.getElementById('createCompanyModal').classList.add('open');
}
function openEditCompany(id, name, email, phone) {
document.getElementById('ec_id').value = id;
document.getElementById('ec_name').value = name;
document.getElementById('ec_email').value = email;
document.getElementById('ec_phone').value = phone;
document.getElementById('editCompanyModal').classList.add('open');
}
async function createCompany() {
const name = document.getElementById('cc_name').value.trim();
if (!name) { showMsg('Company name is required.', 'error'); return; }
const res = await fetch('/api/management-companies', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name,
company_email: document.getElementById('cc_email').value.trim(),
phone: document.getElementById('cc_phone').value.trim()
})
});
const data = await res.json();
if (data.success) {
showMsg('Company created. Reloading...', 'success');
closeModal('createCompanyModal');
setTimeout(() => location.reload(), 1200);
} else {
showMsg(data.error || 'Error creating company', 'error');
}
}
async function updateCompany() {
const id = document.getElementById('ec_id').value;
const res = await fetch('/api/management-companies/' + id, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name: document.getElementById('ec_name').value.trim(),
company_email: document.getElementById('ec_email').value.trim(),
phone: document.getElementById('ec_phone').value.trim()
})
});
const data = await res.json();
if (data.success) {
showMsg('Company updated. Reloading...', 'success');
closeModal('editCompanyModal');
setTimeout(() => location.reload(), 1200);
} else {
showMsg(data.error || 'Error', 'error');
}
}
// ---- USERS ----
function openAddUser(companyId, companyName) {
document.getElementById('au_company_id').value = companyId;
document.getElementById('addUserCompanyName').textContent = companyName;
['au_name','au_email','au_password'].forEach(id => document.getElementById(id).value = '');
document.getElementById('au_role').value = 'admin';
document.getElementById('au_super').checked = false;
document.getElementById('addUserModal').classList.add('open');
}
function openEditUser(userId, companyId, name, email, role, isSuper, isActive) {
document.getElementById('eu_id').value = userId;
document.getElementById('eu_company_id').value = companyId;
document.getElementById('eu_name').value = name;
document.getElementById('eu_email').value = email;
document.getElementById('eu_password').value = '';
document.getElementById('eu_role').value = role;
document.getElementById('eu_super').checked = isSuper;
document.getElementById('eu_active').checked = isActive;
document.getElementById('editUserModal').classList.add('open');
}
async function addUser() {
const companyId = document.getElementById('au_company_id').value;
const name = document.getElementById('au_name').value.trim();
const email = document.getElementById('au_email').value.trim();
const pwd = document.getElementById('au_password').value;
if (!name || !email || !pwd) { showMsg('Name, email and password are required.', 'error'); return; }
const res = await fetch(`/api/management-companies/${companyId}/users`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name, email, password: pwd,
role: document.getElementById('au_role').value,
is_super_admin: document.getElementById('au_super').checked
})
});
const data = await res.json();
if (data.success) {
showMsg('User added successfully. Reloading...', 'success');
closeModal('addUserModal');
setTimeout(() => location.reload(), 1200);
} else {
showMsg(data.error || 'Error adding user', 'error');
}
}
async function updateUser() {
const id = document.getElementById('eu_id').value;
const companyId = document.getElementById('eu_company_id').value;
const payload = {
name: document.getElementById('eu_name').value.trim(),
email: document.getElementById('eu_email').value.trim(),
role: document.getElementById('eu_role').value,
is_super_admin: document.getElementById('eu_super').checked,
is_active: document.getElementById('eu_active').checked
};
const pwd = document.getElementById('eu_password').value;
if (pwd) payload.password = pwd;
const res = await fetch(`/api/management-companies/${companyId}/users/${id}`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
showMsg('User updated. Reloading...', 'success');
closeModal('editUserModal');
setTimeout(() => location.reload(), 1200);
} else {
showMsg(data.error || 'Error', 'error');
}
}
async function deleteUser(userId, companyId) {
if (!confirm('Delete this user? This action cannot be undone.')) return;
const res = await fetch(`/api/management-companies/${companyId}/users/${userId}`, {method: 'DELETE'});
const data = await res.json();
if (data.success) {
showMsg('User deleted. Reloading...', 'success');
setTimeout(() => location.reload(), 1000);
} else {
showMsg(data.error || 'Error', 'error');
}
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+703
View File
@@ -0,0 +1,703 @@
<!DOCTYPE html>
<html>
<head>
<title>Contabilidad — {{ vessel.name }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f0f2f5; }
.header { background: #0a2a3a; color: white; padding: 14px 30px; display: flex; justify-content: space-between; align-items: center; border-bottom: 3px solid #c4a747; }
.header-left { display: flex; align-items: center; gap: 18px; }
.back-btn { background: rgba(255,255,255,0.15); color: white; padding: 7px 14px; text-decoration: none; border-radius: 5px; font-size: 13px; border: 1px solid rgba(255,255,255,0.3); }
.back-btn:hover { background: #c4a747; color: #0a2a3a; border-color: #c4a747; }
.header h1 { font-size: 20px; }
.header h1 span { color: #c4a747; }
.header-right { display: flex; gap: 10px; }
.logout-btn { background: #c4a747; color: #0a2a3a; padding: 7px 16px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 13px; }
.vessel-banner { background: #0a2a3a; color: white; padding: 20px 30px; border-bottom: 2px solid #c4a747; }
.vessel-banner h2 { font-size: 24px; color: #c4a747; margin-bottom: 6px; }
.vessel-meta { display: flex; gap: 25px; flex-wrap: wrap; font-size: 13px; color: rgba(255,255,255,0.75); }
.vessel-meta strong { color: white; }
.container { padding: 25px 30px; max-width: 1400px; margin: 0 auto; }
/* KPI cards */
.kpi-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 22px; }
.kpi { background: white; border-radius: 10px; padding: 18px 14px; text-align: center; box-shadow: 0 1px 5px rgba(0,0,0,0.07); }
.kpi.income { border-top: 4px solid #27ae60; }
.kpi.expense { border-top: 4px solid #e74c3c; }
.kpi.profit { border-top: 4px solid #c4a747; }
.kpi.charters { border-top: 4px solid #3498db; }
.kpi.fuel { border-top: 4px solid #9b59b6; }
.kpi h4 { font-size: 11px; text-transform: uppercase; color: #888; letter-spacing: 0.5px; margin-bottom: 10px; }
.kpi .val { font-size: 26px; font-weight: bold; color: #0a2a3a; }
.kpi .sub { font-size: 11px; color: #aaa; margin-top: 4px; }
/* Filters bar */
.filters { background: white; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; box-shadow: 0 1px 5px rgba(0,0,0,0.07); }
.filters label { font-size: 11px; font-weight: 700; color: #0a2a3a; text-transform: uppercase; display: block; margin-bottom: 4px; }
.filters select, .filters input[type=date] { padding: 8px 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 13px; }
.filters select:focus, .filters input:focus { outline: none; border-color: #c4a747; }
/* Cards */
.card { background: white; border-radius: 10px; padding: 22px; margin-bottom: 20px; box-shadow: 0 1px 5px rgba(0,0,0,0.07); border-left: 4px solid #c4a747; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 2px solid #f0e8cc; }
.card-header h3 { color: #0a2a3a; font-size: 16px; }
.card-header .card-total { font-size: 14px; font-weight: bold; }
/* Journal table */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
th { background: #0a2a3a; color: white; font-weight: 600; font-size: 12px; }
tr:hover td { background: #fafaf8; }
.col-debit { text-align: right; color: #e74c3c; font-weight: 600; }
.col-credit { text-align: right; color: #27ae60; font-weight: 600; }
.col-balance { text-align: right; font-weight: bold; }
.col-num { text-align: right; }
/* Badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.badge-charter { background: #d4edda; color: #155724; }
.badge-fuel { background: #e8d5f5; color: #6c3483; }
.badge-work_order { background: #ffeeba; color: #856404; }
.badge-cleaning { background: #d1ecf1; color: #0c5460; }
.badge-other { background: #e9ecef; color: #495057; }
.badge-plan_subscription { background: #cce5ff; color: #004085; }
/* Buttons */
.btn { background: #0a2a3a; color: white; padding: 8px 16px; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; font-weight: 600; text-decoration: none; display: inline-block; margin: 2px; transition: background 0.2s; }
.btn:hover { background: #c4a747; color: #0a2a3a; }
.btn-gold { background: #c4a747; color: #0a2a3a; }
.btn-gold:hover { background: #d4b757; }
.btn-green { background: #27ae60; color: white; }
.btn-green:hover { background: #2ecc71; color: white; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-danger { background: #e74c3c; color: white; }
.btn-danger:hover { background: #c0392b; color: white; }
/* Modals */
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.55); justify-content: center; align-items: flex-start; z-index: 1000; padding-top: 50px; overflow-y: auto; }
.modal-content { background: white; padding: 28px; border-radius: 10px; width: 560px; max-width: 95%; margin-bottom: 40px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; border-bottom: 2px solid #f0e8cc; padding-bottom: 10px; }
.modal-header h3 { color: #0a2a3a; font-size: 16px; }
.close { cursor: pointer; font-size: 24px; color: #999; }
.close:hover { color: #0a2a3a; }
.form-group { margin-bottom: 13px; }
.form-group label { display: block; margin-bottom: 4px; color: #0a2a3a; font-weight: 600; font-size: 12px; text-transform: uppercase; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 9px 11px; border: 1px solid #ddd; border-radius: 5px; font-size: 13px; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: #c4a747; box-shadow: 0 0 0 2px rgba(196,167,71,0.15); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.form-group textarea { min-height: 70px; resize: vertical; }
.msg-bar { padding: 10px 14px; border-radius: 5px; margin-bottom: 15px; display: none; font-weight: 600; font-size: 13px; }
.msg-bar.success { background: #d4edda; color: #155724; }
.msg-bar.error { background: #f8d7da; color: #721c24; }
.running-balance { font-family: 'Courier New', monospace; }
@media (max-width: 900px) { .kpi-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 600px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } .form-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<a href="/admin/dashboard" class="back-btn">&#8592; Volver al Dashboard</a>
<h1>Fleet <span>Management</span> &mdash; Contabilidad</h1>
</div>
<div class="header-right">
<a href="{{ url_for('auth.logout') }}" class="logout-btn">Cerrar Sesion</a>
</div>
</div>
<div class="vessel-banner">
<h2>&#128676; {{ vessel.name }}</h2>
<div class="vessel-meta">
<span><strong>Dueno:</strong> {{ owner.name if owner else 'N/A' }}</span>
<span><strong>Plan:</strong> {{ plan_name }}</span>
<span><strong>Marca/Modelo:</strong> {{ vessel.make or '' }} {{ vessel.model or '' }}</span>
<span><strong>Eslora:</strong> {{ vessel.length or 'N/A' }} ft</span>
<span><strong>Motores:</strong> {{ vessel.engines or 'N/A' }}</span>
</div>
</div>
<div class="container">
<div id="msgBar" class="msg-bar"></div>
<!-- KPIs -->
<div class="kpi-grid">
<div class="kpi income">
<h4>Ingresos Totales</h4>
<div class="val" id="kpiIncome">$0</div>
<div class="sub" id="kpiIncomeCount">0 transacciones</div>
</div>
<div class="kpi expense">
<h4>Gastos Totales</h4>
<div class="val" id="kpiExpenses">$0</div>
<div class="sub" id="kpiExpCount">0 transacciones</div>
</div>
<div class="kpi profit">
<h4>Utilidad Neta</h4>
<div class="val" id="kpiProfit">$0</div>
<div class="sub">Periodo seleccionado</div>
</div>
<div class="kpi charters">
<h4>Ing. Charters</h4>
<div class="val" id="kpiCharters">$0</div>
<div class="sub" id="kpiChartersCount">0 charters</div>
</div>
<div class="kpi fuel">
<h4>Gasto Combustible</h4>
<div class="val" id="kpiFuel">$0</div>
<div class="sub" id="kpiFuelCount">0 recargas</div>
</div>
</div>
<!-- Filtros -->
<div class="filters">
<div>
<label>Año</label>
<select id="fYear" onchange="loadAll()">
<option value="">Todos</option>
<option value="2024">2024</option>
<option value="2025">2025</option>
<option value="2026" selected>2026</option>
</select>
</div>
<div>
<label>Mes</label>
<select id="fMonth" onchange="loadAll()">
<option value="">Todos</option>
<option value="1">Enero</option><option value="2">Febrero</option>
<option value="3">Marzo</option><option value="4">Abril</option>
<option value="5">Mayo</option><option value="6">Junio</option>
<option value="7">Julio</option><option value="8">Agosto</option>
<option value="9">Septiembre</option><option value="10">Octubre</option>
<option value="11">Noviembre</option><option value="12">Diciembre</option>
</select>
</div>
<div>
<label>Tipo</label>
<select id="fType" onchange="renderJournal()">
<option value="">Todos</option>
<option value="income">Solo Ingresos</option>
<option value="expense">Solo Gastos</option>
</select>
</div>
<div>
<label>Categoria</label>
<select id="fCategory" onchange="renderJournal()">
<option value="">Todas</option>
<option value="charter">Charter</option>
<option value="fuel">Combustible</option>
<option value="work_order">Work Order / Mant.</option>
<option value="plan_subscription">Plan</option>
<option value="cleaning">Limpieza</option>
<option value="detailing">Detailing</option>
<option value="other_income">Otro Ingreso</option>
<option value="other_expense">Otro Gasto</option>
</select>
</div>
<div style="margin-left:auto; display:flex; gap:8px; align-items:flex-end;">
<button class="btn btn-gold" onclick="openModal('entryModal')">+ Asiento Manual</button>
<button class="btn btn-green" onclick="openModal('fuelModal')">+ Combustible</button>
<button class="btn" onclick="syncVessel()" title="Importar charters y WOs a la contabilidad">&#8635; Sincronizar</button>
</div>
</div>
<!-- DIARIO / JOURNAL (main) -->
<div class="card">
<div class="card-header">
<h3>&#128221; Diario Contable</h3>
<div class="card-total">
Saldo del periodo:
<span id="periodBalance" style="color:#0a2a3a;font-size:16px;">$0</span>
</div>
</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th style="width:90px;">#</th>
<th style="width:100px;">Fecha</th>
<th>Descripcion</th>
<th style="width:110px;">Categoria</th>
<th style="width:130px;">N. Invoice / Ref</th>
<th style="width:110px;text-align:right;">Ingreso</th>
<th style="width:110px;text-align:right;">Gasto</th>
<th style="width:120px;text-align:right;">Saldo Acum.</th>
<th style="width:60px;"></th>
</tr>
</thead>
<tbody id="journalBody">
<tr><td colspan="9" style="text-align:center;color:#999;padding:30px;">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- CHARTERS (income) -->
<div class="card" style="border-left-color:#27ae60;">
<div class="card-header">
<h3 style="color:#27ae60;">&#128197; Ingresos por Charters</h3>
<div class="card-total" style="color:#27ae60;">Total: <span id="charterTotal">$0</span></div>
</div>
<table>
<thead>
<tr><th>Fecha</th><th>Cliente</th><th>Horas</th><th>Total Charter</th><th>Owner (75%)</th><th>Mgmt (25%)</th><th>Estado</th><th>Invoice</th></tr>
</thead>
<tbody id="chartersBody">
<tr><td colspan="8" style="color:#999;text-align:center;">Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- MANTENIMIENTO (expenses) -->
<div class="card" style="border-left-color:#e67e22;">
<div class="card-header">
<h3 style="color:#e67e22;">&#128295; Gastos de Mantenimiento (Work Orders)</h3>
<div class="card-total" style="color:#e67e22;">Total: <span id="woTotal">$0</span></div>
</div>
<table>
<thead>
<tr><th>Fecha</th><th>Descripcion</th><th>Prioridad</th><th>Costo Est.</th><th>Costo Real</th><th>Estado</th><th>Invoice</th></tr>
</thead>
<tbody id="woBody">
<tr><td colspan="7" style="color:#999;text-align:center;">Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- COMBUSTIBLE -->
<div class="card" style="border-left-color:#9b59b6;">
<div class="card-header">
<h3 style="color:#9b59b6;">&#9981; Registro de Combustible</h3>
<div class="card-total" style="color:#9b59b6;">Total: <span id="fuelTotal">$0</span></div>
</div>
<table>
<thead>
<tr><th>Fecha</th><th>Gallons</th><th>$/Gal</th><th>Total</th><th>Proveedor</th><th>Invoice</th><th>Charter</th><th></th></tr>
</thead>
<tbody id="fuelBody">
<tr><td colspan="8" style="color:#999;text-align:center;">Sin registros de combustible</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Modal: Asiento Manual -->
<div id="entryModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Nuevo Asiento Contable</h3>
<span class="close" onclick="closeModal('entryModal')">&times;</span>
</div>
<form id="entryForm">
<div class="form-grid">
<div class="form-group">
<label>Tipo *</label>
<select id="entryType" onchange="updateCats()" required>
<option value="income">Ingreso</option>
<option value="expense">Gasto</option>
</select>
</div>
<div class="form-group">
<label>Fecha *</label>
<input type="date" id="entryDate" required>
</div>
</div>
<div class="form-group">
<label>Categoria *</label>
<select id="entryCat" required></select>
</div>
<div class="form-group">
<label>Descripcion *</label>
<input type="text" id="entryDesc" required placeholder="Ej: Ingreso adicional por servicio de catering">
</div>
<div class="form-grid">
<div class="form-group">
<label>Monto ($) *</label>
<input type="number" id="entryAmt" step="0.01" min="0.01" required>
</div>
<div class="form-group">
<label>N. Invoice / Referencia</label>
<input type="text" id="entryInv" placeholder="Ej: INV-2026-042">
</div>
</div>
<div class="form-group">
<label>Notas</label>
<textarea id="entryNotes" placeholder="Detalles adicionales..."></textarea>
</div>
<button type="submit" class="btn btn-gold" style="width:100%;">Guardar Asiento</button>
</form>
</div>
</div>
<!-- Modal: Combustible -->
<div id="fuelModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Registro de Combustible</h3>
<span class="close" onclick="closeModal('fuelModal')">&times;</span>
</div>
<form id="fuelForm">
<div class="form-group">
<label>Fecha *</label>
<input type="date" id="fuelDate" required>
</div>
<div class="form-grid">
<div class="form-group">
<label>Gallons</label>
<input type="number" id="fuelLiters" step="0.1" min="0" oninput="calcFuel()">
</div>
<div class="form-group">
<label>Precio / Galon ($)</label>
<input type="number" id="fuelPpl" step="0.01" min="0" oninput="calcFuel()">
</div>
</div>
<div class="form-group" style="max-width:200px;">
<label>Total ($) *</label>
<input type="number" id="fuelTotalAmt" step="0.01" min="0.01" required>
</div>
<div class="form-grid">
<div class="form-group">
<label>Proveedor / Marina</label>
<input type="text" id="fuelSupplier" placeholder="Ej: Miami Marine Fuel">
</div>
<div class="form-group">
<label>N. Invoice / Recibo</label>
<input type="text" id="fuelInv" placeholder="Ej: REC-042">
</div>
</div>
<div class="form-group">
<label>Charter relacionado (opcional)</label>
<select id="fuelCharter"><option value="">-- Ninguno --</option></select>
</div>
<div class="form-group">
<label>Notas</label>
<textarea id="fuelNotes" rows="2"></textarea>
</div>
<button type="submit" class="btn btn-gold" style="width:100%;">Guardar y Generar Asiento</button>
</form>
</div>
</div>
<script>
const VESSEL_ID = {{ vessel.id }};
// ─── Categories ────────────────────────────────────────────
const CATS = {
income: {charter:'Charter', plan_subscription:'Suscripcion Plan', other_income:'Otro Ingreso'},
expense: {work_order:'Work Order / Mant.', fuel:'Combustible', cleaning:'Limpieza',
detailing:'Detailing', teak:'Limpieza Teca', marina:'Marina / Muelle',
insurance:'Seguro', storage:'Almacenaje', other_expense:'Otro Gasto'}
};
function updateCats() {
const type = document.getElementById('entryType').value;
document.getElementById('entryCat').innerHTML =
Object.entries(CATS[type]).map(([v,l]) => `<option value="${v}">${l}</option>`).join('');
}
updateCats();
function calcFuel() {
const l = parseFloat(document.getElementById('fuelLiters').value) || 0;
const p = parseFloat(document.getElementById('fuelPpl').value) || 0;
if (l > 0 && p > 0) document.getElementById('fuelTotalAmt').value = (l * p).toFixed(2);
}
// ─── Modal helpers ─────────────────────────────────────────
function openModal(id) {
const today = new Date().toISOString().split('T')[0];
if (id === 'entryModal') document.getElementById('entryDate').value = today;
if (id === 'fuelModal') {
document.getElementById('fuelDate').value = today;
loadCharterSelect();
}
document.getElementById(id).style.display = 'flex';
}
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
document.querySelectorAll('.modal').forEach(m => {
m.addEventListener('click', e => { if (e.target === m) m.style.display = 'none'; });
});
async function loadCharterSelect() {
const data = await fetch(`/api/charters`).then(r => r.json());
const mine = data.filter(c => c.vessel_id == VESSEL_ID);
document.getElementById('fuelCharter').innerHTML =
'<option value="">-- Ninguno --</option>' +
mine.map(c => `<option value="${c.id}">${c.start_datetime} ${c.charterer_name}</option>`).join('');
}
// ─── Message bar ───────────────────────────────────────────
function showMsg(msg, type) {
const el = document.getElementById('msgBar');
el.textContent = msg;
el.className = 'msg-bar ' + type;
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 4000);
}
// ─── Cached data ───────────────────────────────────────────
let cachedAcc = null;
let cachedCharters = null;
let cachedWO = null;
let cachedFuel = null;
async function loadAll() {
const year = document.getElementById('fYear').value;
const month = document.getElementById('fMonth').value;
let url = `/api/accounting/vessel/${VESSEL_ID}`;
const params = [];
if (year) params.push(`year=${year}`);
if (month) params.push(`month=${month}`);
if (params.length) url += '?' + params.join('&');
const [acc, fuel] = await Promise.all([
fetch(url).then(r => r.json()),
fetch(`/api/fuel-entries?vessel_id=${VESSEL_ID}`).then(r => r.json())
]);
cachedAcc = acc;
cachedFuel = fuel;
// Also load charters and WOs for dedicated sections
const [allCharters, allWO] = await Promise.all([
fetch('/api/charters').then(r => r.json()),
fetch('/api/workorders').then(r => r.json())
]);
cachedCharters = allCharters.filter(c => c.vessel_id == VESSEL_ID);
cachedWO = allWO.filter(w => w.vessel_name === '{{ vessel.name }}');
renderKPIs(acc, fuel);
renderJournal();
renderCharters();
renderWO();
renderFuel(fuel);
}
function renderKPIs(acc, fuel) {
const income = acc.entries.filter(e => e.entry_type === 'income');
const expenses = acc.entries.filter(e => e.entry_type === 'expense');
const totalIn = income.reduce((s, e) => s + e.amount, 0);
const totalEx = expenses.reduce((s, e) => s + e.amount, 0);
const charterIn = income.filter(e => e.category === 'charter').reduce((s, e) => s + e.amount, 0);
const fuelEx = expenses.filter(e => e.category === 'fuel').reduce((s, e) => s + e.amount, 0);
document.getElementById('kpiIncome').textContent = '$' + totalIn.toLocaleString('en-US', {maximumFractionDigits:0});
document.getElementById('kpiIncomeCount').textContent = income.length + ' transacciones';
document.getElementById('kpiExpenses').textContent = '$' + totalEx.toLocaleString('en-US', {maximumFractionDigits:0});
document.getElementById('kpiExpCount').textContent = expenses.length + ' transacciones';
const profit = totalIn - totalEx;
const kpiP = document.getElementById('kpiProfit');
kpiP.textContent = '$' + profit.toLocaleString('en-US', {maximumFractionDigits:0});
kpiP.style.color = profit >= 0 ? '#27ae60' : '#e74c3c';
document.getElementById('kpiCharters').textContent = '$' + charterIn.toLocaleString('en-US', {maximumFractionDigits:0});
document.getElementById('kpiChartersCount').textContent = income.filter(e=>e.category==='charter').length + ' charters';
document.getElementById('kpiFuel').textContent = '$' + fuelEx.toLocaleString('en-US', {maximumFractionDigits:0});
document.getElementById('kpiFuelCount').textContent = fuel.length + ' recargas';
}
function catBadge(cat) {
const labels = {
charter:'Charter', fuel:'Combustible', work_order:'Mant.', plan_subscription:'Plan',
cleaning:'Limpieza', detailing:'Detailing', teak:'Teca', marina:'Marina',
insurance:'Seguro', storage:'Almacenaje', other_income:'Otro +', other_expense:'Otro -'
};
const cls = ['charter','fuel','work_order','plan_subscription'].includes(cat) ? cat : 'other';
return `<span class="badge badge-${cls}">${labels[cat]||cat}</span>`;
}
function renderJournal() {
if (!cachedAcc) return;
const typeF = document.getElementById('fType').value;
const catF = document.getElementById('fCategory').value;
let entries = [...cachedAcc.entries];
if (typeF) entries = entries.filter(e => e.entry_type === typeF);
if (catF) entries = entries.filter(e => e.category === catF);
// Sort oldest→newest for running balance
entries.sort((a, b) => a.date.localeCompare(b.date));
let balance = 0;
const rows = entries.map((e, i) => {
const credit = e.entry_type === 'income' ? e.amount : 0;
const debit = e.entry_type === 'expense' ? e.amount : 0;
balance += (credit - debit);
const balColor = balance >= 0 ? '#27ae60' : '#e74c3c';
return `<tr>
<td style="font-size:11px;color:#999;">${e.invoice_number || '-'}</td>
<td>${e.date}</td>
<td>${e.description}</td>
<td>${catBadge(e.category)}</td>
<td style="font-size:11px;color:#666;">${e.reference_type ? e.reference_type.replace('_',' ').toUpperCase() + ' #' + String(e.reference_id||'').padStart(4,'0') : '-'}</td>
<td class="col-credit">${credit > 0 ? '$' + credit.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}) : ''}</td>
<td class="col-debit">${debit > 0 ? '$' + debit.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}) : ''}</td>
<td class="col-balance running-balance" style="color:${balColor}">$${balance.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})"></button></td>
</tr>`;
});
const totalIn = entries.filter(e=>e.entry_type==='income').reduce((s,e)=>s+e.amount,0);
const totalEx = entries.filter(e=>e.entry_type==='expense').reduce((s,e)=>s+e.amount,0);
const net = totalIn - totalEx;
document.getElementById('periodBalance').textContent = '$' + net.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2});
document.getElementById('periodBalance').style.color = net >= 0 ? '#27ae60' : '#e74c3c';
document.getElementById('journalBody').innerHTML = rows.length
? rows.join('')
: '<tr><td colspan="9" style="text-align:center;color:#999;padding:25px;">Sin asientos para el periodo / filtros seleccionados</td></tr>';
}
function renderCharters() {
if (!cachedCharters) return;
const charters = cachedCharters;
const totalIncome = charters.filter(c=>c.status==='completed').reduce((s,c)=>s+(c.owner_earnings||0),0);
document.getElementById('charterTotal').textContent = '$' + totalIncome.toLocaleString('en-US',{maximumFractionDigits:0});
const statusBadge = s => {
const map = {draft:'Borrador',signed:'Firmado',completed:'Completado',paid:'Pagado'};
const cls = {draft:'#e9ecef',signed:'#cce5ff',completed:'#d4edda',paid:'#c4a747'};
const tc = {draft:'#495057',signed:'#004085',completed:'#155724',paid:'#0a2a3a'};
return `<span style="background:${cls[s]||'#eee'};color:${tc[s]||'#333'};padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">${map[s]||s}</span>`;
};
document.getElementById('chartersBody').innerHTML = charters.length
? charters.map(c => `<tr>
<td>${c.start_datetime}</td>
<td>${c.charterer_name}<br><small style="color:#999;">${c.charterer_phone||''}</small></td>
<td>${c.hours}h</td>
<td style="font-weight:600;">$${c.total_base_rate}</td>
<td style="color:#27ae60;font-weight:600;">$${c.owner_earnings||0}</td>
<td style="color:#e67e22;font-weight:600;">$${c.management_earnings||0}</td>
<td>${statusBadge(c.status)}</td>
<td style="font-size:11px;color:#666;">CHR-${String(c.id).padStart(4,'0')}</td>
</tr>`).join('')
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin charters registrados</td></tr>';
}
function renderWO() {
if (!cachedWO) return;
const wos = cachedWO;
const totalCost = wos.filter(w=>w.status==='done').reduce((s,w)=>s+(w.actual_cost||w.estimated_cost||0),0);
document.getElementById('woTotal').textContent = '$' + totalCost.toLocaleString('en-US',{maximumFractionDigits:0});
const priBadge = p => {
if (p==='emergencia') return '<span style="background:#f8d7da;color:#721c24;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">&#128680; Emergencia</span>';
if (p==='urgente') return '<span style="background:#ffeeba;color:#856404;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">&#9888; Urgente</span>';
return '<span style="background:#e9ecef;color:#495057;padding:2px 8px;border-radius:10px;font-size:11px;">Normal</span>';
};
const stBadge = s => {
const map = {pending:'Pendiente',approved:'Aprobado',done:'Completado',rejected:'Rechazado'};
const cls = {pending:'#fff3cd',approved:'#cce5ff',done:'#d4edda',rejected:'#f8d7da'};
const tc = {pending:'#856404',approved:'#004085',done:'#155724',rejected:'#721c24'};
return `<span style="background:${cls[s]||'#eee'};color:${tc[s]||'#333'};padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">${map[s]||s}</span>`;
};
document.getElementById('woBody').innerHTML = wos.length
? wos.map(w => `<tr>
<td>${w.created_at}</td>
<td>${w.description}</td>
<td>${priBadge(w.priority)}</td>
<td>$${w.estimated_cost||0}</td>
<td style="font-weight:600;color:#e74c3c;">${w.actual_cost ? '$'+w.actual_cost : '-'}</td>
<td>${stBadge(w.status)}</td>
<td style="font-size:11px;color:#666;">WO-${String(w.id).padStart(4,'0')}</td>
</tr>`).join('')
: '<tr><td colspan="7" style="color:#999;text-align:center;">Sin work orders</td></tr>';
}
function renderFuel(fuel) {
const totalFuel = fuel.reduce((s,f) => s + (f.total_cost||0), 0);
document.getElementById('fuelTotal').textContent = '$' + totalFuel.toLocaleString('en-US',{maximumFractionDigits:0});
document.getElementById('fuelBody').innerHTML = fuel.length
? fuel.map(f => `<tr>
<td>${f.date}</td>
<td>${f.liters ? f.liters + ' gal' : '-'}</td>
<td>${f.price_per_liter ? '$' + f.price_per_liter + '/gal' : '-'}</td>
<td style="font-weight:600;color:#9b59b6;">$${f.total_cost}</td>
<td>${f.supplier||'-'}</td>
<td style="font-size:11px;color:#666;">${f.invoice_number||'-'}</td>
<td style="font-size:11px;">${f.charter_id ? 'CHR-'+String(f.charter_id).padStart(4,'0') : '-'}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteFuel(${f.id})">&#215;</button></td>
</tr>`).join('')
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin registros de combustible</td></tr>';
}
// ─── Actions ───────────────────────────────────────────────
async function deleteEntry(id) {
if (!confirm('Eliminar este asiento?')) return;
await fetch(`/api/accounting/entries/${id}`, {method: 'DELETE'});
showMsg('Asiento eliminado', 'success');
loadAll();
}
async function deleteFuel(id) {
if (!confirm('Eliminar este registro de combustible?')) return;
await fetch(`/api/fuel-entries/${id}`, {method: 'DELETE'});
showMsg('Registro eliminado', 'success');
loadAll();
}
async function syncVessel() {
const res = await fetch(`/api/accounting/sync-vessel/${VESSEL_ID}`, {method: 'POST'});
const data = await res.json();
showMsg(`Sincronizacion completada: ${data.entries_created} asientos nuevos generados`, 'success');
loadAll();
}
// ─── Form submissions ─────────────────────────────────────
document.getElementById('entryForm').onsubmit = async (e) => {
e.preventDefault();
await fetch('/api/accounting/entries', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
vessel_id: VESSEL_ID,
date: document.getElementById('entryDate').value,
entry_type: document.getElementById('entryType').value,
category: document.getElementById('entryCat').value,
description: document.getElementById('entryDesc').value,
amount: document.getElementById('entryAmt').value,
invoice_number: document.getElementById('entryInv').value,
notes: document.getElementById('entryNotes').value
})
});
closeModal('entryModal');
document.getElementById('entryForm').reset();
updateCats();
showMsg('Asiento contable guardado', 'success');
loadAll();
};
document.getElementById('fuelForm').onsubmit = async (e) => {
e.preventDefault();
await fetch('/api/fuel-entries', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
vessel_id: VESSEL_ID,
date: document.getElementById('fuelDate').value,
liters: document.getElementById('fuelLiters').value || 0,
price_per_liter: document.getElementById('fuelPpl').value || 0,
total_cost: document.getElementById('fuelTotalAmt').value,
supplier: document.getElementById('fuelSupplier').value,
invoice_number: document.getElementById('fuelInv').value,
charter_id: document.getElementById('fuelCharter').value || null,
notes: document.getElementById('fuelNotes').value
})
});
closeModal('fuelModal');
document.getElementById('fuelForm').reset();
showMsg('Combustible registrado y asiento generado automaticamente', 'success');
loadAll();
};
// ─── Init ──────────────────────────────────────────────────
loadAll();
</script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title>Fleet Management - Login</title>
<style>
body { font-family: Arial; background: #1a1a2e; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.login-container { background: white; padding: 30px; border-radius: 10px; width: 300px; }
input { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 5px; }
button { width: 100%; padding: 10px; background: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; }
.error { color: red; }
</style>
</head>
<body>
<div class="login-container">
<h2>Fleet Management</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="error">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Contraseña" required>
<button type="submit">Ingresar</button>
</form>
</div>
</body>
</html>
+381
View File
@@ -0,0 +1,381 @@
<!DOCTYPE html>
<html>
<head>
<title>Owner Portal - Fleet Management</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f0f2f5; }
.header { background: #0a2a3a; color: white; padding: 15px 30px; display: flex; justify-content: space-between; align-items: center; border-bottom: 3px solid #c4a747; }
.header h1 { font-size: 20px; }
.header h1 span { color: #c4a747; }
.header-right { display: flex; align-items: center; gap: 15px; }
.user-info { font-size: 13px; color: #c4a747; }
.logout-btn { background: #c4a747; color: #0a2a3a; padding: 8px 18px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px; }
.logout-btn:hover { background: #d4b757; }
.container { padding: 25px 30px; max-width: 1200px; margin: 0 auto; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; margin-bottom: 20px; }
.stat-card { background: white; border-radius: 10px; padding: 20px 15px; text-align: center; border-top: 4px solid #c4a747; box-shadow: 0 1px 6px rgba(0,0,0,0.07); }
.stat-card h4 { color: #666; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
.stat-card .value { font-size: 28px; font-weight: bold; color: #0a2a3a; }
.card { background: white; border-radius: 10px; padding: 25px; margin-bottom: 20px; box-shadow: 0 1px 6px rgba(0,0,0,0.07); border-left: 4px solid #c4a747; }
.card h3 { color: #0a2a3a; margin-bottom: 18px; border-bottom: 2px solid #f0e8cc; padding-bottom: 10px; font-size: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 11px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
th { background: #0a2a3a; color: white; font-weight: 600; }
tr:hover td { background: #fafaf8; }
.btn { padding: 7px 16px; border: none; border-radius: 5px; cursor: pointer; font-size: 13px; font-weight: 600; transition: background 0.2s; }
.btn-approve { background: #27ae60; color: white; }
.btn-approve:hover { background: #2ecc71; }
.btn-reject { background: #e74c3c; color: white; }
.btn-reject:hover { background: #c0392b; }
.badge { display: inline-block; padding: 3px 9px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-approved { background: #d4edda; color: #155724; }
.badge-rejected { background: #f8d7da; color: #721c24; }
.badge-done { background: #cce5ff; color: #004085; }
.badge-normal { background: #e9ecef; color: #495057; }
.badge-urgente { background: #ffeeba; color: #856404; border:1px solid #f39c12; }
.badge-emergencia{ background: #f8d7da; color: #721c24; border:1px solid #e74c3c; }
.message-bar { padding: 10px 15px; margin-bottom: 15px; border-radius: 5px; display: none; font-size: 14px; font-weight: 600; }
.message-bar.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.message-bar.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
/* Approval proof box */
.approval-proof { background: #f0faf4; border: 1px solid #b7dfc8; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #155724; margin-top: 6px; }
.approval-proof strong { display: block; margin-bottom:3px; }
.rejection-proof { background: #fdf5f5; border: 1px solid #f5c6cb; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #721c24; margin-top: 6px; }
/* Modal */
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.55); justify-content: center; align-items: flex-start; z-index: 1000; padding-top: 60px; }
.modal-content { background: white; padding: 30px; border-radius: 10px; width: 500px; max-width: 95%; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 2px solid #f0e8cc; padding-bottom: 12px; }
.modal-header h3 { color: #0a2a3a; }
.close { cursor: pointer; font-size: 26px; color: #999; }
.close:hover { color: #0a2a3a; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; margin-bottom: 5px; color: #0a2a3a; font-weight: 600; font-size: 13px; }
.form-group textarea { width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; min-height: 80px; }
.pending-alert { background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 12px 16px; margin-bottom: 18px; font-size: 14px; color: #856404; font-weight: 600; }
.emergency-alert { background: #f8d7da; border: 2px solid #e74c3c; border-radius: 8px; padding: 12px 16px; margin-bottom: 18px; font-size: 14px; color: #721c24; font-weight: 600; }
@media (max-width: 700px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<div class="header">
<h1>Fleet <span>Management</span> &mdash; Owner Portal</h1>
<div class="header-right">
<span class="user-info" id="welcomeUser"></span>
<a href="{{ url_for('auth.logout') }}" class="logout-btn">Cerrar Sesion</a>
</div>
</div>
<div class="container">
<div id="message" class="message-bar"></div>
<!-- Alertas de emergencia -->
<div id="alertsArea"></div>
<!-- KPIs -->
<div class="stats-grid">
<div class="stat-card"><h4>Mis Botes</h4><div class="value" id="myVessels">-</div></div>
<div class="stat-card"><h4>Ingresos Charters</h4><div class="value" id="myRevenue">$-</div></div>
<div class="stat-card"><h4>Gastos (Planes)</h4><div class="value" id="myExpenses">$-</div></div>
<div class="stat-card"><h4>Utilidad Neta</h4><div class="value" id="myProfit">$-</div></div>
</div>
<!-- Embarcaciones -->
<div class="card">
<h3>Mis Embarcaciones</h3>
<table>
<thead>
<tr>
<th>Nombre</th><th>Marca / Modelo</th><th>Eslora</th>
<th>Plan</th><th>Costo/mes</th><th>Charters</th>
<th>Ingresos</th><th>Utilidad</th>
</tr>
</thead>
<tbody id="vesselsBody">
<tr><td colspan="8" style="color:#999;text-align:center;">Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- Work Orders: pendientes de aprobacion -->
<div class="card" id="pendingWoCard">
<h3>Work Orders Pendientes de Aprobacion</h3>
<p style="font-size:13px;color:#666;margin-bottom:14px;">
Su aprobacion es requerida para autorizar los trabajos de mantenimiento.
Cada aprobacion queda registrada con su nombre, fecha y hora como prueba.
</p>
<div id="pendingWoBody">
<p style="color:#999;font-size:13px;">Sin work orders pendientes.</p>
</div>
</div>
<!-- Historial completo de Work Orders -->
<div class="card">
<h3>Historial de Work Orders</h3>
<table>
<thead>
<tr>
<th>Fecha</th><th>Bote</th><th>Descripcion</th>
<th>Costo Est.</th><th>Costo Real</th><th>Prioridad</th>
<th>Estado</th><th>Registro de Aprobacion</th>
</tr>
</thead>
<tbody id="woHistoryBody">
<tr><td colspan="8" style="color:#999;text-align:center;">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Modal: Confirmar Aprobacion -->
<div id="approveModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Confirmar Aprobacion</h3>
<span class="close" onclick="closeModal('approveModal')">&times;</span>
</div>
<input type="hidden" id="approveWoId">
<div id="approveWoDetail" style="background:#f7f9fa;padding:14px;border-radius:6px;margin-bottom:18px;font-size:13px;"></div>
<p style="font-size:13px;color:#555;margin-bottom:18px;">
Al confirmar, usted autoriza la ejecucion de este trabajo.
Su nombre, fecha y hora quedaran registrados como prueba de aprobacion.
</p>
<div style="display:flex;gap:10px;">
<button class="btn btn-approve" style="flex:1;" onclick="confirmApprove()">Si, Aprobar Work Order</button>
<button class="btn" style="background:#e9ecef;color:#333;flex:1;" onclick="closeModal('approveModal')">Cancelar</button>
</div>
</div>
</div>
<!-- Modal: Confirmar Rechazo -->
<div id="rejectModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Rechazar Work Order</h3>
<span class="close" onclick="closeModal('rejectModal')">&times;</span>
</div>
<input type="hidden" id="rejectWoId">
<div id="rejectWoDetail" style="background:#fdf5f5;padding:14px;border-radius:6px;margin-bottom:18px;font-size:13px;"></div>
<div class="form-group">
<label>Motivo del rechazo (requerido)</label>
<textarea id="rejectReason" placeholder="Explique por que rechaza este trabajo..." required></textarea>
</div>
<p style="font-size:12px;color:#888;margin-bottom:14px;">El rechazo tambien queda registrado con su nombre y motivo.</p>
<div style="display:flex;gap:10px;">
<button class="btn btn-reject" style="flex:1;" onclick="confirmReject()">Confirmar Rechazo</button>
<button class="btn" style="background:#e9ecef;color:#333;flex:1;" onclick="closeModal('rejectModal')">Cancelar</button>
</div>
</div>
</div>
<script>
function showMessage(msg, type) {
const el = document.getElementById('message');
el.textContent = msg;
el.className = 'message-bar ' + type;
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 5000);
}
function closeModal(id) {
document.getElementById(id).style.display = 'none';
}
document.querySelectorAll('.modal').forEach(m => {
m.addEventListener('click', e => { if (e.target === m) m.style.display = 'none'; });
});
const priBadge = p => {
if (p === 'emergencia') return '<span class="badge badge-emergencia">&#128680; Emergencia</span>';
if (p === 'urgente') return '<span class="badge badge-urgente">&#9888;&#65039; Urgente</span>';
return '<span class="badge badge-normal">Normal</span>';
};
const statusBadge = s => {
const map = {pending:'Pendiente', approved:'Aprobado', rejected:'Rechazado', done:'Completado'};
return `<span class="badge badge-${s}">${map[s]||s}</span>`;
};
async function loadData() {
try {
const data = await fetch('/api/owner/dashboard').then(r => r.json());
document.getElementById('welcomeUser').textContent = 'Bienvenido, ' + (data.owner_name || '');
document.getElementById('myVessels').textContent = data.vessels_count;
document.getElementById('myRevenue').textContent = '$' + data.total_revenue.toLocaleString();
document.getElementById('myExpenses').textContent = '$' + data.total_expenses.toLocaleString();
const profit = data.net_profit;
const profitEl = document.getElementById('myProfit');
profitEl.textContent = '$' + profit.toLocaleString();
profitEl.style.color = profit >= 0 ? '#27ae60' : '#e74c3c';
// Vessels
document.getElementById('vesselsBody').innerHTML = data.vessels.length
? data.vessels.map(v => `<tr>
<td><strong>${v.name}</strong></td>
<td>${v.make ? v.make + (v.model ? ' ' + v.model : '') : '-'}</td>
<td>${v.length ? v.length + ' ft' : '-'}</td>
<td>${v.plan}</td>
<td>$${v.plan_cost}</td>
<td>${v.charters_count || 0}</td>
<td style="color:#27ae60;font-weight:600;">$${v.charter_revenue}</td>
<td style="font-weight:bold;color:${v.net_profit >= 0 ? '#27ae60' : '#e74c3c'}">$${v.net_profit}</td>
</tr>`).join('')
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin embarcaciones</td></tr>';
// Split WOs: pending vs all
const pendingWos = data.workorders.filter(wo => wo.status === 'pending');
const allWos = data.workorders;
// Emergency alerts
const alerts = document.getElementById('alertsArea');
const emergencies = pendingWos.filter(wo => wo.priority === 'emergencia');
if (emergencies.length) {
alerts.innerHTML = emergencies.map(wo =>
`<div class="emergency-alert">
&#128680; EMERGENCIA — <strong>${wo.vessel_name}</strong>:
${wo.description} — El bote NO puede ir a charter.
Apruebe esta work order de inmediato.
</div>`
).join('');
} else {
alerts.innerHTML = '';
}
// Pending WOs — approval cards
if (pendingWos.length) {
document.getElementById('pendingWoBody').innerHTML = pendingWos.map(wo => `
<div style="border:1px solid ${wo.priority==='emergencia'?'#e74c3c':wo.priority==='urgente'?'#f39c12':'#ddd'};border-radius:8px;padding:16px;margin-bottom:14px;background:${wo.priority==='emergencia'?'#fff5f5':wo.priority==='urgente'?'#fffbf0':'#fafafa'};">
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;">
<div style="flex:1;min-width:250px;">
<div style="font-weight:bold;color:#0a2a3a;font-size:15px;margin-bottom:4px;">${wo.vessel_name}</div>
<div style="font-size:13px;color:#444;margin-bottom:8px;">${wo.description}</div>
<div style="font-size:12px;color:#666;">
Solicitado: ${wo.created_at} &nbsp;|&nbsp;
Costo estimado: <strong>$${wo.estimated_cost || 0}</strong>
&nbsp;|&nbsp; ${priBadge(wo.priority)}
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn btn-approve" onclick="openApprove(${wo.id}, '${wo.vessel_name}', '${wo.description.replace(/'/g,"\\'")}', ${wo.estimated_cost||0})">
&#10003; Aprobar
</button>
<button class="btn btn-reject" onclick="openReject(${wo.id}, '${wo.vessel_name}', '${wo.description.replace(/'/g,"\\'")}')">
&#10007; Rechazar
</button>
</div>
</div>
</div>`).join('');
} else {
document.getElementById('pendingWoBody').innerHTML =
'<p style="color:#27ae60;font-size:14px;padding:10px 0;">&#10003; Sin work orders pendientes de aprobacion.</p>';
}
// Full WO history with approval proof
document.getElementById('woHistoryBody').innerHTML = allWos.length
? allWos.map(wo => {
let proofHtml = '';
if (wo.status === 'approved' || wo.status === 'done') {
proofHtml = `<div class="approval-proof">
<strong>&#10003; Aprobado por: ${wo.approved_by_name || 'Owner'}</strong>
Fecha: ${wo.approved_at || '-'}
</div>`;
} else if (wo.status === 'rejected') {
proofHtml = `<div class="rejection-proof">
<strong>&#10007; Rechazado por: ${wo.approved_by_name || 'Owner'}</strong>
Fecha: ${wo.rejected_at || '-'}<br>
${wo.rejection_reason ? 'Motivo: ' + wo.rejection_reason : ''}
</div>`;
} else {
proofHtml = '<span style="color:#999;font-size:12px;">Pendiente</span>';
}
return `<tr>
<td>${wo.created_at}</td>
<td>${wo.vessel_name}</td>
<td>${wo.description}</td>
<td>$${wo.estimated_cost || 0}</td>
<td>${wo.actual_cost ? '$'+wo.actual_cost : '-'}</td>
<td>${priBadge(wo.priority)}</td>
<td>${statusBadge(wo.status)}</td>
<td>${proofHtml}</td>
</tr>`;
}).join('')
: '<tr><td colspan="8" style="color:#999;text-align:center;">Sin work orders</td></tr>';
} catch(e) {
console.error(e);
showMessage('Error al cargar los datos', 'error');
}
}
// ─── Approve flow ───────────────────────────────────────
function openApprove(id, vessel, desc, cost) {
document.getElementById('approveWoId').value = id;
document.getElementById('approveWoDetail').innerHTML =
`<strong style="color:#0a2a3a;">${vessel}</strong><br>
${desc}<br>
<span style="color:#666;">Costo estimado: <strong>$${cost}</strong></span>`;
document.getElementById('approveModal').style.display = 'flex';
}
async function confirmApprove() {
const id = document.getElementById('approveWoId').value;
const res = await fetch(`/api/workorders/${id}/approve`, {method: 'POST'});
const data = await res.json();
if (data.success) {
closeModal('approveModal');
showMessage(`Work Order aprobada por ${data.approved_by} el ${data.approved_at}`, 'success');
loadData();
} else {
showMessage('Error al aprobar', 'error');
}
}
// ─── Reject flow ────────────────────────────────────────
function openReject(id, vessel, desc) {
document.getElementById('rejectWoId').value = id;
document.getElementById('rejectReason').value = '';
document.getElementById('rejectWoDetail').innerHTML =
`<strong style="color:#721c24;">${vessel}</strong><br>${desc}`;
document.getElementById('rejectModal').style.display = 'flex';
}
async function confirmReject() {
const id = document.getElementById('rejectWoId').value;
const reason = document.getElementById('rejectReason').value.trim();
if (!reason) { showMessage('Por favor indique el motivo del rechazo', 'error'); return; }
const res = await fetch(`/api/workorders/${id}/reject`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({reason})
});
const data = await res.json();
if (data.success) {
closeModal('rejectModal');
showMessage('Work Order rechazada. El equipo de gestion ha sido notificado.', 'success');
loadData();
} else {
showMessage('Error al rechazar', 'error');
}
}
loadData();
</script>
</body>
</html>
+254
View File
@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Captain Services Agreement</title>
<style>
@page { size: letter; margin: 1.8cm 2.2cm; }
body { font-family: 'Times New Roman', Times, serif; font-size: 11pt; color: #1a1a1a; line-height: 1.55; }
.header-banner { background: #0a2a3a; color: white; padding: 16px 22px; }
.header-banner .title { font-size: 17pt; font-weight: bold; color: #c4a747; letter-spacing: 1px; font-family: Arial, sans-serif; }
.header-banner .sub { font-size: 9pt; color: #aac4d0; margin-top: 3px; font-family: Arial, sans-serif; }
.gold-bar { background: #c4a747; height: 5px; margin-bottom: 16px; }
.doc-title { text-align: center; font-size: 14pt; font-weight: bold; color: #0a2a3a; margin: 14px 0 3px; text-transform: uppercase; letter-spacing: 1.5px; font-family: Arial, sans-serif; }
.doc-ref { text-align: center; font-size: 9pt; color: #666; margin-bottom: 14px; font-family: Arial, sans-serif; }
.alert-box { background: #fff3cd; border: 2px solid #c4a747; border-radius: 3px; padding: 11px 16px; margin: 12px 0 16px; font-size: 10.5pt; font-weight: bold; color: #7d5a00; text-align: center; font-family: Arial, sans-serif; line-height: 1.6; }
.section { margin-bottom: 14px; }
.section-title { background: #0a2a3a; color: #c4a747; padding: 4px 10px; font-size: 9.5pt; font-weight: bold; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 8px; font-family: Arial, sans-serif; }
table { width: 100%; border-collapse: collapse; font-size: 10.5pt; }
th { background: #0a2a3a; color: white; padding: 5px 8px; text-align: left; font-size: 9.5pt; font-family: Arial, sans-serif; }
td { padding: 5px 8px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) td { background: #f9f9f9; }
.total-row td { background: #f0e8cc; font-weight: bold; font-size: 11.5pt; }
.clause { margin-bottom: 9px; text-align: justify; font-size: 10.5pt; }
.clause-num { font-weight: bold; color: #0a2a3a; }
.ic-box { background: #eef6fc; border: 1px solid #b3d9ef; border-radius: 3px; padding: 11px 15px; font-size: 10.5pt; }
.license-badge { display: inline-block; padding: 2px 10px; border-radius: 8px; font-size: 9.5pt; font-weight: bold; font-family: Arial, sans-serif; }
.badge-private { background: #e9ecef; color: #495057; }
.badge-sixpack { background: #cce5ff; color: #004085; }
.badge-master { background: #f0e8cc; color: #7d5a00; }
.sig-line { border-top: 1px solid #333; margin-top: 45px; padding-top: 5px; font-size: 9pt; color: #555; font-family: Arial, sans-serif; }
.footer { margin-top: 18px; border-top: 1px solid #ddd; padding-top: 7px; text-align: center; font-size: 8.5pt; color: #999; font-family: Arial, sans-serif; }
</style>
</head>
<body>
<div class="header-banner">
<div class="title">Captain Services Agreement</div>
<div class="sub">Independent Contractor Agreement &bull; Private Charter &bull; State of Florida</div>
</div>
<div class="gold-bar"></div>
<div class="doc-title">Captain Services Agreement</div>
<div class="doc-ref">
Reference: Charter Agreement No. CHR-{{ '%04d' | format(charter.id) }} &nbsp;&bull;&nbsp;
Vessel: {{ vessel.name if vessel else 'N/A' }} &nbsp;&bull;&nbsp;
Date: {{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '________________' }}
</div>
<div class="alert-box">
THIS AGREEMENT IS ENTERED INTO DIRECTLY BETWEEN THE CHARTERER AND THE CAPTAIN.<br>
THE VESSEL MANAGEMENT COMPANY IS NOT A PARTY TO THIS AGREEMENT<br>
AND ASSUMES NO LIABILITY WHATSOEVER UNDER THIS CONTRACT.
</div>
<!-- 1. PARTIES -->
<div class="section">
<div class="section-title">1. Parties</div>
<table>
<tr>
<th style="width:50%;">Captain (Independent Contractor)</th>
<th style="width:50%;">Client (Charterer)</th>
</tr>
<tr>
<td style="vertical-align:top;padding:8px;">
{% if captain %}
<strong>{{ captain.name }}</strong><br>
{% if captain.phone %}Phone: {{ captain.phone }}<br>{% endif %}
{% if captain.license_number %}License No.: {{ captain.license_number }}<br>{% endif %}
License Type:&nbsp;
{% if captain.license_type == 'six_pack' %}
<span class="license-badge badge-sixpack">USCG Six-Pack (OUPV)</span>
{% elif captain.license_type == 'master' %}
<span class="license-badge badge-master">USCG Master 50/100 Ton</span>
{% else %}
<span class="license-badge badge-private">Private Captain</span>
{% endif %}
{% else %}
<span style="color:#999;font-style:italic;">Captain not yet assigned</span>
{% endif %}
</td>
<td style="vertical-align:top;padding:8px;">
<strong>{{ charter.charterer_name }}</strong><br>
{% if charter.charterer_email %}Email: {{ charter.charterer_email }}<br>{% endif %}
{% if charter.charterer_phone %}Phone: {{ charter.charterer_phone }}<br>{% endif %}
ID / Passport No.: _______________________
</td>
</tr>
</table>
</div>
<!-- 2. VESSEL & SERVICE DETAILS -->
<div class="section">
<div class="section-title">2. Vessel and Service Details</div>
<table>
<tr>
<th>Vessel</th><th>Date</th><th>Departure</th><th>Return</th><th>Duration</th><th>Departure Port</th>
</tr>
<tr>
<td><strong>{{ vessel.name if vessel else '—' }}</strong><br>
<span style="font-size:9pt;color:#777;">{{ ((vessel.make or '') ~ ' ' ~ (vessel.model or '')) | trim if vessel else '' }}</span>
</td>
<td>{{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '—' }}</td>
<td><strong>{{ charter.start_datetime.strftime('%I:%M %p') if charter.start_datetime else '—' }}</strong></td>
<td><strong>{{ end_dt.strftime('%I:%M %p') if end_dt else '—' }}</strong></td>
<td>{{ charter.hours | string ~ ' hr' ~ ('s' if charter.hours != 1 else '') if charter.hours else '—' }}</td>
<td>___________________</td>
</tr>
</table>
</div>
<!-- 3. CAPTAIN'S FEE -->
<div class="section">
<div class="section-title">3. Captain's Fee</div>
<table>
<tr><th style="width:65%;">Description</th><th style="width:35%;text-align:right;">Amount (USD)</th></tr>
{% if captain and captain.hourly_rate and charter.hours %}
<tr>
<td>Captain's hourly rate: ${{ '%.2f' | format(captain.hourly_rate) }}/hr &times; {{ charter.hours }} hr{{ 's' if charter.hours != 1 else '' }}</td>
<td style="text-align:right;">${{ '%.2f' | format(captain.hourly_rate * charter.hours) }}</td>
</tr>
<tr class="total-row">
<td>TOTAL CAPTAIN'S FEE</td>
<td style="text-align:right;">${{ '%.2f' | format(captain.hourly_rate * charter.hours) }}</td>
</tr>
{% else %}
<tr>
<td>Fee agreed upon between the parties</td>
<td style="text-align:right;">$_____________________</td>
</tr>
{% endif %}
</table>
<div style="font-size:9pt;color:#777;margin-top:5px;">
All amounts in U.S. Dollars. Payment is due directly from Charterer to Captain.
The vessel management company has no involvement in this payment.
Gratuity, if any, is at the sole discretion of the Charterer and is not included above.
</div>
</div>
<!-- 4. CAPTAIN'S RESPONSIBILITIES -->
<div class="section">
<div class="section-title">4. Captain's Responsibilities</div>
<div class="clause">
<span class="clause-num">4.1 SAFE OPERATION.</span>
The Captain shall operate the vessel in a safe and seamanlike manner at all times, in compliance with all applicable federal, state, and local maritime laws and U.S. Coast Guard regulations.
</div>
<div class="clause">
<span class="clause-num">4.2 PASSENGER CAPACITY.</span>
The vessel's maximum rated capacity per its official documentation is
<strong>{{ vessel.max_passengers if vessel and vessel.max_passengers else '___' }} persons total</strong>
(including captain). The Captain shall ensure that the number of passengers does not exceed
<strong>{{ (vessel.max_passengers - 1) if vessel and vessel.max_passengers else '___' }} persons</strong>
at any time. The Captain is solely responsible for enforcing this limit before departure and during the voyage.
Exceeding the rated capacity is a federal violation and voids the insurance rider.
</div>
<div class="clause">
<span class="clause-num">4.3 WEATHER AUTHORITY.</span>
The Captain has sole and final authority to cancel or terminate the voyage at any time if, in the Captain's professional judgment, weather or sea conditions pose an unacceptable risk to the safety of passengers, crew, or the vessel.
</div>
<div class="clause">
<span class="clause-num">4.4 INCIDENT REPORTING.</span>
The Captain shall promptly report any accident, injury, damage to the vessel, or contact with another vessel or object to the vessel owner's management company and, if required by law, to the U.S. Coast Guard.
</div>
<div class="clause">
<span class="clause-num">4.5 PRIVATE USE ENFORCEMENT.</span>
The Captain acknowledges that this charter is for private recreational use only and shall not knowingly permit any commercial activity aboard the vessel.
</div>
<div class="clause">
<span class="clause-num">4.6 SOBRIETY.</span>
The Captain shall not operate the vessel under the influence of alcohol or any controlled substance. The Captain may refuse to depart or may return to port if any passenger's behavior poses a safety risk.
</div>
</div>
<!-- 5. INDEPENDENT CONTRACTOR STATUS -->
<div class="section">
<div class="section-title">5. Independent Contractor Status</div>
<div class="ic-box">
<div class="clause" style="margin-bottom:8px;">
<span class="clause-num">5.1</span>
The Captain is an <strong>independent contractor</strong> and not an employee, agent, or representative of the vessel management company or the vessel owner. Nothing in this Agreement shall be construed to create an employer-employee relationship between the Captain and any party other than as expressly stated herein.
</div>
<div class="clause" style="margin-bottom:8px;">
<span class="clause-num">5.2</span>
The Captain is solely responsible for all federal, state, and local income taxes, self-employment taxes, and any other taxes or contributions arising from compensation received under this Agreement.
</div>
<div class="clause" style="margin-bottom:0;">
<span class="clause-num">5.3</span>
The vessel management company shall have no obligation to provide workers' compensation, unemployment insurance, health insurance, or any other employee benefits to the Captain.
</div>
</div>
</div>
<!-- 6. LIABILITY & INDEMNIFICATION -->
<div class="section">
<div class="section-title">6. Liability and Indemnification</div>
<div class="clause">
<span class="clause-num">6.1</span>
The Captain shall indemnify and hold harmless the Charterer from any claims arising out of the Captain's gross negligence or willful misconduct during the performance of services under this Agreement.
</div>
<div class="clause">
<span class="clause-num">6.2</span>
The Charterer shall indemnify and hold harmless the Captain from any claims arising from the Charterer's or guests' actions, misconduct, or violation of the terms of the Charter Agreement.
</div>
<div class="clause">
<span class="clause-num">6.3</span>
<strong>The vessel management company and the vessel owner are expressly excluded from any liability arising under this Captain Services Agreement.</strong>
</div>
</div>
<!-- 7. GOVERNING LAW -->
<div class="section">
<div class="section-title">7. Governing Law</div>
<div class="clause" style="margin-bottom:0;">
This Agreement shall be governed by the laws of the State of Florida and applicable federal maritime law. Any disputes shall be resolved by binding arbitration in Miami-Dade County, Florida.
</div>
</div>
<!-- 8. SIGNATURES -->
<div class="section" style="margin-top:20px;">
<div class="section-title">8. Signatures</div>
<p style="font-size:10pt;margin-bottom:12px;">
By signing below, both parties confirm they have read and agree to all terms of this Captain Services Agreement.
</p>
<table style="border:none;">
<tr>
<td style="width:46%;border:none;vertical-align:bottom;padding:8px 5px;">
<div style="font-size:10pt;margin-bottom:6px;"><strong>CAPTAIN (Independent Contractor)</strong></div>
<div style="font-size:10pt;color:#333;margin-bottom:4px;">{{ captain.name if captain else '______________________________' }}</div>
{% if captain and captain.license_number %}
<div style="font-size:9.5pt;color:#666;margin-bottom:4px;">License No.: {{ captain.license_number }}</div>
{% endif %}
<div style="font-size:9.5pt;color:#666;margin-bottom:40px;">Date: ___________________________</div>
<div class="sig-line">Captain's Signature</div>
</td>
<td style="width:8%;border:none;"></td>
<td style="width:46%;border:none;vertical-align:bottom;padding:8px 5px;">
<div style="font-size:10pt;margin-bottom:6px;"><strong>CHARTERER (Client)</strong></div>
<div style="font-size:10pt;color:#333;margin-bottom:4px;">{{ charter.charterer_name }}</div>
<div style="font-size:9.5pt;color:#666;margin-bottom:4px;">ID / Passport No.: _______________</div>
<div style="font-size:9.5pt;color:#666;margin-bottom:40px;">Date: ___________________________</div>
<div class="sig-line">Charterer's Signature</div>
</td>
</tr>
</table>
</div>
<div class="footer">
Captain Services Agreement &bull; Ref. Charter No. CHR-{{ '%04d' | format(charter.id) }} &bull;
{{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '' }} &bull;
Vessel: {{ vessel.name if vessel else 'N/A' }} &bull;
Governing Law: State of Florida
</div>
</body>
</html>
+278
View File
@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Private Vessel Charter Agreement</title>
<style>
@page { size: letter; margin: 1.8cm 2.2cm; }
body { font-family: 'Times New Roman', Times, serif; font-size: 11pt; color: #1a1a1a; line-height: 1.55; }
.header-banner { background: #0a2a3a; color: white; padding: 16px 22px; }
.header-banner .company-name { font-size: 17pt; font-weight: bold; color: #c4a747; letter-spacing: 1px; font-family: Arial, sans-serif; }
.header-banner .company-sub { font-size: 9pt; color: #aac4d0; margin-top: 3px; font-family: Arial, sans-serif; }
.gold-bar { background: #c4a747; height: 5px; margin-bottom: 16px; }
.doc-title { text-align: center; font-size: 14pt; font-weight: bold; color: #0a2a3a; margin: 14px 0 3px; text-transform: uppercase; letter-spacing: 1.5px; font-family: Arial, sans-serif; }
.doc-ref { text-align: center; font-size: 9pt; color: #666; margin-bottom: 18px; font-family: Arial, sans-serif; }
.section { margin-bottom: 14px; }
.section-title { background: #0a2a3a; color: #c4a747; padding: 4px 10px; font-size: 9.5pt; font-weight: bold; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 8px; font-family: Arial, sans-serif; }
table { width: 100%; border-collapse: collapse; font-size: 10.5pt; }
th { background: #0a2a3a; color: white; padding: 5px 8px; text-align: left; font-size: 9.5pt; font-family: Arial, sans-serif; }
td { padding: 5px 8px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) td { background: #f9f9f9; }
.total-row td { background: #f0e8cc; font-weight: bold; font-size: 11.5pt; }
.clause { margin-bottom: 9px; text-align: justify; font-size: 10.5pt; }
.clause-num { font-weight: bold; color: #0a2a3a; }
.insurance-box { background: #eef6fc; border: 1px solid #b3d9ef; padding: 10px 14px; border-radius: 3px; font-size: 10pt; }
.notice-box { background: #fff8e1; border-left: 4px solid #c4a747; padding: 9px 13px; margin: 10px 0; font-size: 10pt; }
.sig-area { margin-top: 28px; }
.sig-line { border-top: 1px solid #333; margin-top: 45px; padding-top: 5px; font-size: 9pt; color: #555; font-family: Arial, sans-serif; }
.footer { margin-top: 18px; border-top: 1px solid #ddd; padding-top: 7px; text-align: center; font-size: 8.5pt; color: #999; font-family: Arial, sans-serif; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 8px; font-size: 9pt; font-weight: bold; font-family: Arial, sans-serif; background: #d4edda; color: #155724; }
</style>
</head>
<body>
<div class="header-banner">
<div class="company-name">{{ management_company.name if management_company else 'Fleet Management LLC' }}</div>
<div class="company-sub">Professional Vessel Management &bull; Private Vessel Charter Agreement &bull; State of Florida</div>
</div>
<div class="gold-bar"></div>
<div class="doc-title">Private Vessel Charter Agreement</div>
<div class="doc-ref">
Agreement No. CHR-{{ '%04d' | format(charter.id) }} &nbsp;&bull;&nbsp;
Status: <span class="badge">{{ charter.status | upper }}</span> &nbsp;&bull;&nbsp;
Governing Law: State of Florida, United States
</div>
<!-- 1. PARTIES -->
<div class="section">
<div class="section-title">1. Parties</div>
<table>
<tr>
<th style="width:50%;">Owner's Agent (Lessor)</th>
<th style="width:50%;">Charterer (Lessee)</th>
</tr>
<tr>
<td style="vertical-align:top;padding:8px;">
<strong>{{ management_company.name if management_company else 'Fleet Management LLC' }}</strong><br>
Acting as authorized agent for the vessel owner<br>
{% if management_company and management_company.email %}Email: {{ management_company.email }}<br>{% endif %}
{% if management_company and management_company.phone %}Phone: {{ management_company.phone }}{% endif %}
</td>
<td style="vertical-align:top;padding:8px;">
<strong>{{ charter.charterer_name }}</strong><br>
{% if charter.charterer_email %}Email: {{ charter.charterer_email }}<br>{% endif %}
{% if charter.charterer_phone %}Phone: {{ charter.charterer_phone }}<br>{% endif %}
ID / Passport No.: _______________________
</td>
</tr>
</table>
{% if owner_company %}
<div style="margin-top:7px;font-size:9.5pt;color:#555;font-style:italic;">
Registered owner of vessel: {{ owner_company.name }}
</div>
{% endif %}
</div>
<!-- 2. VESSEL -->
<div class="section">
<div class="section-title">2. Vessel Description</div>
<table>
<tr>
<th>Vessel Name</th><th>HIN / USCG Doc.</th><th>Make / Model</th><th>LOA</th><th>Engines</th><th>Max. Passengers</th>
</tr>
<tr>
<td><strong>{{ vessel.name if vessel else 'N/A' }}</strong></td>
<td>{{ vessel.hin if vessel and vessel.hin else '_______________' }}</td>
<td>{{ ((vessel.make or '') ~ ' ' ~ (vessel.model or '')) | trim if vessel else '—' }}</td>
<td>{{ (vessel.length | string ~ ' ft') if vessel and vessel.length else '—' }}</td>
<td>{{ vessel.engines if vessel and vessel.engines else '—' }}</td>
<td>___________</td>
</tr>
</table>
</div>
<!-- 3. CHARTER PERIOD -->
<div class="section">
<div class="section-title">3. Charter Period</div>
<table>
<tr>
<th>Date</th><th>Departure Time</th><th>Return Time</th><th>Duration</th><th>Departure Port / Marina</th>
</tr>
<tr>
<td>{{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '________________' }}</td>
<td><strong>{{ charter.start_datetime.strftime('%I:%M %p') if charter.start_datetime else '________' }}</strong></td>
<td><strong>{{ end_dt.strftime('%I:%M %p') if end_dt else '________' }}</strong></td>
<td>{{ charter.hours | string ~ ' hour' ~ ('s' if charter.hours != 1 else '') if charter.hours else '___' }}</td>
<td>___________________________</td>
</tr>
<tr>
<td colspan="5" style="background:#fff8e1;padding:7px 8px;">
<strong>Maximum persons aboard:</strong>
{% if vessel and vessel.max_passengers %}
{{ vessel.max_passengers }} total per vessel documentation
&mdash; <strong>{{ vessel.max_passengers - 1 }} passengers</strong> (excluding captain)
{% else %}
Per vessel documentation &mdash; Captain not counted as passenger
{% endif %}
&nbsp;&bull;&nbsp; <em>Exceeding rated capacity is strictly prohibited and voids insurance coverage.</em>
</td>
</tr>
</table>
{% if captain %}
<div style="margin-top:7px;font-size:10pt;">
<strong>Captain:</strong> {{ captain.name }}
{% if captain.license_number %}&nbsp;— License No. {{ captain.license_number }}{% endif %}
&nbsp;— Engaged directly by Charterer under separate Captain Services Agreement.
</div>
{% endif %}
</div>
<!-- 4. CHARTER FEE -->
<div class="section">
<div class="section-title">4. Charter Fee</div>
<table>
<tr><th style="width:65%;">Description</th><th style="width:35%;text-align:right;">Amount (USD)</th></tr>
{% if vessel and vessel.base_rate_4h %}
<tr>
<td>Base charter rate (4-hour minimum) — {{ vessel.name }}</td>
<td style="text-align:right;">${{ '%.2f' | format(vessel.base_rate_4h) }}</td>
</tr>
{% endif %}
{% if charter.hours and charter.hours > 4 and vessel and vessel.hourly_rate_extra %}
<tr>
<td>Additional hours: {{ '%.1f' | format(charter.hours - 4) }} hr &times; ${{ '%.2f' | format(vessel.hourly_rate_extra) }}/hr</td>
<td style="text-align:right;">${{ '%.2f' | format((charter.hours - 4) * vessel.hourly_rate_extra) }}</td>
</tr>
{% endif %}
{% if charter.damage_waiver and charter.damage_waiver > 0 %}
<tr>
<td>Damage Waiver (optional / non-refundable)</td>
<td style="text-align:right;">${{ '%.2f' | format(charter.damage_waiver) }}</td>
</tr>
{% endif %}
<tr class="total-row">
<td>TOTAL CHARTER FEE</td>
<td style="text-align:right;">${{ '%.2f' | format(charter.total_base_rate or 0) }}</td>
</tr>
</table>
<div style="font-size:9pt;color:#777;margin-top:5px;">All amounts in U.S. Dollars (USD). Payment due in full upon signing this Agreement. Captain's fees are separate and paid directly by Charterer to Captain.</div>
</div>
<!-- 5. INSURANCE -->
<div class="section">
<div class="section-title">5. Insurance Coverage</div>
<div class="insurance-box">
<table style="border:none;">
<tr>
<td style="border:none;padding:4px 6px;width:50%;">
<span style="font-size:9pt;color:#555;font-weight:bold;font-family:Arial,sans-serif;">Policy / Rider No.:</span><br>
<strong>{{ charter.insurance_rider_number if charter.insurance_rider_number else '_________________________' }}</strong>
</td>
<td style="border:none;padding:4px 6px;width:50%;">
<span style="font-size:9pt;color:#555;font-weight:bold;font-family:Arial,sans-serif;">Insurer:</span><br>
<strong>{{ charter.insurer_name if charter.insurer_name else '_________________________' }}</strong>
</td>
</tr>
<tr>
<td style="border:none;padding:4px 6px;">
<span style="font-size:9pt;color:#555;font-weight:bold;font-family:Arial,sans-serif;">Coverage Amount:</span><br>
<strong>{{ '$%,.2f' | format(charter.coverage_amount) if charter.coverage_amount else '_________________________' }}</strong>
</td>
<td style="border:none;padding:4px 6px;">
<span style="font-size:9pt;color:#555;font-weight:bold;font-family:Arial,sans-serif;">Coverage Period:</span><br>
<strong>
{{ charter.start_datetime.strftime('%B %d, %Y %I:%M %p') if charter.start_datetime else '____________' }}
&ndash;
{{ end_dt.strftime('%I:%M %p') if end_dt else '____________' }}
</strong>
</td>
</tr>
</table>
{% if not charter.insurance_rider_number %}
<div style="margin-top:8px;color:#c0392b;font-weight:bold;font-size:9.5pt;font-family:Arial,sans-serif;">
&#9888; INSURANCE RIDER PENDING — Vessel is NOT authorized to depart until a valid rider number is assigned.
</div>
{% endif %}
</div>
</div>
<!-- 6. TERMS & CONDITIONS -->
<div class="section">
<div class="section-title">6. Terms and Conditions</div>
<div class="clause">
<span class="clause-num">6.1 PRIVATE RECREATIONAL USE ONLY.</span>
This Agreement is strictly for private recreational use. The Charterer expressly agrees that the vessel shall not be used for any commercial purpose, for-hire passenger transport, commercial fishing, or any income-generating activity whatsoever. Any breach of this clause shall immediately void the insurance coverage and entitle the Owner's Agent to terminate this Agreement without refund.
</div>
<div class="clause">
<span class="clause-num">6.2 CAPTAIN.</span>
The captain providing services during this charter is engaged directly by the Charterer pursuant to a separate Captain Services Agreement. The management company and vessel owner are not parties to that agreement and assume no liability for the captain's actions or compensation.
</div>
<div class="clause">
<span class="clause-num">6.3 CHARTERER'S LIABILITY.</span>
The Charterer shall be responsible for all damage to the vessel, equipment, or third parties caused by the Charterer, guests, or any person aboard during the charter period, unless such damage results from normal wear and tear or a pre-existing condition documented prior to departure.
</div>
<div class="clause">
<span class="clause-num">6.4 PASSENGER CAPACITY AND PROHIBITED CONDUCT.</span>
The Charterer shall not exceed the vessel's maximum rated passenger capacity. Operation of the vessel while under the influence of alcohol or controlled substances, navigation outside agreed operational area, and overnight use without prior written consent are strictly prohibited.
</div>
<div class="clause">
<span class="clause-num">6.5 CANCELLATION.</span>
Cancellations made 72 hours or more before departure: full refund. Cancellations within 72 hours: 50% of the charter fee is forfeited. No-shows: full charter fee is forfeited. Cancellation due to unsafe weather conditions at captain's sole discretion: full credit toward a future booking.
</div>
<div class="clause">
<span class="clause-num">6.6 LIMITATION OF LIABILITY.</span>
The Owner's Agent's total liability under this Agreement shall not exceed the total charter fee paid. In no event shall the Owner's Agent be liable for any indirect, incidental, or consequential damages.
</div>
<div class="clause">
<span class="clause-num">6.7 GOVERNING LAW AND VENUE.</span>
This Agreement shall be governed by and construed in accordance with the laws of the State of Florida, United States. Any dispute arising under this Agreement shall be resolved by binding arbitration in Miami-Dade County, Florida, under the rules of the American Arbitration Association.
</div>
<div class="clause">
<span class="clause-num">6.8 ENTIRE AGREEMENT.</span>
This Agreement, together with the Insurance Rider and Captain Services Agreement (if applicable), constitutes the entire agreement between the parties and supersedes all prior negotiations or understandings.
</div>
</div>
<!-- 7. SIGNATURES -->
<div class="sig-area">
<div class="section-title">7. Signatures</div>
<div class="notice-box" style="font-size:10pt;">
By signing below, both parties confirm they have read, understood, and agree to all terms and conditions of this Private Vessel Charter Agreement.
</div>
<table style="border:none;margin-top:10px;">
<tr>
<td style="width:46%;border:none;vertical-align:bottom;padding:8px 5px;">
<div style="font-size:10pt;margin-bottom:8px;"><strong>OWNER'S AGENT</strong></div>
<div style="font-size:10pt;color:#333;margin-bottom:4px;">{{ management_company.name if management_company else 'Fleet Management LLC' }}</div>
<div style="font-size:9.5pt;color:#666;margin-bottom:40px;">Name: ___________________________</div>
<div class="sig-line">Signature &nbsp;&nbsp; Date: _______________</div>
</td>
<td style="width:8%;border:none;"></td>
<td style="width:46%;border:none;vertical-align:bottom;padding:8px 5px;">
<div style="font-size:10pt;margin-bottom:8px;"><strong>CHARTERER</strong></div>
<div style="font-size:10pt;color:#333;margin-bottom:4px;">{{ charter.charterer_name }}</div>
<div style="font-size:9.5pt;color:#666;margin-bottom:40px;">ID / Passport No.: _______________</div>
<div class="sig-line">Signature &nbsp;&nbsp; Date: _______________</div>
</td>
</tr>
</table>
</div>
<div class="footer">
Agreement No. CHR-{{ '%04d' | format(charter.id) }} &bull;
{{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '' }} &bull;
{{ management_company.name if management_company else 'Fleet Management LLC' }} &bull;
Governing Law: State of Florida
</div>
</body>
</html>
+210
View File
@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Insurance Rider Confirmation</title>
<style>
@page { size: letter; margin: 1.8cm 2.2cm; }
body { font-family: 'Times New Roman', Times, serif; font-size: 11pt; color: #1a1a1a; line-height: 1.55; }
.header-banner { background: #0a2a3a; color: white; padding: 16px 22px; }
.header-banner .title { font-size: 17pt; font-weight: bold; color: #c4a747; letter-spacing: 1px; font-family: Arial, sans-serif; }
.header-banner .sub { font-size: 9pt; color: #aac4d0; margin-top: 3px; font-family: Arial, sans-serif; }
.gold-bar { background: #c4a747; height: 5px; margin-bottom: 16px; }
.doc-title { text-align: center; font-size: 14pt; font-weight: bold; color: #0a2a3a; margin: 14px 0 4px; text-transform: uppercase; letter-spacing: 1.5px; font-family: Arial, sans-serif; }
.doc-ref { text-align: center; font-size: 9pt; color: #666; margin-bottom: 18px; font-family: Arial, sans-serif; }
.status-issued { display: block; text-align: center; background: #d4edda; color: #155724; border: 2px solid #28a745; border-radius: 4px; padding: 10px; font-size: 14pt; font-weight: bold; margin-bottom: 18px; font-family: Arial, sans-serif; letter-spacing: 1px; }
.status-pending { display: block; text-align: center; background: #fff3cd; color: #856404; border: 2px solid #ffc107; border-radius: 4px; padding: 10px; font-size: 14pt; font-weight: bold; margin-bottom: 18px; font-family: Arial, sans-serif; letter-spacing: 1px; }
.section { margin-bottom: 14px; }
.section-title { background: #0a2a3a; color: #c4a747; padding: 4px 10px; font-size: 9.5pt; font-weight: bold; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 8px; font-family: Arial, sans-serif; }
table { width: 100%; border-collapse: collapse; font-size: 10.5pt; }
th { background: #0a2a3a; color: white; padding: 5px 8px; text-align: left; font-size: 9.5pt; font-family: Arial, sans-serif; }
td { padding: 6px 8px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) td { background: #f9f9f9; }
.coverage-box { background: #eef6fc; border: 1px solid #b3d9ef; border-radius: 3px; padding: 14px 16px; }
.field-label { font-size: 9pt; color: #555; font-weight: bold; font-family: Arial, sans-serif; text-transform: uppercase; letter-spacing: 0.4px; display: block; margin-bottom: 3px; }
.field-value { font-size: 11.5pt; font-weight: bold; color: #0a2a3a; }
.field-blank { font-size: 11.5pt; color: #bbb; border-bottom: 1px solid #ccc; display: inline-block; min-width: 180px; }
.grid-2 { display: table; width: 100%; }
.grid-cell { display: table-cell; width: 50%; padding: 8px 10px; vertical-align: top; }
.instructions { background: #fff8e1; border-left: 4px solid #c4a747; padding: 11px 15px; margin-top: 12px; font-size: 10pt; }
.clearance-ok { background: #d4edda; border: 1.5px solid #28a745; border-radius: 3px; padding: 9px 14px; font-size: 10.5pt; color: #155724; font-weight: bold; margin-top: 12px; font-family: Arial, sans-serif; }
.clearance-no { background: #f8d7da; border: 1.5px solid #dc3545; border-radius: 3px; padding: 9px 14px; font-size: 10.5pt; color: #721c24; font-weight: bold; margin-top: 12px; font-family: Arial, sans-serif; }
.stamp-area { border: 2px dashed #ccc; border-radius: 6px; padding: 20px; text-align: center; color: #bbb; font-size: 10pt; margin-top: 14px; min-height: 80px; font-family: Arial, sans-serif; }
.footer { margin-top: 18px; border-top: 1px solid #ddd; padding-top: 7px; text-align: center; font-size: 8.5pt; color: #999; font-family: Arial, sans-serif; }
</style>
</head>
<body>
<div class="header-banner">
<div class="title">Insurance Rider — Private Charter</div>
<div class="sub">Temporary Named Insured Endorsement &bull; Private Recreational Vessel Use &bull; State of Florida</div>
</div>
<div class="gold-bar"></div>
<div class="doc-title">Insurance Rider Confirmation</div>
<div class="doc-ref">
Charter Ref. No. CHR-{{ '%04d' | format(charter.id) }} &nbsp;&bull;&nbsp;
Vessel: <strong>{{ vessel.name if vessel else 'N/A' }}</strong> &nbsp;&bull;&nbsp;
Date: {{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '________________' }}
</div>
{% if charter.insurance_rider_number %}
<div class="status-issued">&#10003; RIDER ISSUED &mdash; VESSEL CLEARED FOR DEPARTURE</div>
{% else %}
<div class="status-pending">&#9888; PENDING ISSUANCE &mdash; VESSEL NOT AUTHORIZED TO DEPART</div>
{% endif %}
<!-- 1. NAMED INSURED -->
<div class="section">
<div class="section-title">1. Named Insured (Temporary)</div>
<div class="coverage-box">
<div class="grid-2">
<div class="grid-cell">
<span class="field-label">Full Name</span>
<span class="field-value">{{ charter.charterer_name }}</span>
</div>
<div class="grid-cell">
<span class="field-label">Contact</span>
<span class="field-value">{{ charter.charterer_email or charter.charterer_phone or '—' }}</span>
</div>
</div>
</div>
</div>
<!-- 2. VESSEL -->
<div class="section">
<div class="section-title">2. Insured Vessel</div>
<table>
<tr><th>Vessel Name</th><th>HIN / USCG Doc.</th><th>Make / Model</th><th>LOA</th><th>Engines</th></tr>
<tr>
<td><strong>{{ vessel.name if vessel else '—' }}</strong></td>
<td>{{ vessel.hin if vessel and vessel.hin else '_______________' }}</td>
<td>{{ ((vessel.make or '') ~ ' ' ~ (vessel.model or '')) | trim if vessel else '—' }}</td>
<td>{{ (vessel.length | string ~ ' ft') if vessel and vessel.length else '—' }}</td>
<td>{{ vessel.engines if vessel and vessel.engines else '—' }}</td>
</tr>
</table>
</div>
<!-- 3. COVERAGE -->
<div class="section">
<div class="section-title">3. Coverage Details</div>
<div class="coverage-box">
<div class="grid-2">
<div class="grid-cell">
<span class="field-label">Rider / Endorsement No.</span>
{% if charter.insurance_rider_number %}
<span class="field-value">{{ charter.insurance_rider_number }}</span>
{% else %}
<span class="field-blank">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
{% endif %}
</div>
<div class="grid-cell">
<span class="field-label">Insurer</span>
{% if charter.insurer_name %}
<span class="field-value">{{ charter.insurer_name }}</span>
{% else %}
<span class="field-blank">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
{% endif %}
</div>
</div>
<div class="grid-2" style="margin-top:10px;">
<div class="grid-cell">
<span class="field-label">Coverage Amount</span>
{% if charter.coverage_amount %}
<span class="field-value">${{ '%,.2f' | format(charter.coverage_amount) }} USD</span>
{% else %}
<span class="field-blank">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
{% endif %}
</div>
<div class="grid-cell">
<span class="field-label">Coverage Type</span>
<span class="field-value">Liability — Private Recreational Use Only</span>
</div>
</div>
<div class="grid-2" style="margin-top:10px;">
<div class="grid-cell">
<span class="field-label">Coverage Start</span>
<span class="field-value">{{ charter.start_datetime.strftime('%B %d, %Y %I:%M %p') if charter.start_datetime else '___________________________' }}</span>
</div>
<div class="grid-cell">
<span class="field-label">Coverage End</span>
<span class="field-value">{{ end_dt.strftime('%B %d, %Y %I:%M %p') if end_dt else '___________________________' }}</span>
</div>
</div>
{% if charter.damage_waiver and charter.damage_waiver > 0 %}
<div style="margin-top:10px;">
<span class="field-label">Damage Waiver (paid by Charterer)</span>
<span class="field-value">${{ '%.2f' | format(charter.damage_waiver) }} USD</span>
</div>
{% endif %}
</div>
{% if charter.insurance_rider_number %}
<div class="clearance-ok">&#9989; DEPARTURE CLEARANCE GRANTED &mdash; Coverage active {{ charter.start_datetime.strftime('%I:%M %p') if charter.start_datetime else '—' }} to {{ end_dt.strftime('%I:%M %p') if end_dt else '—' }}</div>
{% else %}
<div class="clearance-no">&#128683; NO CLEARANCE &mdash; Vessel may NOT depart until this rider is issued and the rider number is recorded.</div>
{% endif %}
</div>
<!-- 4. SCOPE -->
<div class="section">
<div class="section-title">4. Scope and Exclusions</div>
<table>
<tr><th style="width:50%;">Covered</th><th style="width:50%;">Excluded</th></tr>
<tr>
<td>Third-party bodily injury and property damage liability during the coverage period</td>
<td>Commercial use, for-hire transport, or any income-generating activity</td>
</tr>
<tr>
<td>Passenger liability for the named insured's guests aboard during the coverage period</td>
<td>Operation under the influence of alcohol or controlled substances</td>
</tr>
<tr>
<td>Navigation within agreed Florida coastal / intracoastal operational area</td>
<td>Navigation outside approved area or overnight use without prior written consent</td>
</tr>
<tr>
<td>Accidental vessel damage if Damage Waiver was purchased</td>
<td>Intentional damage, gross negligence, or willful misconduct</td>
</tr>
</table>
</div>
{% if not charter.insurance_rider_number %}
<div class="instructions">
<strong>TO ISSUE THIS RIDER:</strong> Contact your marine insurance broker with the vessel and charterer details above.<br>
Recommended Florida marine insurers: <strong>Markel Marine</strong> &bull; <strong>BoatUS / GEICO Marine</strong> &bull; <strong>Progressive Marine</strong> &bull; <strong>Pantaenius America</strong><br>
Most brokers issue short-term hourly riders via online portal or email within minutes.
Once issued, enter the Rider No. in the charter record to grant departure clearance.
</div>
{% endif %}
<!-- 5. INSURER CONFIRMATION -->
<div class="section" style="margin-top:16px;">
<div class="section-title">5. Insurer Confirmation</div>
<table style="border:none;">
<tr>
<td style="border:none;vertical-align:bottom;padding:8px 5px;width:55%;">
<div style="font-size:10pt;margin-bottom:4px;"><strong>{{ charter.insurer_name or 'Insurer' }}</strong></div>
<div style="font-size:9.5pt;color:#666;margin-bottom:4px;">Authorized Representative: ___________________________</div>
<div style="font-size:9.5pt;color:#666;margin-bottom:40px;">Date of Issuance: ___________________________</div>
<div style="border-top:1px solid #333;padding-top:5px;font-size:9pt;color:#555;font-family:Arial,sans-serif;">Authorized Signature</div>
</td>
<td style="border:none;padding:8px 5px;width:45%;vertical-align:top;">
<div class="stamp-area">Insurer Stamp / Seal</div>
</td>
</tr>
</table>
</div>
<div class="footer">
Insurance Rider &bull; Charter Ref. CHR-{{ '%04d' | format(charter.id) }} &bull;
Vessel: {{ vessel.name if vessel else 'N/A' }} &bull;
{{ charter.start_datetime.strftime('%B %d, %Y %I:%M %p') if charter.start_datetime else '' }}
&ndash; {{ end_dt.strftime('%I:%M %p') if end_dt else '' }} &bull;
Private Recreational Use Only &bull; State of Florida
</div>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
from app import create_app, db
from app.models import User, Company
from werkzeug.security import generate_password_hash
app = create_app()
with app.app_context():
# Verificar si ya existe la compañía
company = Company.query.filter_by(email='admin@fleet.com').first()
if not company:
company = Company(name='Al & Al Management LLC', type='management', email='admin@fleet.com')
db.session.add(company)
db.session.commit()
print("Compañía creada")
else:
print("Compañía ya existe")
# Verificar si ya existe el usuario
user = User.query.filter_by(email='admin@fleet.com').first()
if not user:
user = User(
name='Administrador',
email='admin@fleet.com',
password_hash=generate_password_hash('admin123'),
company_id=company.id,
role='admin'
)
db.session.add(user)
db.session.commit()
print("Usuario admin creado: admin@fleet.com / admin123")
else:
print("Usuario admin ya existe: admin@fleet.com / admin123")
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "C:\fleet-management"
C:\Python313\python.exe run.py
+16
View File
@@ -0,0 +1,16 @@
@echo off
cd /d "%~dp0"
call venv\Scripts\activate.bat
echo ========================================
echo FLEET MANAGEMENT SYSTEM
echo http://localhost:5010
echo Ctrl+C para detener
echo ========================================
echo.
:: Abrir browser despues de 2 segundos
start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:5010"
python run.py
pause
+34
View File
@@ -0,0 +1,34 @@
# iniciar.ps1 - Script para cargar .env y ejecutar run.py
Write-Host "Cargando variables de entorno desde .env..." -ForegroundColor Green
# Verificar si existe .env
if (-not (Test-Path ".env")) {
Write-Host "ERROR: No se encuentra el archivo .env" -ForegroundColor Red
Read-Host "Presiona Enter para salir"
exit 1
}
# Verificar si existe run.py
if (-not (Test-Path "run.py")) {
Write-Host "ERROR: No se encuentra el archivo run.py" -ForegroundColor Red
Read-Host "Presiona Enter para salir"
exit 1
}
# Cargar variables del .env
Get-Content .env | ForEach-Object {
if ( -match '^([^=]+)=(.*)$') {
$nombre = $matches[1].Trim()
$valor = $matches[2].Trim().Trim('"')
[Environment]::SetEnvironmentVariable($nombre, $valor, 'Process')
Write-Host "Cargado: $nombre = $valor" -ForegroundColor Yellow
}
}
Write-Host "
Ejecutando run.py..." -ForegroundColor Green
python run.py
Write-Host "
Presiona Enter para salir..." -ForegroundColor Gray
Read-Host
BIN
View File
Binary file not shown.
+596
View File
@@ -0,0 +1,596 @@
"""
seed_data.py Datos de demo completos para Fleet Management
Ejecutar: python seed_data.py
Cuentas creadas:
Admin: admin@fleet.com / admin123
Owner 1: rmitchell@email.com / owner123 (Robert Mitchell - 2 botes)
Owner 2: msantos@email.com / owner123 (Maria Santos - 2 botes)
Owner 3: cvega@email.com / owner123 (Carlos Vega - 1 bote)
Owner 4: jwilliams@email.com / owner123 (Jennifer Williams - 1 bote)
"""
from app import create_app, db
from app.models import Company, User, Vessel, Captain, Charter, WorkOrder, Voucher, AccountingEntry, FuelEntry
from werkzeug.security import generate_password_hash
from datetime import date
from datetime import datetime, timedelta
import random
app = create_app()
def get_or_create(model, defaults=None, **kwargs):
instance = model.query.filter_by(**kwargs).first()
if instance:
return instance, False
params = {**kwargs, **(defaults or {})}
instance = model(**params)
db.session.add(instance)
db.session.flush()
return instance, True
with app.app_context():
print("=" * 55)
print(" Fleet Management — Cargando datos de demo")
print("=" * 55)
# ============================================================
# MANAGEMENT COMPANY + ADMIN USER
# ============================================================
mgmt, _ = get_or_create(Company,
defaults={'type': 'management', 'phone': '305-900-0001'},
name='Al & Al Management LLC', email='admin@fleet.com')
admin_user, created = get_or_create(User,
defaults={
'name': 'Admin Fleet',
'password_hash': generate_password_hash('admin123'),
'company_id': mgmt.id,
'role': 'admin',
'is_active': True,
'is_super_admin': True
},
email='admin@fleet.com')
# Ensure existing admin is marked as super_admin
if not admin_user.is_super_admin:
admin_user.is_super_admin = True
if created:
print(" [+] Admin creado: admin@fleet.com / admin123")
else:
print(" [=] Admin ya existe")
db.session.commit()
# ============================================================
# OWNERS (companies + portal users)
# ============================================================
owners_data = [
{
'name': 'Robert Mitchell',
'email': 'rmitchell@email.com',
'phone': '305-555-2001',
},
{
'name': 'Maria Santos',
'email': 'msantos@email.com',
'phone': '305-555-2002',
},
{
'name': 'Carlos Vega',
'email': 'cvega@email.com',
'phone': '786-555-2003',
},
{
'name': 'Jennifer Williams',
'email': 'jwilliams@email.com',
'phone': '786-555-2004',
},
]
owner_companies = {}
for od in owners_data:
company, created = get_or_create(Company,
defaults={'type': 'owner', 'phone': od['phone']},
name=od['name'], email=od['email'])
owner_companies[od['name']] = company
user, ucreated = get_or_create(User,
defaults={
'name': od['name'],
'password_hash': generate_password_hash('owner123'),
'company_id': company.id,
'role': 'owner',
'is_active': True
},
email=od['email'])
if ucreated:
print(f" [+] Owner: {od['email']} / owner123")
else:
print(f" [=] Owner ya existe: {od['email']}")
db.session.commit()
# ============================================================
# CAPTAINS
# ============================================================
captains_data = [
{'name': 'Carlos Pérez', 'phone': '305-555-5001', 'license_number': 'USCG-REC-12345', 'hourly_rate': 55},
{'name': 'Miguel Torres', 'phone': '305-555-5002', 'license_number': 'USCG-REC-67890', 'hourly_rate': 60},
{'name': 'Roberto Díaz', 'phone': '786-555-5003', 'license_number': 'USCG-REC-11223', 'hourly_rate': 50},
]
captains = {}
for cd in captains_data:
cap, created = get_or_create(Captain,
defaults={
'phone': cd['phone'],
'license_number': cd['license_number'],
'hourly_rate': cd['hourly_rate'],
'company_id': mgmt.id
},
name=cd['name'])
captains[cd['name']] = cap
if created:
print(f" [+] Capitán: {cd['name']}")
db.session.commit()
# ============================================================
# VESSELS (6 embarcaciones)
# ============================================================
# plan_id: 1=Básico $199, 2=Estándar $399, 3=Mantenimiento $299, 4=Plus $599
vessels_data = [
{
'name': 'Blue Horizon',
'make': 'Sea Ray',
'model': '430 Sundancer',
'engines': '2 x Mercruiser 496 MAG',
'length': 43,
'fuel_consumption_14knots': 65,
'base_rate_4h': 1200,
'hourly_rate_extra': 300,
'charter_percentage': 25,
'plan_id': 4,
'owner': 'Robert Mitchell',
'hin': 'SRAY43001A222'
},
{
'name': 'Sol y Mar',
'make': 'Azimut',
'model': '38 Flybridge',
'engines': '2 x Volvo Penta D6-370',
'length': 38,
'fuel_consumption_14knots': 55,
'base_rate_4h': 950,
'hourly_rate_extra': 250,
'charter_percentage': 25,
'plan_id': 2,
'owner': 'Robert Mitchell',
'hin': 'AZMT38002B222'
},
{
'name': 'La Perla',
'make': 'Contender',
'model': '32 ST',
'engines': '2 x Yamaha F350',
'length': 32,
'fuel_consumption_14knots': 42,
'base_rate_4h': 750,
'hourly_rate_extra': 190,
'charter_percentage': 25,
'plan_id': 2,
'owner': 'Maria Santos',
'hin': 'CONT32003C222'
},
{
'name': 'Brisa Marina',
'make': 'Grady-White',
'model': 'Freedom 285',
'engines': '2 x Yamaha F150',
'length': 28,
'fuel_consumption_14knots': 30,
'base_rate_4h': 550,
'hourly_rate_extra': 140,
'charter_percentage': 25,
'plan_id': 1,
'owner': 'Maria Santos',
'hin': 'GRDY28004D222'
},
{
'name': 'Veloce',
'make': 'Scarab',
'model': '35 Sport',
'engines': '2 x Mercury Verado 400R',
'length': 35,
'fuel_consumption_14knots': 50,
'base_rate_4h': 850,
'hourly_rate_extra': 220,
'charter_percentage': 25,
'plan_id': 1,
'owner': 'Carlos Vega',
'hin': 'SCRB35005E222'
},
{
'name': 'Lady J',
'make': 'Sea Ray',
'model': '480 Sundancer',
'engines': '2 x Zeus Pod 600',
'length': 48,
'fuel_consumption_14knots': 75,
'base_rate_4h': 1500,
'hourly_rate_extra': 375,
'charter_percentage': 25,
'plan_id': 4,
'owner': 'Jennifer Williams',
'hin': 'SRAY48006F222'
},
]
vessels = {}
for vd in vessels_data:
owner_co = owner_companies[vd['owner']]
vessel, created = get_or_create(Vessel,
defaults={
'make': vd['make'],
'model': vd['model'],
'engines': vd['engines'],
'length': vd['length'],
'fuel_consumption_14knots': vd['fuel_consumption_14knots'],
'base_rate_4h': vd['base_rate_4h'],
'hourly_rate_extra': vd['hourly_rate_extra'],
'charter_percentage': vd['charter_percentage'],
'plan_id': vd['plan_id'],
'owner_company_id': owner_co.id,
'management_company_id': mgmt.id,
'hin': vd['hin'],
'is_active': True
},
name=vd['name'])
vessels[vd['name']] = vessel
if created:
print(f" [+] Bote: {vd['name']} ({vd['make']} {vd['model']}) — {vd['owner']}")
db.session.commit()
# ============================================================
# CHARTERS
# ============================================================
# Clientes reales con datos
charterers = [
{'name': 'Andrew Lawson', 'phone': '305-600-1001', 'email': 'alawson@gmail.com'},
{'name': 'Sophia Chen', 'phone': '305-600-1002', 'email': 'sophia.chen@mail.com'},
{'name': 'Marcus Johnson', 'phone': '786-600-1003', 'email': 'mjohnson@corp.com'},
{'name': 'Isabella Gomez', 'phone': '305-600-1004', 'email': 'igomez@gmail.com'},
{'name': 'David Park', 'phone': '786-600-1005', 'email': 'dpark@hotmail.com'},
{'name': 'Nicole Fontaine', 'phone': '305-600-1006', 'email': 'nfontaine@gmail.com'},
{'name': 'Tyler Brooks', 'phone': '786-600-1007', 'email': 'tbrooks@corp.com'},
{'name': 'Valentina Cruz', 'phone': '305-600-1008', 'email': 'vcruz@gmail.com'},
{'name': 'James Whitfield', 'phone': '305-600-1009', 'email': 'jwhitfield@mail.com'},
{'name': 'Camila Restrepo', 'phone': '786-600-1010', 'email': 'crestrepo@gmail.com'},
]
def calc_charter(vessel, hours):
base = vessel.base_rate_4h
extra = vessel.hourly_rate_extra
total = base if hours <= 4 else base + (hours - 4) * extra
pct = vessel.charter_percentage
mgmt_earn = round(total * pct / 100, 2)
owner_earn = round(total - mgmt_earn, 2)
return round(total, 2), mgmt_earn, owner_earn
now = datetime.utcnow()
charters_def = [
# Blue Horizon — 4 charters completados, 1 próximo
{'vessel': 'Blue Horizon', 'days_ago': 45, 'hours': 4, 'charterer': 0, 'status': 'completed'},
{'vessel': 'Blue Horizon', 'days_ago': 32, 'hours': 6, 'charterer': 3, 'status': 'completed'},
{'vessel': 'Blue Horizon', 'days_ago': 18, 'hours': 8, 'charterer': 6, 'status': 'completed'},
{'vessel': 'Blue Horizon', 'days_ago': 7, 'hours': 4, 'charterer': 9, 'status': 'completed'},
{'vessel': 'Blue Horizon', 'days_ago': -5, 'hours': 6, 'charterer': 1, 'status': 'signed'},
# Sol y Mar — 3 completados
{'vessel': 'Sol y Mar', 'days_ago': 38, 'hours': 5, 'charterer': 2, 'status': 'completed'},
{'vessel': 'Sol y Mar', 'days_ago': 21, 'hours': 4, 'charterer': 5, 'status': 'completed'},
{'vessel': 'Sol y Mar', 'days_ago': 10, 'hours': 6, 'charterer': 8, 'status': 'completed'},
# La Perla — 3 completados, 1 draft
{'vessel': 'La Perla', 'days_ago': 50, 'hours': 4, 'charterer': 1, 'status': 'completed'},
{'vessel': 'La Perla', 'days_ago': 29, 'hours': 4, 'charterer': 4, 'status': 'completed'},
{'vessel': 'La Perla', 'days_ago': 14, 'hours': 5, 'charterer': 7, 'status': 'completed'},
{'vessel': 'La Perla', 'days_ago': -3, 'hours': 4, 'charterer': 0, 'status': 'draft'},
# Brisa Marina — 2 completados
{'vessel': 'Brisa Marina', 'days_ago': 42, 'hours': 4, 'charterer': 3, 'status': 'completed'},
{'vessel': 'Brisa Marina', 'days_ago': 15, 'hours': 4, 'charterer': 6, 'status': 'completed'},
# Veloce — 3 completados, 1 signed
{'vessel': 'Veloce', 'days_ago': 55, 'hours': 4, 'charterer': 5, 'status': 'completed'},
{'vessel': 'Veloce', 'days_ago': 35, 'hours': 6, 'charterer': 2, 'status': 'completed'},
{'vessel': 'Veloce', 'days_ago': 12, 'hours': 4, 'charterer': 9, 'status': 'completed'},
{'vessel': 'Veloce', 'days_ago': -7, 'hours': 5, 'charterer': 4, 'status': 'signed'},
# Lady J — 4 completados, 1 próximo (el más caro)
{'vessel': 'Lady J', 'days_ago': 60, 'hours': 8, 'charterer': 7, 'status': 'completed'},
{'vessel': 'Lady J', 'days_ago': 40, 'hours': 6, 'charterer': 0, 'status': 'completed'},
{'vessel': 'Lady J', 'days_ago': 22, 'hours': 4, 'charterer': 3, 'status': 'completed'},
{'vessel': 'Lady J', 'days_ago': 8, 'hours': 10, 'charterer': 8, 'status': 'completed'},
{'vessel': 'Lady J', 'days_ago': -10,'hours': 6, 'charterer': 1, 'status': 'signed'},
]
charters_created = 0
vouchers_created = 0
for cd in charters_def:
vessel = vessels[cd['vessel']]
ch_data = charterers[cd['charterer']]
start_dt = now - timedelta(days=cd['days_ago'], hours=10)
total, mgmt_earn, owner_earn = calc_charter(vessel, cd['hours'])
existing = Charter.query.filter_by(
vessel_id=vessel.id,
charterer_name=ch_data['name'],
hours=cd['hours']
).filter(
Charter.start_datetime >= start_dt - timedelta(hours=2),
Charter.start_datetime <= start_dt + timedelta(hours=2)
).first()
if existing:
continue
charter = Charter(
vessel_id=vessel.id,
charterer_name=ch_data['name'],
charterer_phone=ch_data['phone'],
charterer_email=ch_data['email'],
start_datetime=start_dt,
hours=cd['hours'],
total_base_rate=total,
management_percentage=vessel.charter_percentage,
management_earnings=mgmt_earn,
owner_earnings=owner_earn,
status=cd['status'],
completed_at=start_dt + timedelta(hours=cd['hours']) if cd['status'] == 'completed' else None
)
db.session.add(charter)
db.session.flush()
charters_created += 1
# Generar voucher para charters completados
if cd['status'] == 'completed':
fuel = round(vessel.fuel_consumption_14knots * cd['hours'] * random.uniform(0.8, 1.0))
tip_pct = random.choice([15, 18, 20])
voucher = Voucher(
charter_id=charter.id,
total_charged=total,
fuel_actual_liters=fuel,
deviation_charged=0,
tip_amount=round(total * tip_pct / 100, 2),
tip_percentage=tip_pct,
issued_at=charter.completed_at,
paid_at=charter.completed_at + timedelta(days=1)
)
db.session.add(voucher)
vouchers_created += 1
db.session.commit()
print(f"\n [+] Charters creados: {charters_created} ({vouchers_created} con voucher)")
# ============================================================
# WORK ORDERS
# ============================================================
wo_data = [
{
'vessel': 'Blue Horizon',
'description': 'Cambio de aceite y filtros motores - servicio 200h',
'estimated_cost': 850, 'actual_cost': 820,
'status': 'done', 'priority': 'normal', 'days_ago': 40
},
{
'vessel': 'Blue Horizon',
'description': 'Revisión sistema de dirección hidráulica - pérdida de fluido detectada',
'estimated_cost': 450, 'actual_cost': None,
'status': 'approved', 'priority': 'urgente', 'days_ago': 5
},
{
'vessel': 'Sol y Mar',
'description': 'Limpieza de teca y aplicación de teak oil',
'estimated_cost': 600, 'actual_cost': 580,
'status': 'done', 'priority': 'normal', 'days_ago': 25
},
{
'vessel': 'Sol y Mar',
'description': 'Actualización software GPS Garmin y radar',
'estimated_cost': 200, 'actual_cost': None,
'status': 'pending', 'priority': 'normal', 'days_ago': 3
},
{
'vessel': 'La Perla',
'description': 'Reemplazo baterías principales (banco 4 x AGM)',
'estimated_cost': 1200, 'actual_cost': 1150,
'status': 'done', 'priority': 'normal', 'days_ago': 30
},
{
'vessel': 'La Perla',
'description': 'Pintura fondo antifouling - temporada anual',
'estimated_cost': 1800, 'actual_cost': None,
'status': 'pending', 'priority': 'normal', 'days_ago': 2
},
{
'vessel': 'Brisa Marina',
'description': 'Cambio impellers bombas de agua dulce y salada',
'estimated_cost': 320, 'actual_cost': 310,
'status': 'done', 'priority': 'normal', 'days_ago': 20
},
{
'vessel': 'Veloce',
'description': 'Servicio anual motores Mercury Verado (aceite, filtros, bujías)',
'estimated_cost': 1400, 'actual_cost': 1380,
'status': 'done', 'priority': 'normal', 'days_ago': 35
},
{
'vessel': 'Veloce',
'description': 'Falla en sistema eléctrico principal — luces de navegación inoperativas. BOTE NO PUEDE SALIR A CHARTER.',
'estimated_cost': 1100, 'actual_cost': None,
'status': 'pending', 'priority': 'emergencia', 'days_ago': 1
},
{
'vessel': 'Lady J',
'description': 'Detailing completo exterior e interior - plan Plus anual',
'estimated_cost': 2200, 'actual_cost': 2200,
'status': 'done', 'priority': 'normal', 'days_ago': 50
},
{
'vessel': 'Lady J',
'description': 'Revisión y ajuste sistema Zeus Pod - vibración anormal a más de 12 nudos',
'estimated_cost': 950, 'actual_cost': None,
'status': 'pending', 'priority': 'urgente', 'days_ago': 1
},
]
wo_created = 0
for wd in wo_data:
vessel = vessels[wd['vessel']]
existing = WorkOrder.query.filter_by(
vessel_id=vessel.id,
description=wd['description']
).first()
if existing:
continue
created_date = now - timedelta(days=wd['days_ago'])
wo = WorkOrder(
vessel_id=vessel.id,
requested_by_company_id=mgmt.id,
description=wd['description'],
estimated_cost=wd['estimated_cost'],
actual_cost=wd['actual_cost'],
status=wd['status'],
priority=wd.get('priority', 'normal'),
created_at=created_date,
completed_at=created_date + timedelta(days=3) if wd['status'] == 'done' else None,
approved_by_owner_id=1 if wd['status'] in ('approved', 'done') else None
)
db.session.add(wo)
wo_created += 1
db.session.commit()
print(f" [+] Work Orders creadas: {wo_created}")
# ============================================================
# ACCOUNTING ENTRIES (desde charters y WOs existentes)
# ============================================================
acc_created = 0
# Income: charters completados → 75% va al owner
all_charters = Charter.query.filter_by(status='completed').all()
for ch in all_charters:
exists = AccountingEntry.query.filter_by(
vessel_id=ch.vessel_id, reference_type='charter', reference_id=ch.id
).first()
if exists:
continue
if not ch.owner_earnings:
continue
entry_date = ch.completed_at.date() if ch.completed_at else (
ch.start_datetime.date() if ch.start_datetime else date.today())
db.session.add(AccountingEntry(
vessel_id=ch.vessel_id,
date=entry_date,
entry_type='income',
category='charter',
description=f'Charter {ch.charterer_name} {ch.hours}h',
amount=round(ch.owner_earnings, 2),
reference_type='charter',
reference_id=ch.id,
invoice_number=f'CHR-{ch.id:04d}'
))
acc_created += 1
# Expense: WOs completadas
all_wos = WorkOrder.query.filter_by(status='done').all()
for wo in all_wos:
exists = AccountingEntry.query.filter_by(
vessel_id=wo.vessel_id, reference_type='work_order', reference_id=wo.id
).first()
if exists:
continue
cost = wo.actual_cost or wo.estimated_cost or 0
if cost <= 0:
continue
entry_date = wo.completed_at.date() if wo.completed_at else date.today()
db.session.add(AccountingEntry(
vessel_id=wo.vessel_id,
date=entry_date,
entry_type='expense',
category='work_order',
description=f'Mantenimiento {wo.description[:80]}',
amount=round(cost, 2),
reference_type='work_order',
reference_id=wo.id,
invoice_number=f'WO-{wo.id:04d}'
))
acc_created += 1
# Expense: combustible de vouchers (si tiene fuel_actual_liters)
fuel_price = 1.45 # $/litro aprox
all_vouchers = Voucher.query.filter(Voucher.fuel_actual_liters > 0).all()
for v in all_vouchers:
ch = Charter.query.get(v.charter_id)
if not ch:
continue
exists = AccountingEntry.query.filter_by(
vessel_id=ch.vessel_id, reference_type='fuel_entry',
reference_id=v.id
).first()
if exists:
continue
fuel_cost = round(v.fuel_actual_liters * fuel_price, 2)
entry_date = v.issued_at.date() if v.issued_at else date.today()
# FuelEntry record
fuel_rec = FuelEntry(
vessel_id=ch.vessel_id,
charter_id=ch.id,
date=entry_date,
liters=v.fuel_actual_liters,
price_per_liter=fuel_price,
total_cost=fuel_cost,
supplier='Marina Fuel Station',
invoice_number=f'FUEL-V{v.id:04d}'
)
db.session.add(fuel_rec)
db.session.flush()
db.session.add(AccountingEntry(
vessel_id=ch.vessel_id,
date=entry_date,
entry_type='expense',
category='fuel',
description=f'Combustible {v.fuel_actual_liters:.0f}L charter {ch.charterer_name}',
amount=fuel_cost,
reference_type='fuel_entry',
reference_id=fuel_rec.id,
invoice_number=f'FUEL-V{v.id:04d}'
))
acc_created += 1
db.session.commit()
print(f" [+] Asientos contables generados: {acc_created}")
# ============================================================
# RESUMEN
# ============================================================
print()
print("=" * 55)
print(" DATOS CARGADOS EXITOSAMENTE")
print("=" * 55)
print()
print(" ACCESOS:")
print(" Admin: admin@fleet.com / admin123")
print(" Owner 1: rmitchell@email.com / owner123")
print(" Owner 2: msantos@email.com / owner123")
print(" Owner 3: cvega@email.com / owner123")
print(" Owner 4: jwilliams@email.com / owner123")
print()
print(" DATOS:")
print(f" - {len(owner_companies)} duenos | 6 embarcaciones | 3 capitanes")
print(f" - {Charter.query.count()} charters | {WorkOrder.query.count()} work orders | {Voucher.query.count()} vouchers")
print(f" - {AccountingEntry.query.count()} asientos contables | {FuelEntry.query.count()} registros de combustible")
print()
print(" URL: http://localhost:5010")
print("=" * 55)