Initial commit — MarineInvoice v1.0

Multi-tenant marine invoicing system: Stripe payments, PDF generation,
digital signatures, QR codes, SMTP email, bilingual templates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 01:54:08 -04:00
commit 35d460b127
30 changed files with 6943 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# === Base de datos ===
instance/marineinvoice.db
instance/*.db
# === Archivos generados ===
static/pdfs/
static/logos/
# === Secrets ===
.env
*.env
secrets.py
# === Python ===
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
*.egg
# === Entorno virtual ===
venv/
env/
.venv/
# === Herramientas externas ===
ngrok.exe
ngrok_Inicio.bat
*.zip
# === IDE / OS ===
.vscode/
.idea/
.continue/
*.swp
*.swo
.DS_Store
Thumbs.db
+23
View File
@@ -0,0 +1,23 @@
@echo off
title MarineInvoice Pro
color 0A
echo.
echo ================================================
echo MarineInvoice Pro - Servidor de Facturacion
echo ================================================
echo.
echo Iniciando servidor...
echo.
echo Acceso LOCAL: http://localhost:5000
echo Acceso TAILSCALE: http://100.96.43.86:5000
echo.
echo Usuario inicial: admin
echo Contrasena: admin123
echo (Cambiala despues del primer login!)
echo.
echo Para detener el servidor: Ctrl+C
echo ================================================
echo.
cd /d "%~dp0"
C:\Users\aerom\AppData\Local\Python\pythoncore-3.14-64\python.exe app.py
pause
+47
View File
@@ -0,0 +1,47 @@
# MarineInvoice Pro — Instrucciones
## Estructura de carpetas
marineinvoice/
├── app.py
├── INICIAR_SERVIDOR.bat
├── templates/
│ ├── base.html, login.html, dashboard.html
│ ├── companies.html, company_form.html
│ ├── clients.html, products.html
│ ├── documents.html ← invoices Y cotizaciones
│ └── users.html
└── static/
├── logos/ ← logos de compañías
└── pdfs/ ← PDFs guardados por compañía
## Instalación
1. Crea carpeta C:\MarineInvoice\
2. Copia app.py e INICIAR_SERVIDOR.bat a C:\MarineInvoice\
3. Crea C:\MarineInvoice\templates\ y copia todos los .html
4. Crea C:\MarineInvoice\static\logos\ y C:\MarineInvoice\static\pdfs\
5. Doble clic en INICIAR_SERVIDOR.bat
## Acceso
- Local: http://localhost:5000
- Tailscale (remoto): http://100.96.43.86:5000
- Login inicial: admin / admin123
## Roles
- superadmin → todo
- admin → solo su compañía asignada
- user → solo su compañía asignada
## Numeración automática
Formato: PREFIJO-001-MMAAAA (configurable al crear la compañía)
- Invoice: IPY-001-032026
- Cotización: QPY-001-032026
- Contador reinicia cada mes
- Ajuste manual NO altera el contador interno
## PDFs
- Se guardan en static/pdfs/[company_id]/
- Usuarios pueden ver, descargar y enviar por email
- El email usa SMTP configurado en la compañía
## Arranque automático
Win+R → shell:startup → acceso directo de INICIAR_SERVIDOR.bat
+896
View File
@@ -0,0 +1,896 @@
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_file, abort
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
from datetime import datetime
import os, json, smtplib, re, base64
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from email import encoders
app = Flask(__name__)
app.config['SECRET_KEY'] = 'marineinvoice-secret-key-2024'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///marineinvoice.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['UPLOAD_FOLDER'] = 'static/logos'
app.config['PDF_FOLDER'] = 'static/pdfs'
os.makedirs('static/logos', exist_ok=True)
os.makedirs('static/pdfs', exist_ok=True)
db = SQLAlchemy(app)
login_manager = LoginManager(app)
login_manager.login_view = 'login'
login_manager.login_message = 'Por favor inicia sesión para continuar.'
# ============================================================
# MODELS
# ============================================================
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(20), default='user') # superadmin, admin, user
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=True)
full_name = db.Column(db.String(120))
signature = db.Column(db.Text) # base64 PNG firma del usuario
smtp_user = db.Column(db.String(120)) # email corporativo
smtp_password = db.Column(db.String(200)) # contraseña del email corporativo
email_title = db.Column(db.String(200)) # Título/cargo que aparece en el From del email
active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def set_password(self, pw): self.password_hash = generate_password_hash(pw)
def check_password(self, pw): return check_password_hash(self.password_hash, pw)
def is_superadmin(self): return self.role == 'superadmin'
def can_access_company(self, company_id):
if self.role == 'superadmin': return True
return self.company_id == int(company_id)
class Company(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
ein = db.Column(db.String(20))
license_num = db.Column(db.String(50))
phone = db.Column(db.String(30))
address = db.Column(db.String(200))
city = db.Column(db.String(80))
state = db.Column(db.String(30))
email = db.Column(db.String(120))
website = db.Column(db.String(120))
manager = db.Column(db.String(120))
authorized = db.Column(db.String(120))
tax_rate = db.Column(db.Float, default=7.0)
notes = db.Column(db.Text) # quote notes (backward compat)
invoice_notes = db.Column(db.Text) # notes specific to invoices
quote_notes = db.Column(db.Text) # notes specific to quotations
logo_path = db.Column(db.String(200))
signature_path = db.Column(db.String(200)) # firma guardada de la compañía
# Numbering format: prefix letters only, e.g. "IPY" for invoices, "QPY" for quotes
invoice_prefix = db.Column(db.String(10), default='INV')
quote_prefix = db.Column(db.String(10), default='QUO')
# Internal counters per month — stored as JSON: {"2024-03": 5, "2024-04": 2}
invoice_counters = db.Column(db.Text, default='{}')
quote_counters = db.Column(db.Text, default='{}')
# Email config for sending PDFs
smtp_host = db.Column(db.String(120))
smtp_port = db.Column(db.Integer, default=587)
smtp_user = db.Column(db.String(120))
smtp_password = db.Column(db.String(200))
smtp_from_name = db.Column(db.String(120))
active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
clients = db.relationship('Client', backref='company', lazy=True)
products = db.relationship('Product', backref='company', lazy=True)
documents = db.relationship('Document', backref='company', lazy=True)
users = db.relationship('User', backref='company', lazy=True)
def get_next_number(self, doc_type):
"""Get next auto number for invoice (I) or quote (Q). Does NOT increment — call increment_counter after saving."""
now = datetime.utcnow()
month_key = now.strftime('%Y-%m')
month_str = now.strftime('%m%Y')
if doc_type == 'invoice':
counters = json.loads(self.invoice_counters or '{}')
prefix = self.invoice_prefix or 'INV'
else:
counters = json.loads(self.quote_counters or '{}')
prefix = self.quote_prefix or 'QUO'
current_count = counters.get(month_key, 0) + 1
return f"{prefix}-{str(current_count).zfill(3)}-{month_str}"
def increment_counter(self, doc_type):
"""Increment the internal counter for this month."""
now = datetime.utcnow()
month_key = now.strftime('%Y-%m')
if doc_type == 'invoice':
counters = json.loads(self.invoice_counters or '{}')
counters[month_key] = counters.get(month_key, 0) + 1
self.invoice_counters = json.dumps(counters)
else:
counters = json.loads(self.quote_counters or '{}')
counters[month_key] = counters.get(month_key, 0) + 1
self.quote_counters = json.dumps(counters)
class Client(db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
name = db.Column(db.String(120), nullable=False)
contact = db.Column(db.String(120))
email = db.Column(db.String(120))
phone = db.Column(db.String(30))
address = db.Column(db.String(200))
city = db.Column(db.String(80))
state = db.Column(db.String(30))
yacht_name = db.Column(db.String(80))
yacht_info = db.Column(db.String(120))
notes = db.Column(db.Text)
active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Product(db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
name = db.Column(db.String(120), nullable=False)
description = db.Column(db.Text)
price = db.Column(db.Float, nullable=False)
unit = db.Column(db.String(20), default='hr')
item_type = db.Column(db.String(20), default='service')
active = db.Column(db.Boolean, default=True)
class Document(db.Model):
"""Unified model for both Invoices and Quotes"""
id = db.Column(db.Integer, primary_key=True)
doc_type = db.Column(db.String(10), nullable=False) # 'invoice' or 'quote'
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
client_id = db.Column(db.Integer, db.ForeignKey('client.id'), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('user.id'))
number = db.Column(db.String(40), nullable=False) # display number (user can adjust)
internal_seq = db.Column(db.Integer, default=0) # internal counter, never changes
date = db.Column(db.Date, nullable=False)
due_date = db.Column(db.Date)
status = db.Column(db.String(20), default='draft')
# invoice statuses: draft, sent, paid, cancelled
# quote statuses: draft, sent, accepted, rejected
language = db.Column(db.String(5), default='en')
description = db.Column(db.Text)
line_items = db.Column(db.Text) # JSON
subtotal = db.Column(db.Float, default=0)
tax_rate = db.Column(db.Float, default=7)
tax_amount = db.Column(db.Float, default=0)
total = db.Column(db.Float, default=0)
notes = db.Column(db.Text)
pdf_path = db.Column(db.String(300)) # stored PDF path on server
prepared_by = db.Column(db.String(120))
signed_by = db.Column(db.String(120))
signature = db.Column(db.Text) # base64 PNG o URL de firma
created_at = db.Column(db.DateTime, default=datetime.utcnow)
sent_at = db.Column(db.DateTime, nullable=True) # ✅ FIX: última vez enviado por email
sent_to = db.Column(db.String(200), nullable=True) # ✅ FIX: email(s) destinatario
client = db.relationship('Client', backref='documents', lazy=True)
creator = db.relationship('User', backref='documents', lazy=True, foreign_keys=[created_by])
@login_manager.user_loader
def load_user(user_id): return User.query.get(int(user_id))
# ============================================================
# AUTH
# ============================================================
@app.route('/login', methods=['GET','POST'])
def login():
if current_user.is_authenticated: return redirect(url_for('dashboard'))
if request.method == 'POST':
u = request.form.get('username','').strip()
p = request.form.get('password','')
user = User.query.filter_by(username=u, active=True).first()
if user and user.check_password(p):
login_user(user, remember=True)
return redirect(url_for('dashboard'))
flash('Usuario o contraseña incorrectos', 'error')
return render_template('login.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('login'))
# ============================================================
# DASHBOARD
# ============================================================
@app.route('/')
@login_required
def dashboard():
if current_user.is_superadmin():
companies = Company.query.filter_by(active=True).all()
total_invoices = Document.query.filter_by(doc_type='invoice').count()
total_quotes = Document.query.filter_by(doc_type='quote').count()
total_clients = Client.query.count()
total_billed = db.session.query(db.func.sum(Document.total)).filter_by(doc_type='invoice').scalar() or 0
recent_invoices = Document.query.filter_by(doc_type='invoice').order_by(Document.created_at.desc()).limit(6).all()
recent_quotes = Document.query.filter_by(doc_type='quote').order_by(Document.created_at.desc()).limit(6).all()
else:
companies = [current_user.company] if current_user.company else []
cid = current_user.company_id
total_invoices = Document.query.filter_by(company_id=cid, doc_type='invoice').count()
total_quotes = Document.query.filter_by(company_id=cid, doc_type='quote').count()
total_clients = Client.query.filter_by(company_id=cid).count()
total_billed = db.session.query(db.func.sum(Document.total)).filter_by(company_id=cid, doc_type='invoice').scalar() or 0
recent_invoices = Document.query.filter_by(company_id=cid, doc_type='invoice').order_by(Document.created_at.desc()).limit(6).all()
recent_quotes = Document.query.filter_by(company_id=cid, doc_type='quote').order_by(Document.created_at.desc()).limit(6).all()
return render_template('dashboard.html', companies=companies,
total_invoices=total_invoices, total_quotes=total_quotes,
total_clients=total_clients, total_billed=total_billed,
recent_invoices=recent_invoices, recent_quotes=recent_quotes)
# ============================================================
# COMPANIES
# ============================================================
@app.route('/companies')
@login_required
def companies():
if not current_user.is_superadmin(): return redirect(url_for('dashboard'))
return render_template('companies.html', companies=Company.query.filter_by(active=True).all())
@app.route('/companies/new', methods=['GET','POST'])
@login_required
def new_company():
if not current_user.is_superadmin(): return redirect(url_for('dashboard'))
if request.method == 'POST':
logo_path = None
if 'logo' in request.files:
f = request.files['logo']
if f and f.filename:
fn = secure_filename(f.filename)
f.save(os.path.join(app.config['UPLOAD_FOLDER'], fn))
logo_path = f'logos/{fn}'
c = Company(
name=request.form['name'], ein=request.form.get('ein',''),
license_num=request.form.get('license',''), phone=request.form.get('phone',''),
address=request.form.get('address',''), city=request.form.get('city',''),
state=request.form.get('state',''), email=request.form.get('email',''),
website=request.form.get('website',''), manager=request.form.get('manager',''),
authorized=request.form.get('authorized',''),
tax_rate=float(request.form.get('tax_rate',7)),
invoice_prefix=request.form.get('invoice_prefix','INV').upper().strip(),
quote_prefix=request.form.get('quote_prefix','QUO').upper().strip(),
smtp_host=request.form.get('smtp_host',''),
smtp_port=int(request.form.get('smtp_port',587) or 587),
smtp_user=request.form.get('smtp_user',''),
smtp_password=request.form.get('smtp_password',''),
smtp_from_name=request.form.get('smtp_from_name',''),
notes=request.form.get('notes',''),
invoice_notes=request.form.get('invoice_notes',''),
quote_notes=request.form.get('quote_notes',''),
logo_path=logo_path
)
db.session.add(c)
db.session.commit()
flash('Compañía creada exitosamente', 'success')
return redirect(url_for('companies'))
return render_template('company_form.html', company=None)
@app.route('/companies/<int:id>/edit', methods=['GET','POST'])
@login_required
def edit_company(id):
if not current_user.is_superadmin(): return redirect(url_for('dashboard'))
c = Company.query.get_or_404(id)
if request.method == 'POST':
if 'logo' in request.files:
f = request.files['logo']
if f and f.filename:
fn = secure_filename(f.filename)
f.save(os.path.join(app.config['UPLOAD_FOLDER'], fn))
c.logo_path = f'logos/{fn}'
if 'signature' in request.files:
f = request.files['signature']
if f and f.filename:
fn = 'sig_' + secure_filename(f.filename)
f.save(os.path.join(app.config['UPLOAD_FOLDER'], fn))
c.signature_path = f'logos/{fn}'
c.name=request.form['name']; c.ein=request.form.get('ein','')
c.license_num=request.form.get('license',''); c.phone=request.form.get('phone','')
c.address=request.form.get('address',''); c.city=request.form.get('city','')
c.state=request.form.get('state',''); c.email=request.form.get('email','')
c.website=request.form.get('website',''); c.manager=request.form.get('manager','')
c.authorized=request.form.get('authorized','')
c.tax_rate=float(request.form.get('tax_rate',7))
c.invoice_prefix=request.form.get('invoice_prefix','INV').upper().strip()
c.quote_prefix=request.form.get('quote_prefix','QUO').upper().strip()
c.smtp_host=request.form.get('smtp_host','')
c.smtp_port=int(request.form.get('smtp_port',587) or 587)
c.smtp_user=request.form.get('smtp_user','')
if request.form.get('smtp_password'): c.smtp_password=request.form.get('smtp_password')
c.smtp_from_name=request.form.get('smtp_from_name','')
c.notes=request.form.get('notes','')
c.invoice_notes=request.form.get('invoice_notes','')
c.quote_notes=request.form.get('quote_notes','')
db.session.commit()
flash('Compañía actualizada', 'success')
return redirect(url_for('companies'))
return render_template('company_form.html', company=c)
@app.route('/companies/<int:id>/delete', methods=['POST'])
@login_required
def delete_company(id):
if not current_user.is_superadmin(): return jsonify({'error':'No autorizado'}),403
c = Company.query.get_or_404(id)
c.active = False; db.session.commit()
return jsonify({'success':True})
# ============================================================
# USERS
# ============================================================
@app.route('/users')
@login_required
def users():
if not current_user.is_superadmin(): return redirect(url_for('dashboard'))
return render_template('users.html',
users=User.query.filter_by(active=True).all(),
companies=Company.query.filter_by(active=True).all())
@app.route('/users/new', methods=['POST'])
@login_required
def new_user():
if not current_user.is_superadmin(): return jsonify({'error':'No autorizado'}),403
d = request.get_json()
if User.query.filter_by(username=d['username']).first():
return jsonify({'error':'Usuario ya existe'}),400
u = User(username=d['username'], email=d.get('email',''),
full_name=d.get('full_name',''), role=d.get('role','user'),
company_id=d.get('company_id') or None,
smtp_user=d.get('smtp_user',''),
smtp_password=d.get('smtp_password',''),
email_title=d.get('email_title',''))
u.set_password(d['password'])
db.session.add(u); db.session.commit()
return jsonify({'success':True,'id':u.id})
@app.route('/profile')
@login_required
def profile():
return render_template('profile.html')
@app.route('/profile/save', methods=['POST'])
@login_required
def save_profile():
d = request.get_json()
current_user.full_name = d.get('full_name', current_user.full_name)
current_user.smtp_user = d.get('smtp_user', current_user.smtp_user or '')
current_user.email_title = d.get('email_title', current_user.email_title or '')
if d.get('smtp_password'):
current_user.smtp_password = d.get('smtp_password')
if d.get('password'):
current_user.set_password(d['password'])
db.session.commit()
return jsonify({'success': True})
@app.route('/users/<int:id>', methods=['PUT'])
@login_required
def edit_user(id):
# Superadmin puede editar cualquiera; cualquier usuario puede editarse a sí mismo
if not current_user.is_superadmin() and current_user.id != id:
return jsonify({'error':'No autorizado'}),403
u = User.query.get_or_404(id)
d = request.get_json()
u.full_name = d.get('full_name', u.full_name)
u.email = d.get('email', u.email)
u.smtp_user = d.get('smtp_user', u.smtp_user or '')
u.email_title = d.get('email_title', u.email_title or '')
if d.get('smtp_password'):
u.smtp_password = d.get('smtp_password')
# Only superadmin can change role and company
if current_user.is_superadmin():
u.role = d.get('role', u.role)
u.company_id = d.get('company_id') or None
if d.get('password'):
u.set_password(d['password'])
db.session.commit()
return jsonify({'success': True})
@app.route('/users/<int:id>/delete', methods=['POST'])
@login_required
def delete_user(id):
if not current_user.is_superadmin(): return jsonify({'error':'No autorizado'}),403
u = User.query.get_or_404(id); u.active=False; db.session.commit()
return jsonify({'success':True})
@app.route('/users/<int:id>/reset-password', methods=['POST'])
@login_required
def reset_password(id):
if not current_user.is_superadmin(): return jsonify({'error':'No autorizado'}),403
d = request.get_json()
u = User.query.get_or_404(id); u.set_password(d['password']); db.session.commit()
return jsonify({'success':True})
# ============================================================
# CLIENTS
# ============================================================
@app.route('/clients')
@login_required
def clients():
if current_user.is_superadmin():
all_clients = Client.query.filter_by(active=True).all()
else:
all_clients = Client.query.filter_by(company_id=current_user.company_id, active=True).all()
companies = Company.query.filter_by(active=True).all()
return render_template('clients.html', clients=all_clients, companies=companies)
@app.route('/clients/new', methods=['POST'])
@login_required
def new_client():
d = request.get_json()
cid = d.get('company_id')
if not current_user.can_access_company(cid): return jsonify({'error':'No autorizado'}),403
c = Client(company_id=cid, name=d['name'], contact=d.get('contact',''),
email=d.get('email',''), phone=d.get('phone',''), address=d.get('address',''),
city=d.get('city',''), state=d.get('state',''),
yacht_name=d.get('yacht_name',''), yacht_info=d.get('yacht_info',''),
notes=d.get('notes',''))
db.session.add(c); db.session.commit()
return jsonify({'success':True,'id':c.id})
@app.route('/clients/<int:id>', methods=['PUT'])
@login_required
def update_client(id):
c = Client.query.get_or_404(id)
if not current_user.can_access_company(c.company_id): return jsonify({'error':'No autorizado'}),403
d = request.get_json()
for f in ['name','contact','email','phone','address','city','state','yacht_name','yacht_info','notes']:
if f in d: setattr(c, f, d[f])
db.session.commit(); return jsonify({'success':True})
@app.route('/clients/<int:id>', methods=['DELETE'])
@login_required
def delete_client(id):
c = Client.query.get_or_404(id)
if not current_user.can_access_company(c.company_id): return jsonify({'error':'No autorizado'}),403
c.active=False; db.session.commit(); return jsonify({'success':True})
# ============================================================
# PRODUCTS
# ============================================================
@app.route('/products')
@login_required
def products():
if current_user.is_superadmin():
all_products = Product.query.filter_by(active=True).all()
else:
all_products = Product.query.filter_by(company_id=current_user.company_id, active=True).all()
companies = Company.query.filter_by(active=True).all()
return render_template('products.html', products=all_products, companies=companies)
@app.route('/products/new', methods=['POST'])
@login_required
def new_product():
d = request.get_json()
cid = d.get('company_id')
if not current_user.can_access_company(cid): return jsonify({'error':'No autorizado'}),403
p = Product(company_id=cid, name=d['name'], description=d.get('description',''),
price=float(d['price']), unit=d.get('unit','hr'), item_type=d.get('item_type','service'))
db.session.add(p); db.session.commit(); return jsonify({'success':True,'id':p.id})
@app.route('/products/<int:id>', methods=['PUT'])
@login_required
def update_product(id):
p = Product.query.get_or_404(id)
if not current_user.can_access_company(p.company_id): return jsonify({'error':'No autorizado'}),403
d = request.get_json()
for f in ['name','description','unit','item_type']:
if f in d: setattr(p, f, d[f])
if 'price' in d: p.price=float(d['price'])
db.session.commit(); return jsonify({'success':True})
@app.route('/products/<int:id>', methods=['DELETE'])
@login_required
def delete_product(id):
p = Product.query.get_or_404(id)
if not current_user.can_access_company(p.company_id): return jsonify({'error':'No autorizado'}),403
p.active=False; db.session.commit(); return jsonify({'success':True})
# ============================================================
# DOCUMENTS (INVOICES + QUOTES)
# ============================================================
@app.route('/invoices')
@login_required
def invoices():
return _doc_list_page('invoice')
@app.route('/quotes')
@login_required
def quotes():
return _doc_list_page('quote')
def _doc_list_page(doc_type):
if current_user.is_superadmin():
docs = Document.query.filter_by(doc_type=doc_type).order_by(Document.created_at.desc()).all()
else:
docs = Document.query.filter_by(doc_type=doc_type, company_id=current_user.company_id).order_by(Document.created_at.desc()).all()
companies = Company.query.filter_by(active=True).all()
clients = Client.query.filter_by(active=True).all()
products = Product.query.filter_by(active=True).all()
return render_template('documents.html', docs=docs, doc_type=doc_type,
companies=companies, clients=clients, products=products)
@app.route('/documents/new', methods=['POST'])
@login_required
def new_document():
d = request.get_json()
cid = int(d['company_id'])
if not current_user.can_access_company(cid): return jsonify({'error':'No autorizado'}),403
company = Company.query.get(cid)
doc_type = d.get('doc_type','invoice')
line_items = d.get('line_items',[])
subtotal = sum(i['qty']*i['price'] for i in line_items)
tax_rate = company.tax_rate if company else 7
# Tax only on items marked taxable (products/materials). Default: taxable if item_type is product or material
def is_taxable(item):
if 'taxable' in item:
return item['taxable']
return item.get('item_type','service') in ('product','material')
taxable_amt = sum(i['qty']*i['price'] for i in line_items if is_taxable(i))
tax_amount = taxable_amt*(tax_rate/100)
# Auto number — ignore any user-provided number for the internal counter
auto_number = company.get_next_number(doc_type)
# User can override display number but internal counter is untouched
display_number = d.get('number','').strip() or auto_number
doc = Document(
doc_type=doc_type, company_id=cid, client_id=int(d['client_id']),
created_by=current_user.id, number=display_number,
date=datetime.strptime(d['date'],'%Y-%m-%d').date(),
due_date=datetime.strptime(d['due_date'],'%Y-%m-%d').date() if d.get('due_date') else None,
status=d.get('status','draft'), language=d.get('language','en'),
description=d.get('description',''), line_items=json.dumps(line_items),
subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount,
total=subtotal+tax_amount, notes=d.get('notes',''),
prepared_by=d.get('prepared_by',''), signed_by=d.get('signed_by',''),
signature=d.get('signature','')
)
db.session.add(doc)
# Increment internal counter regardless of display number
company.increment_counter(doc_type)
db.session.commit()
return jsonify({'success':True,'id':doc.id,'auto_number':auto_number,'display_number':display_number})
@app.route('/documents/<int:id>', methods=['PUT'])
@login_required
def update_document(id):
doc = Document.query.get_or_404(id)
if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403
d = request.get_json()
line_items = d.get('line_items', json.loads(doc.line_items or '[]'))
subtotal = sum(i['qty']*i['price'] for i in line_items)
company = Company.query.get(doc.company_id)
tax_rate = company.tax_rate if company else 7
def is_taxable(item):
if 'taxable' in item:
return item['taxable']
return item.get('item_type','service') in ('product','material')
taxable_amt = sum(i['qty']*i['price'] for i in line_items if is_taxable(i))
tax_amount = taxable_amt*(tax_rate/100)
doc.client_id = int(d.get('client_id', doc.client_id))
doc.number = d.get('number', doc.number)
doc.date = datetime.strptime(d['date'],'%Y-%m-%d').date() if d.get('date') else doc.date
doc.due_date = datetime.strptime(d['due_date'],'%Y-%m-%d').date() if d.get('due_date') else doc.due_date
doc.status = d.get('status', doc.status)
doc.language = d.get('language', doc.language)
doc.description = d.get('description', doc.description)
doc.line_items = json.dumps(line_items)
doc.subtotal=subtotal; doc.tax_rate=tax_rate; doc.tax_amount=tax_amount; doc.total=subtotal+tax_amount
doc.notes = d.get('notes', doc.notes)
doc.prepared_by = d.get('prepared_by', doc.prepared_by or '')
doc.signed_by = d.get('signed_by', doc.signed_by or '')
doc.signature = d.get('signature', doc.signature or '')
db.session.commit()
return jsonify({'success':True})
@app.route('/documents/<int:id>/status', methods=['POST'])
@login_required
def update_doc_status(id):
doc = Document.query.get_or_404(id)
if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403
d = request.get_json()
doc.status = d['status']; db.session.commit()
return jsonify({'success':True})
@app.route('/documents/<int:id>', methods=['DELETE'])
@login_required
def delete_document(id):
doc = Document.query.get_or_404(id)
if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403
# Delete stored PDF if exists
if doc.pdf_path and os.path.exists(doc.pdf_path):
os.remove(doc.pdf_path)
db.session.delete(doc); db.session.commit()
return jsonify({'success':True})
@app.route('/documents/<int:id>/data')
@login_required
def get_doc_data(id):
doc = Document.query.get_or_404(id)
if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403
return jsonify({
'id':doc.id, 'doc_type':doc.doc_type, 'number':doc.number,
'company_id':doc.company_id, 'client_id':doc.client_id,
'date':doc.date.strftime('%Y-%m-%d'),
'due_date':doc.due_date.strftime('%Y-%m-%d') if doc.due_date else '',
'status':doc.status, 'language':doc.language, 'description':doc.description or '',
'line_items':json.loads(doc.line_items or '[]'),
'subtotal':doc.subtotal, 'tax_rate':doc.tax_rate,
'tax_amount':doc.tax_amount, 'total':doc.total,
'notes':doc.notes or '', 'prepared_by':doc.prepared_by or '',
'signed_by':doc.signed_by or '', 'signature':doc.signature or '',
'has_pdf': bool(doc.pdf_path and os.path.exists(doc.pdf_path))
})
# ============================================================
# PDF - SAVE ON SERVER + DOWNLOAD
# ============================================================
@app.route('/documents/<int:id>/save-pdf', methods=['POST'])
@login_required
def save_pdf(id):
"""Receive PDF as raw binary (multipart) or base64 JSON, store on server."""
doc = Document.query.get_or_404(id)
if not current_user.can_access_company(doc.company_id): return jsonify({'error':'No autorizado'}),403
company_pdf_dir = os.path.join(app.config['PDF_FOLDER'], str(doc.company_id))
os.makedirs(company_pdf_dir, exist_ok=True)
safe_number = re.sub(r'[^\w\-]','_', doc.number)
filename = f"{doc.doc_type}_{safe_number}_{doc.id}.pdf"
filepath = os.path.join(company_pdf_dir, filename)
if request.files.get('pdf'):
f = request.files['pdf']
f.save(filepath)
# Verify file was written correctly
size = os.path.getsize(filepath)
if size < 100:
return jsonify({'error': f'PDF muy pequeño ({size} bytes), posiblemente corrupto'}), 400
else:
d = request.get_json()
if not d: return jsonify({'error':'No data received'}),400
pdf_b64 = d.get('pdf_b64','').strip()
if not pdf_b64: return jsonify({'error':'No PDF data'}),400
if ',' in pdf_b64:
pdf_b64 = pdf_b64.split(',',1)[1]
pdf_b64 += '=' * (-len(pdf_b64) % 4)
try:
pdf_bytes = base64.b64decode(pdf_b64)
with open(filepath, 'wb') as wf:
wf.write(pdf_bytes)
except Exception as e:
return jsonify({'error': f'Error decodificando PDF: {str(e)}'}), 400
doc.pdf_path = filepath
db.session.commit()
return jsonify({'success':True, 'filename': filename})
@app.route('/documents/<int:id>/preview-pdf')
@login_required
def preview_pdf(id):
"""Serve PDF inline for browser preview."""
doc = Document.query.get_or_404(id)
if not current_user.can_access_company(doc.company_id): abort(403)
if not doc.pdf_path or not os.path.exists(doc.pdf_path):
return "<html><body style='font-family:sans-serif;padding:40px;background:#0a1628;color:white;'><h2>⚠️ PDF no encontrado</h2><p>Genera el PDF primero usando el botón 📄 Generar PDF</p><script>setTimeout(()=>window.close(),3000)</script></body></html>", 404
response = send_file(doc.pdf_path, as_attachment=False,
download_name=f"{doc.number}.pdf", mimetype='application/pdf')
response.headers['Content-Disposition'] = f'inline; filename="{doc.number}.pdf"'
return response
@app.route('/documents/<int:id>/download-pdf')
@login_required
def download_pdf(id):
doc = Document.query.get_or_404(id)
if not current_user.can_access_company(doc.company_id): abort(403)
if not doc.pdf_path or not os.path.exists(doc.pdf_path):
flash('PDF no encontrado. Genera el PDF primero.', 'error')
return redirect(url_for('invoices') if doc.doc_type=='invoice' else url_for('quotes'))
return send_file(doc.pdf_path, as_attachment=True,
download_name=f"{doc.number}.pdf", mimetype='application/pdf')
# ============================================================
# EMAIL PDF ✅ CORREGIDO
# ============================================================
@app.route('/documents/<int:id>/send-email', methods=['POST'])
@login_required
def send_email_pdf(id):
doc = Document.query.get_or_404(id)
if not current_user.can_access_company(doc.company_id):
return jsonify({'error': 'No autorizado'}), 403
if not doc.pdf_path or not os.path.exists(doc.pdf_path):
return jsonify({'error': 'PDF no encontrado. Genera el PDF primero.'}), 400
company = Company.query.get(doc.company_id)
# SMTP: servidor de la compañía, credenciales del usuario (fallback a compañía)
smtp_host = (company.smtp_host or '').strip()
smtp_port = company.smtp_port or 587
smtp_user = (current_user.smtp_user or company.smtp_user or '').strip()
smtp_pass = (current_user.smtp_password or company.smtp_password or '').strip()
from_name = current_user.email_title or current_user.full_name or company.smtp_from_name or company.name
if not smtp_host or not smtp_user:
return jsonify({'error': 'Configura tu email en tu perfil o el SMTP en la compañía primero.'}), 400
if not smtp_pass:
return jsonify({'error': 'Falta la contraseña del email. Configúrala en tu perfil.'}), 400
d = request.get_json()
to_email = d.get('to_email', '').strip()
if not to_email:
return jsonify({'error': 'Email del destinatario requerido.'}), 400
subject = d.get('subject', f"{doc.number} - {company.name}")
body = d.get('body', f"Please find attached {doc.doc_type} {doc.number}.\n\nThank you for your business.\n\n{company.name}")
try:
# --- Construir mensaje principal (solo al cliente) ---
msg = MIMEMultipart()
msg['From'] = f"{from_name} <{smtp_user}>"
msg['To'] = to_email
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain'))
with open(doc.pdf_path, 'rb') as f:
part = MIMEBase('application', 'octet-stream')
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header('Content-Disposition', f'attachment; filename="{doc.number}.pdf"')
msg.attach(part)
# --- Enviar SOLO al cliente ---
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server:
server.ehlo()
server.starttls()
server.ehlo()
server.login(smtp_user, smtp_pass)
server.sendmail(smtp_user, [to_email], msg.as_string())
# --- Enviar copia separada al remitente (no bloquea si falla) ---
try:
msg_copy = MIMEMultipart()
msg_copy['From'] = f"{from_name} <{smtp_user}>"
msg_copy['To'] = smtp_user
msg_copy['Subject'] = f"[COPIA] {subject}"
msg_copy.attach(MIMEText(f"Copia del envío a: {to_email}\n\n{body}", 'plain'))
with open(doc.pdf_path, 'rb') as f:
part2 = MIMEBase('application', 'octet-stream')
part2.set_payload(f.read())
encoders.encode_base64(part2)
part2.add_header('Content-Disposition', f'attachment; filename="{doc.number}.pdf"')
msg_copy.attach(part2)
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server2:
server2.ehlo()
server2.starttls()
server2.ehlo()
server2.login(smtp_user, smtp_pass)
server2.sendmail(smtp_user, [smtp_user], msg_copy.as_string())
except Exception:
pass # La copia al remitente falla silenciosamente — no afecta el envío principal
# --- Registrar en DB siempre ---
doc.status = 'sent'
doc.sent_at = datetime.utcnow()
doc.sent_to = to_email
db.session.commit()
return jsonify({'success': True})
except smtplib.SMTPAuthenticationError:
return jsonify({'error': 'Error de autenticación SMTP. Verifica tu email y contraseña en Mi Perfil.'}), 500
except smtplib.SMTPConnectError:
return jsonify({'error': f'No se pudo conectar al servidor {smtp_host}:{smtp_port}. Verifica el SMTP en la compañía.'}), 500
except Exception as e:
return jsonify({'error': f'Error enviando email: {str(e)}'}), 500
# ============================================================
# API
# ============================================================
@app.route('/api/clients/<int:company_id>')
@login_required
def api_clients(company_id):
clients = Client.query.filter_by(company_id=company_id, active=True).all()
return jsonify([{'id':c.id,'name':c.name,'yacht':c.yacht_name,'email':c.email,
'phone':c.phone,'address':c.address,'city':c.city,'state':c.state,
'contact':c.contact,'yacht_info':c.yacht_info} for c in clients])
@app.route('/api/products/<int:company_id>')
@login_required
def api_products(company_id):
prods = Product.query.filter_by(company_id=company_id, active=True).all()
return jsonify([{'id':p.id,'name':p.name,'price':p.price,'unit':p.unit,'desc':p.description} for p in prods])
@app.route('/api/next-number/<int:company_id>/<doc_type>')
@login_required
def api_next_number(company_id, doc_type):
company = Company.query.get_or_404(company_id)
return jsonify({'number': company.get_next_number(doc_type)})
@app.route('/api/company/<int:company_id>')
@login_required
def api_company(company_id):
c = Company.query.get_or_404(company_id)
return jsonify({
'id':c.id,'name':c.name,'ein':c.ein,'license_num':c.license_num,
'phone':c.phone,'address':c.address,'city':c.city,'state':c.state,
'email':c.email,'website':c.website,'manager':c.manager,'authorized':c.authorized,
'tax_rate':c.tax_rate,
'notes':c.notes,
'invoice_notes': c.invoice_notes or c.notes or '',
'quote_notes': c.quote_notes or c.notes or '',
'logo_path':c.logo_path or '',
'signature_path': c.signature_path or '',
'invoice_prefix':c.invoice_prefix,'quote_prefix':c.quote_prefix
})
@app.route('/api/me')
@login_required
def api_me():
"""Retorna info del usuario loggeado incluyendo su firma"""
return jsonify({
'id': current_user.id,
'full_name': current_user.full_name or current_user.username,
'username': current_user.username,
'smtp_user': current_user.smtp_user or '',
'signature': current_user.signature or ''
})
@app.route('/api/me/signature', methods=['POST'])
@login_required
def save_my_signature():
"""Guarda la firma del usuario loggeado"""
d = request.get_json()
sig = d.get('signature','')
if not sig:
return jsonify({'error':'No signature data'}), 400
current_user.signature = sig
db.session.commit()
return jsonify({'success': True})
@app.route('/api/users/<int:company_id>')
@login_required
def api_company_users(company_id):
"""Lista usuarios activos de una compañía para el dropdown de 'Autorizado por'"""
if not current_user.can_access_company(company_id):
return jsonify({'error':'No autorizado'}), 403
users = User.query.filter_by(company_id=company_id, active=True).all()
# Superadmin también aparece
admins = User.query.filter_by(role='superadmin', active=True).all()
all_users = {u.id: u for u in users + admins}
return jsonify([{
'id': u.id,
'full_name': u.full_name or u.username,
'username': u.username
} for u in all_users.values()])
# ============================================================
# INIT
# ============================================================
def init_db():
with app.app_context():
db.create_all()
# Migrate: add new columns if they don't exist (safe for existing DBs)
with db.engine.connect() as conn:
try:
conn.execute(db.text('ALTER TABLE document ADD COLUMN sent_at DATETIME'))
except Exception:
pass
try:
conn.execute(db.text('ALTER TABLE document ADD COLUMN sent_to VARCHAR(200)'))
except Exception:
pass
if not User.query.filter_by(username='admin').first():
admin = User(username='admin', email='admin@marineinvoice.com',
full_name='Super Admin', role='superadmin')
admin.set_password('admin123')
db.session.add(admin); db.session.commit()
print('✅ Admin creado: usuario=admin, contraseña=admin123')
print('⚠️ Cambia la contraseña después del primer login!')
if __name__ == '__main__':
init_db()
print('🚀 MarineInvoice Pro corriendo en http://localhost:5000')
print('📱 Desde Tailscale: http://100.96.43.86:5000')
app.run(host='0.0.0.0', port=5000, debug=False)
+204
View File
@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}MarineInvoice Pro{% endblock %}</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--navy: #0a1628; --navy-mid: #112240; --navy-light: #1a3a6b;
--gold: #c9a84c; --gold-light: #e8c97a; --white: #f8f9fc;
--gray: #8892a4; --gray-light: #e2e8f0; --success: #2ecc71;
--danger: #e74c3c; --shadow: 0 8px 32px rgba(0,0,0,0.3);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'DM Sans', sans-serif; background: var(--navy); color: var(--white); min-height: 100vh; }
.app-header {
background: linear-gradient(135deg, var(--navy-mid), var(--navy));
border-bottom: 1px solid rgba(201,168,76,0.3);
padding: 0 24px; display: flex; align-items: center;
justify-content: space-between; height: 64px;
position: sticky; top: 0; z-index: 100;
}
.app-logo { display: flex; align-items: center; gap: 12px; text-decoration: none; }
.logo-icon {
width: 40px; height: 40px;
background: linear-gradient(135deg, var(--gold), var(--gold-light));
border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px;
}
.logo-text { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 700; color: var(--white); }
.logo-text span { color: var(--gold); }
nav { display: flex; gap: 4px; align-items: center; }
.nav-btn {
background: none; border: none; color: var(--gray);
padding: 8px 14px; border-radius: 8px; cursor: pointer;
font-family: 'DM Sans', sans-serif; font-size: 13px; font-weight: 500;
transition: all 0.2s; text-decoration: none; display: flex; align-items: center; gap: 5px;
}
.nav-btn:hover { background: rgba(255,255,255,0.08); color: var(--white); }
.nav-btn.active { background: rgba(201,168,76,0.15); color: var(--gold); }
.nav-user {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; background: rgba(255,255,255,0.06);
border-radius: 20px; font-size: 13px; color: var(--gray);
}
.nav-user strong { color: var(--white); }
.badge-role {
padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; text-transform: uppercase;
}
.badge-superadmin { background: rgba(201,168,76,0.2); color: var(--gold); }
.badge-admin { background: rgba(100,181,246,0.2); color: #64b5f6; }
.badge-user { background: rgba(46,204,113,0.2); color: var(--success); }
.main { max-width: 1200px; margin: 0 auto; padding: 32px 24px; }
.card { background: var(--navy-mid); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; margin-bottom: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.card-title { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
@media(max-width:900px) { .grid-4 { grid-template-columns: 1fr 1fr; } }
@media(max-width:768px) { .grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; } nav { display: none; } .mobile-menu { display: flex !important; } }
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 11px; font-weight: 600; color: var(--gray); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
input,select,textarea {
width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px; padding: 10px 14px; color: var(--white);
font-family: 'DM Sans', sans-serif; font-size: 14px; transition: border-color 0.2s; outline: none;
}
input:focus,select:focus,textarea:focus { border-color: var(--gold); background: rgba(201,168,76,0.08); }
select option { background: var(--navy-mid); color: var(--white); }
textarea { resize: vertical; min-height: 80px; }
.btn { padding: 9px 18px; border-radius: 10px; border: none; font-family: 'DM Sans', sans-serif; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 5px; }
.btn-primary { background: linear-gradient(135deg, var(--gold), var(--gold-light)); color: var(--navy); }
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(201,168,76,0.4); }
.btn-secondary { background: rgba(255,255,255,0.08); color: var(--white); border: 1px solid rgba(255,255,255,0.15); }
.btn-secondary:hover { background: rgba(255,255,255,0.14); }
.btn-danger { background: rgba(231,76,60,0.15); color: var(--danger); border: 1px solid rgba(231,76,60,0.3); }
.btn-success { background: linear-gradient(135deg, #27ae60, #2ecc71); color: white; }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.list-item { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; transition: all 0.2s; }
.list-item:hover { background: rgba(255,255,255,0.07); border-color: rgba(201,168,76,0.2); }
.list-item-info h4 { font-size: 14px; font-weight: 600; margin-bottom: 3px; }
.list-item-info p { font-size: 12px; color: var(--gray); }
.list-item-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.badge { padding: 3px 9px; border-radius: 20px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
.badge-gold { background: rgba(201,168,76,0.2); color: var(--gold); }
.badge-blue { background: rgba(100,181,246,0.15); color: #64b5f6; }
.badge-green { background: rgba(46,204,113,0.15); color: var(--success); }
.badge-gray { background: rgba(136,146,164,0.15); color: var(--gray); }
.stat-card { background: var(--navy-mid); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 20px; }
.stat-icon { font-size: 24px; margin-bottom: 10px; }
.stat-label { font-size: 11px; color: var(--gray); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.stat-value { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--gray); }
.empty-state .emoji { font-size: 48px; margin-bottom: 16px; }
.empty-state h3 { font-size: 18px; margin-bottom: 8px; color: var(--white); }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center; padding: 20px; overflow-y: auto; }
.modal-overlay.active { display: flex; }
.modal { background: var(--navy-mid); border: 1px solid rgba(255,255,255,0.12); border-radius: 20px; padding: 32px; width: 100%; max-width: 650px; box-shadow: var(--shadow); }
.modal-lg { max-width: 900px; }
.modal-title { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 700; margin-bottom: 24px; color: var(--gold); }
.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 20px; }
.divider { height: 1px; background: rgba(255,255,255,0.08); margin: 20px 0; }
.page-title { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; margin-bottom: 4px; }
.page-subtitle { color: var(--gray); font-size: 13px; margin-bottom: 24px; }
.flex { display: flex; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.mt-3 { margin-top: 12px; }
.mb-4 { margin-bottom: 20px; }
.totals-box { background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.2); border-radius: 12px; padding: 16px; width: 280px; margin-left: auto; }
.totals-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 13px; color: var(--gray); }
.totals-row.total { font-size: 17px; font-weight: 700; color: var(--gold); border-top: 1px solid rgba(201,168,76,0.3); margin-top: 8px; padding-top: 10px; }
.line-items-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.line-items-table th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--gray); padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,0.1); }
.line-items-table td { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); }
.line-items-table td input, .line-items-table td select { padding: 5px 8px; font-size: 12px; }
.toast { position: fixed; top: 76px; right: 20px; background: var(--navy-mid); border: 1px solid rgba(201,168,76,0.4); border-radius: 12px; padding: 12px 18px; font-size: 13px; z-index: 2000; transform: translateX(300%); transition: transform 0.3s; box-shadow: var(--shadow); }
.toast.show { transform: translateX(0); }
.toast.success { border-color: rgba(46,204,113,0.4); color: var(--success); }
.toast.error { border-color: rgba(231,76,60,0.4); color: var(--danger); }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: var(--navy-mid); border-top: 1px solid rgba(255,255,255,0.1); padding: 6px; z-index: 100; justify-content: space-around; }
@media(max-width:768px) { .mobile-nav { display: flex; } .main { padding-bottom: 80px; } }
.mobile-nav-btn { background: none; border: none; color: var(--gray); display: flex; flex-direction: column; align-items: center; gap: 3px; font-size: 9px; font-family: 'DM Sans', sans-serif; cursor: pointer; padding: 5px 10px; border-radius: 8px; text-decoration: none; }
.mobile-nav-btn.active { color: var(--gold); background: rgba(201,168,76,0.1); }
.mobile-nav-btn .ico { font-size: 18px; }
.flash-messages { margin-bottom: 20px; }
.flash { padding: 12px 16px; border-radius: 10px; margin-bottom: 10px; font-size: 13px; }
.flash-success { background: rgba(46,204,113,0.15); border: 1px solid rgba(46,204,113,0.3); color: var(--success); }
.flash-error { background: rgba(231,76,60,0.15); border: 1px solid rgba(231,76,60,0.3); color: var(--danger); }
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<header class="app-header">
<a href="/" class="app-logo">
<div class="logo-icon"></div>
<span class="logo-text">Marine<span>Invoice</span> Pro</span>
</a>
<nav>
<a href="/" class="nav-btn {% if request.endpoint == 'dashboard' %}active{% endif %}">📊 Dashboard</a>
{% if current_user.is_superadmin() %}
<a href="/companies" class="nav-btn {% if request.endpoint == 'companies' %}active{% endif %}">🏢 Compañías</a>
<a href="/users" class="nav-btn {% if request.endpoint == 'users' %}active{% endif %}">👤 Usuarios</a>
{% endif %}
<a href="/clients" class="nav-btn {% if request.endpoint == 'clients' %}active{% endif %}">👥 Clientes</a>
<a href="/products" class="nav-btn {% if request.endpoint == 'products' %}active{% endif %}">🔧 Productos</a>
<a href="/invoices" class="nav-btn {% if request.endpoint == 'invoices' %}active{% endif %}">📄 Invoices</a>
<a href="/quotes" class="nav-btn {% if request.endpoint == 'quotes' %}active{% endif %}">📋 Cotizaciones</a>
<div class="nav-user">
<strong>{{ current_user.full_name or current_user.username }}</strong>
<span class="badge-role badge-{{ current_user.role }}">{{ current_user.role }}</span>
</div>
<a href="/profile" class="btn btn-secondary btn-sm">👤 Mi Perfil</a>
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
</nav>
</header>
<nav class="mobile-nav">
<a href="/" class="mobile-nav-btn {% if request.endpoint == 'dashboard' %}active{% endif %}"><span class="ico">📊</span>Home</a>
{% if current_user.is_superadmin() %}<a href="/companies" class="mobile-nav-btn {% if request.endpoint == 'companies' %}active{% endif %}"><span class="ico">🏢</span>Cias</a>{% endif %}
<a href="/clients" class="mobile-nav-btn {% if request.endpoint == 'clients' %}active{% endif %}"><span class="ico">👥</span>Clientes</a>
<a href="/products" class="mobile-nav-btn {% if request.endpoint == 'products' %}active{% endif %}"><span class="ico">🔧</span>Prods</a>
<a href="/invoices" class="mobile-nav-btn {% if request.endpoint == 'invoices' %}active{% endif %}"><span class="ico">📄</span>Invoices</a>
<a href="/quotes" class="mobile-nav-btn {% if request.endpoint == 'quotes' %}active{% endif %}"><span class="ico">📋</span>Cotiz.</a>
<a href="/profile" class="mobile-nav-btn {% if request.endpoint == 'profile' %}active{% endif %}"><span class="ico">👤</span>Perfil</a>
<a href="/logout" class="mobile-nav-btn"><span class="ico">🚪</span>Salir</a>
</nav>
<div class="main">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<div class="toast" id="toast"></div>
<script>
function showToast(msg, type='success', duration=3000) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `toast ${type} show`;
// info style
if (type === 'info') {
t.style.background = 'linear-gradient(135deg,#1a6fa8,#0d5c94)';
t.style.borderColor = 'rgba(26,111,168,0.4)';
} else {
t.style.background = ''; t.style.borderColor = '';
}
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove('show'), duration);
}
function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
// Modal stays open on outside click — only closes via Cancel/X buttons
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+147
View File
@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Clientes — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div><h1 class="page-title">Clientes</h1><p class="page-subtitle">Base de datos de clientes</p></div>
<button class="btn btn-primary" onclick="openNewClient()">+ Nuevo Cliente</button>
</div>
{% if clients %}
{% for c in clients %}
<div class="list-item" id="cli-{{ c.id }}">
<div class="list-item-info">
<h4>{{ c.name }} {% if c.yacht_name %}<span class="badge badge-blue">⛵ {{ c.yacht_name }}</span>{% endif %}</h4>
<p>{{ c.email or '' }}{% if c.phone %} · {{ c.phone }}{% endif %}{% if c.city %} · {{ c.city }}{% endif %}</p>
{% if c.yacht_info %}<p style="font-size:11px;margin-top:2px;">{{ c.yacht_info }}</p>{% endif %}
</div>
<div class="list-item-actions">
<button class="btn btn-secondary btn-sm" data-id="{{ c.id }}" data-company="{{ c.company_id }}" onclick="editClient(this)">✏️ Editar</button>
<button class="btn btn-danger btn-sm" onclick="delClient({{ c.id }})">🗑️</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">👥</div><h3>No hay clientes</h3><p>Agrega tu primer cliente</p></div>
{% endif %}
<!-- Hidden data store for all clients -->
<script type="application/json" id="clients-data">
[{% for c in clients %}{
"id": {{ c.id }},
"company_id": {{ c.company_id }},
"name": {{ c.name|tojson }},
"contact": {{ (c.contact or '')|tojson }},
"email": {{ (c.email or '')|tojson }},
"phone": {{ (c.phone or '')|tojson }},
"address": {{ (c.address or '')|tojson }},
"city": {{ (c.city or '')|tojson }},
"state": {{ (c.state or '')|tojson }},
"yacht_name": {{ (c.yacht_name or '')|tojson }},
"yacht_info": {{ (c.yacht_info or '')|tojson }},
"notes": {{ (c.notes or '')|tojson }}
}{% if not loop.last %},{% endif %}{% endfor %}]
</script>
<div class="modal-overlay" id="clientModal">
<div class="modal">
<h2 class="modal-title" id="clientModalTitle">👥 Nuevo Cliente</h2>
<input type="hidden" id="cli-id">
{% if current_user.is_superadmin() %}
<div class="form-group"><label>Compañía *</label>
<select id="cli-company">
{% for c in companies %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</div>
{% else %}
<input type="hidden" id="cli-company" value="{{ current_user.company_id }}">
{% endif %}
<div class="grid-2">
<div class="form-group"><label>Nombre / Empresa *</label><input type="text" id="cli-name" placeholder="John Smith"></div>
<div class="form-group"><label>Contacto</label><input type="text" id="cli-contact" placeholder="Nombre del contacto"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Email</label><input type="email" id="cli-email" placeholder="cliente@email.com"></div>
<div class="form-group"><label>Teléfono</label><input type="text" id="cli-phone" placeholder="(305) XXX-XXXX"></div>
</div>
<div class="form-group"><label>Dirección</label><input type="text" id="cli-address" placeholder="Dirección"></div>
<div class="grid-2">
<div class="form-group"><label>Ciudad</label><input type="text" id="cli-city" placeholder="Miami"></div>
<div class="form-group"><label>Estado/ZIP</label><input type="text" id="cli-state" placeholder="FL 33010"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Nombre del Yate</label><input type="text" id="cli-yacht" placeholder="Lady K"></div>
<div class="form-group"><label>Tipo / Eslora</label><input type="text" id="cli-yacht-info" placeholder="65ft Azimut"></div>
</div>
<div class="form-group"><label>Notas</label><textarea id="cli-notes" placeholder="Marina, ubicación, notas..."></textarea></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('clientModal')">Cancelar</button>
<button class="btn btn-primary" onclick="saveClient()">💾 Guardar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Load client data from embedded JSON (safe, no escaping issues)
const ALL_CLIENTS = JSON.parse(document.getElementById('clients-data').textContent);
function openNewClient() {
document.getElementById('clientModalTitle').textContent = '👥 Nuevo Cliente';
document.getElementById('cli-id').value = '';
['cli-name','cli-contact','cli-email','cli-phone','cli-address','cli-city','cli-state','cli-yacht','cli-yacht-info','cli-notes'].forEach(id => document.getElementById(id).value = '');
openModal('clientModal');
}
function editClient(btn) {
const id = parseInt(btn.dataset.id);
const c = ALL_CLIENTS.find(x => x.id === id);
if (!c) { showToast('Cliente no encontrado', 'error'); return; }
document.getElementById('clientModalTitle').textContent = '👥 Editar Cliente';
document.getElementById('cli-id').value = c.id;
document.getElementById('cli-name').value = c.name;
document.getElementById('cli-contact').value = c.contact;
document.getElementById('cli-email').value = c.email;
document.getElementById('cli-phone').value = c.phone;
document.getElementById('cli-address').value = c.address;
document.getElementById('cli-city').value = c.city;
document.getElementById('cli-state').value = c.state;
document.getElementById('cli-yacht').value = c.yacht_name;
document.getElementById('cli-yacht-info').value = c.yacht_info;
document.getElementById('cli-notes').value = c.notes;
const comp = document.getElementById('cli-company');
if (comp && comp.tagName === 'SELECT') comp.value = c.company_id;
openModal('clientModal');
}
async function saveClient() {
const name = document.getElementById('cli-name').value.trim();
if (!name) { showToast('El nombre es requerido', 'error'); return; }
const id = document.getElementById('cli-id').value;
const data = {
company_id: document.getElementById('cli-company').value,
name,
contact: document.getElementById('cli-contact').value,
email: document.getElementById('cli-email').value,
phone: document.getElementById('cli-phone').value,
address: document.getElementById('cli-address').value,
city: document.getElementById('cli-city').value,
state: document.getElementById('cli-state').value,
yacht_name: document.getElementById('cli-yacht').value,
yacht_info: document.getElementById('cli-yacht-info').value,
notes: document.getElementById('cli-notes').value
};
const url = id ? `/clients/${id}` : '/clients/new';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, {method, headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)});
const res = await r.json();
if (res.success) { showToast(id ? '✅ Cliente actualizado' : '✅ Cliente creado'); setTimeout(()=>location.reload(), 900); }
else showToast(res.error || 'Error', 'error');
}
async function delClient(id) {
if (!confirm('¿Eliminar este cliente?')) return;
const r = await fetch(`/clients/${id}`, {method:'DELETE'});
const res = await r.json();
if (res.success) { showToast('🗑️ Cliente eliminado'); document.getElementById(`cli-${id}`).remove(); }
}
</script>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Compañías — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div><h1 class="page-title">Compañías</h1><p class="page-subtitle">Gestiona tus empresas</p></div>
<a href="/companies/new" class="btn btn-primary">+ Nueva Compañía</a>
</div>
{% if companies %}
{% for c in companies %}
<div class="list-item">
<div class="flex items-center gap-3">
{% if c.logo_path %}
<img src="/static/{{ c.logo_path }}" style="width:50px;height:50px;object-fit:contain;border-radius:8px;background:white;padding:4px;">
{% else %}
<div style="width:50px;height:50px;background:var(--navy-light);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:24px;">🏢</div>
{% endif %}
<div class="list-item-info">
<h4>{{ c.name }}</h4>
<p>EIN: {{ c.ein }} · Tax: {{ c.tax_rate }}% · {{ c.city or '' }} {{ c.state or '' }}</p>
{% if c.manager %}<p style="font-size:11px;margin-top:2px;">Gerente: {{ c.manager }}</p>{% endif %}
</div>
</div>
<div class="list-item-actions">
<a href="/companies/{{ c.id }}/edit" class="btn btn-secondary btn-sm">✏️ Editar</a>
<button class="btn btn-danger btn-sm" onclick="delCompany({{ c.id }})">🗑️</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">🏢</div><h3>No hay compañías</h3><p>Crea tu primera compañía</p></div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
async function delCompany(id) {
if (!confirm('¿Eliminar esta compañía?')) return;
const r = await fetch(`/companies/${id}/delete`, {method:'POST'});
const res = await r.json();
if (res.success) { showToast('🗑️ Compañía eliminada'); setTimeout(()=>location.reload(), 1000); }
}
</script>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}{{ 'Editar' if company else 'Nueva' }} Compañía{% endblock %}
{% block content %}
<div class="flex items-center gap-3 mb-4">
<a href="/companies" class="btn btn-secondary btn-sm">← Volver</a>
<h1 class="page-title">{{ 'Editar' if company else 'Nueva' }} Compañía</h1>
</div>
<div class="card">
<form method="POST" enctype="multipart/form-data">
<p class="inv-section-title">Información General</p>
<div class="grid-2">
<div class="form-group"><label>Nombre *</label><input type="text" name="name" value="{{ company.name if company else '' }}" required placeholder="Ej: Prisa Yachts"></div>
<div class="form-group"><label>EIN / Tax ID</label><input type="text" name="ein" value="{{ company.ein if company else '' }}" placeholder="XX-XXXXXXX"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Licencia</label><input type="text" name="license" value="{{ company.license_num if company else '' }}" placeholder="Número de licencia"></div>
<div class="form-group"><label>Teléfono</label><input type="text" name="phone" value="{{ company.phone if company else '' }}" placeholder="(305) XXX-XXXX"></div>
</div>
<div class="form-group"><label>Dirección</label><input type="text" name="address" value="{{ company.address if company else '' }}" placeholder="Dirección"></div>
<div class="grid-2">
<div class="form-group"><label>Ciudad</label><input type="text" name="city" value="{{ company.city if company else '' }}" placeholder="Miami"></div>
<div class="form-group"><label>Estado / ZIP</label><input type="text" name="state" value="{{ company.state if company else '' }}" placeholder="FL 33010"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Email</label><input type="email" name="email" value="{{ company.email if company else '' }}" placeholder="info@empresa.com"></div>
<div class="form-group"><label>Website</label><input type="text" name="website" value="{{ company.website if company else '' }}" placeholder="www.empresa.com"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Gerente / Owner</label><input type="text" name="manager" value="{{ company.manager if company else '' }}" placeholder="Nombre completo"></div>
<div class="form-group"><label>Persona Autorizada</label><input type="text" name="authorized" value="{{ company.authorized if company else '' }}" placeholder="Nombre completo"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Sales Tax %</label><input type="number" name="tax_rate" value="{{ company.tax_rate if company else '7' }}" min="0" max="20" step="0.1"></div>
<div class="form-group">
<label>Logo</label>
{% if company and company.logo_path %}<img src="/static/{{ company.logo_path }}" style="height:44px;margin-bottom:6px;display:block;background:white;padding:3px;border-radius:5px;">{% endif %}
<input type="file" name="logo" accept="image/*" style="padding:7px;">
</div>
</div>
<div class="divider"></div>
<p class="inv-section-title">📋 Formato de Numeración Automática</p>
<div style="background:rgba(201,168,76,0.06);border:1px solid rgba(201,168,76,0.15);border-radius:10px;padding:12px 16px;font-size:12px;color:var(--gray);margin-bottom:16px;">
El número se genera como: <strong style="color:var(--gold)">PREFIJO-001-MMAAAA</strong> &nbsp;·&nbsp;
Ej: <code>IPY</code><strong style="color:var(--gold)">IPY-001-032026</strong> &nbsp;·&nbsp; <code>QPY</code><strong style="color:var(--gold)">QPY-001-032026</strong><br>
<span style="margin-top:4px;display:block;">El contador reinicia automáticamente cada mes. El usuario puede ajustar el número en el documento pero el contador interno no se altera.</span>
</div>
<div class="grid-2">
<div class="form-group">
<label>Prefijo de Invoice (solo letras)</label>
<input type="text" name="invoice_prefix" value="{{ company.invoice_prefix if company else 'INV' }}" placeholder="Ej: IPY" maxlength="8" style="text-transform:uppercase;" oninput="this.value=this.value.toUpperCase();updatePreview()">
<small style="color:var(--gray);font-size:11px;margin-top:4px;display:block;">Preview: <span id="inv-preview" style="color:var(--gold);font-weight:700;"></span></small>
</div>
<div class="form-group">
<label>Prefijo de Cotización (solo letras)</label>
<input type="text" name="quote_prefix" value="{{ company.quote_prefix if company else 'QUO' }}" placeholder="Ej: QPY" maxlength="8" style="text-transform:uppercase;" oninput="this.value=this.value.toUpperCase();updatePreview()">
<small style="color:var(--gray);font-size:11px;margin-top:4px;display:block;">Preview: <span id="quo-preview" style="color:var(--gold);font-weight:700;"></span></small>
</div>
</div>
<div class="divider"></div>
<p class="inv-section-title">📧 Servidor SMTP para envío de PDFs</p>
<div style="background:rgba(201,168,76,0.06);border:1px solid rgba(201,168,76,0.15);border-radius:10px;padding:12px 16px;font-size:12px;color:var(--gray);margin-bottom:14px;">
⚙️ Aquí solo se configura el <strong style="color:var(--gold)">servidor</strong> — es el mismo para todos.<br>
El email y contraseña de cada persona se configura en <strong style="color:var(--gold)">Usuarios</strong>.<br>
<span style="margin-top:4px;display:block;">Namecheap Private Email: <code>mail.privateemail.com</code> puerto <code>587</code></span>
</div>
<div class="grid-2">
<div class="form-group"><label>Servidor SMTP</label><input type="text" name="smtp_host" value="{{ company.smtp_host if company else '' }}" placeholder="mail.privateemail.com"></div>
<div class="form-group"><label>Puerto</label><input type="number" name="smtp_port" value="{{ company.smtp_port if company else '587' }}"></div>
</div>
<div class="divider"></div>
<div class="form-group">
<label>Notas para Invoices</label>
<textarea name="invoice_notes" rows="6" placeholder="Payment terms, methods of payment...">{{ company.invoice_notes if company else '' }}</textarea>
</div>
<div class="form-group">
<label>Notas para Cotizaciones</label>
<textarea name="quote_notes" rows="6" placeholder="Quotation validity, payment terms...">{{ company.quote_notes if company else '' }}</textarea>
</div>
<div class="flex gap-2 justify-between mt-3">
<a href="/companies" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary">💾 Guardar Compañía</button>
</div>
</form>
</div>
<style>
.inv-section-title { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:14px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600; }
code { background:rgba(201,168,76,0.12);padding:2px 6px;border-radius:4px;font-size:11px; }
</style>
<script>
function updatePreview() {
const now = new Date();
const monthStr = String(now.getMonth()+1).padStart(2,'0')+String(now.getFullYear());
const ip = document.querySelector('[name="invoice_prefix"]').value||'INV';
const qp = document.querySelector('[name="quote_prefix"]').value||'QUO';
document.getElementById('inv-preview').textContent = ip+'-001-'+monthStr;
document.getElementById('quo-preview').textContent = qp+'-001-'+monthStr;
}
updatePreview();
</script>
{% endblock %}
+92
View File
@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Dashboard — MarineInvoice Pro{% endblock %}
{% block content %}
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">Bienvenido, {{ current_user.full_name or current_user.username }}</p>
<!-- Stat cards -->
<div class="grid-4 mb-4">
<div class="stat-card"><div class="stat-icon">🏢</div><div class="stat-label">Compañías</div><div class="stat-value">{{ companies|length }}</div></div>
<div class="stat-card"><div class="stat-icon">👥</div><div class="stat-label">Clientes</div><div class="stat-value">{{ total_clients }}</div></div>
<div class="stat-card"><div class="stat-icon">📄</div><div class="stat-label">Invoices</div><div class="stat-value">{{ total_invoices }}</div></div>
<div class="stat-card"><div class="stat-icon">📋</div><div class="stat-label">Cotizaciones</div><div class="stat-value">{{ total_quotes }}</div></div>
</div>
<!-- Total facturado banner -->
<div style="background:linear-gradient(135deg,#1a2744 0%,#243560 100%);border-radius:12px;padding:20px 28px;margin-bottom:24px;display:flex;align-items:center;justify-content:space-between;">
<div>
<div style="color:#c9a84c;font-size:12px;font-weight:600;letter-spacing:1px;text-transform:uppercase;">Total Facturado (Invoices)</div>
<div style="color:white;font-size:32px;font-weight:700;margin-top:4px;">${{ "%.2f"|format(total_billed) }}</div>
</div>
<div style="font-size:40px;opacity:0.4;">💰</div>
</div>
<!-- Two columns: Invoices | Cotizaciones -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<!-- INVOICES -->
<div class="card" style="margin-bottom:0;">
<div class="card-header" style="border-left:4px solid #c9a84c;padding-left:12px;">
<span class="card-title" style="color:#c9a84c;">📄 Últimos Invoices</span>
<a href="/invoices" class="btn btn-primary btn-sm">Ver todos</a>
</div>
{% if recent_invoices %}
{% for inv in recent_invoices %}
{% set client = inv.client %}
<div class="list-item" style="padding:10px 0;">
<div class="list-item-info">
<h4 style="font-size:13px;margin-bottom:2px;">
{{ inv.number }}
{% if inv.status == 'draft' %}<span class="badge badge-gold">Borrador</span>
{% elif inv.status == 'sent' %}<span class="badge badge-blue">Enviado</span>
{% elif inv.status == 'paid' %}<span class="badge badge-green">Pagado</span>
{% else %}<span class="badge" style="background:#eee;color:#666;">Cancelado</span>{% endif %}
</h4>
<p style="font-size:11px;color:var(--gray);">{{ client.name if client else '—' }} · {{ inv.date }}</p>
</div>
<span style="font-weight:700;color:#c9a84c;font-size:13px;">${{ "%.2f"|format(inv.total) }}</span>
</div>
{% endfor %}
{% else %}
<div class="empty-state" style="padding:30px 0;">
<div class="emoji">📄</div>
<h3>No hay invoices aún</h3>
<p>Ve a <a href="/invoices">Invoices</a> para crear uno</p>
</div>
{% endif %}
</div>
<!-- COTIZACIONES -->
<div class="card" style="margin-bottom:0;">
<div class="card-header" style="border-left:4px solid #4a90d9;padding-left:12px;">
<span class="card-title" style="color:#4a90d9;">📋 Últimas Cotizaciones</span>
<a href="/quotes" class="btn btn-secondary btn-sm">Ver todas</a>
</div>
{% if recent_quotes %}
{% for qt in recent_quotes %}
{% set client = qt.client %}
<div class="list-item" style="padding:10px 0;">
<div class="list-item-info">
<h4 style="font-size:13px;margin-bottom:2px;">
{{ qt.number }}
{% if qt.status == 'draft' %}<span class="badge badge-gold">Borrador</span>
{% elif qt.status == 'sent' %}<span class="badge badge-blue">Enviado</span>
{% elif qt.status == 'accepted' %}<span class="badge badge-green">Aceptado</span>
{% else %}<span class="badge" style="background:#fde8e8;color:#c0392b;">Rechazado</span>{% endif %}
</h4>
<p style="font-size:11px;color:var(--gray);">{{ client.name if client else '—' }} · {{ qt.date }}</p>
</div>
<span style="font-weight:700;color:#4a90d9;font-size:13px;">${{ "%.2f"|format(qt.total) }}</span>
</div>
{% endfor %}
{% else %}
<div class="empty-state" style="padding:30px 0;">
<div class="emoji">📋</div>
<h3>No hay cotizaciones aún</h3>
<p>Ve a <a href="/quotes">Cotizaciones</a> para crear una</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+983
View File
@@ -0,0 +1,983 @@
{% extends "base.html" %}
{% block title %}{{ 'Invoices' if doc_type == 'invoice' else 'Cotizaciones' }} — MarineInvoice Pro{% endblock %}
{% block extra_css %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.31/jspdf.plugin.autotable.min.js"></script>
<style>
.inv-section-title { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600; }
.number-display { font-family:'Playfair Display',serif;font-size:15px;font-weight:700;color:var(--gold);letter-spacing:0.5px; }
.auto-badge { background:rgba(46,204,113,0.12);color:#2ecc71;font-size:10px;padding:2px 7px;border-radius:8px;font-weight:600; }
.send-tab { background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);color:var(--gray);padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;transition:all 0.2s; }
.send-tab:hover { background:rgba(255,255,255,0.1);color:var(--white); }
.send-tab.active { background:rgba(201,168,76,0.15);border-color:rgba(201,168,76,0.4);color:var(--gold);font-weight:600; }
</style>
{% endblock %}
{% block content %}
{% set is_invoice = doc_type == 'invoice' %}
{% set page_title = 'Invoices' if is_invoice else 'Cotizaciones' %}
{% set icon = '📄' if is_invoice else '📋' %}
<div class="flex justify-between items-center mb-4">
<div>
<h1 class="page-title">{{ icon }} {{ page_title }}</h1>
<p class="page-subtitle">{{ 'Gestión de facturas' if is_invoice else 'Gestión de cotizaciones' }}</p>
</div>
<button class="btn btn-primary" onclick="openNewDoc()">+ Nuevo {{ 'Invoice' if is_invoice else 'Cotización' }}</button>
</div>
{% if docs %}
{% for doc in docs %}
{% set comp = doc.company %}
{% set client = doc.client %}
<div class="list-item" id="doc-row-{{ doc.id }}">
<div class="list-item-info">
<h4>
<span class="number-display">{{ doc.number }}</span>
{% if doc.status == 'draft' %}<span class="badge badge-gold">Borrador</span>
{% elif doc.status == 'sent' %}<span class="badge badge-blue">Enviado</span>
{% elif doc.status == 'paid' %}<span class="badge badge-green">Pagado</span>
{% elif doc.status == 'accepted' %}<span class="badge badge-green">Aceptado</span>
{% elif doc.status == 'rejected' %}<span class="badge" style="background:rgba(231,76,60,0.15);color:#e74c3c;">Rechazado</span>
{% elif doc.status == 'cancelled' %}<span class="badge" style="background:rgba(136,146,164,0.15);color:var(--gray);">Cancelado</span>
{% endif %}
<span class="badge badge-gray">{{ doc.language|upper }}</span>
{% if doc.pdf_path %}<span class="badge" style="background:rgba(46,204,113,0.12);color:#2ecc71;">💾 PDF</span>{% endif %}
</h4>
<p>{{ comp.name if comp }} → <strong>{{ client.name if client }}</strong>{% if client and client.yacht_name %} ⛵{{ client.yacht_name }}{% endif %} · {{ doc.date }}</p>
<p style="font-weight:700;color:#c9a84c;margin-top:3px;">${{ "%.2f"|format(doc.total) }}</p>
</div>
<div class="list-item-actions">
<button class="btn btn-success btn-sm" onclick="genAndSavePDF({{ doc.id }})">📄 Generar PDF</button>
{% if doc.pdf_path %}
<button class="btn btn-secondary btn-sm" onclick="previewPDF({{ doc.id }})">👁️ Preview</button>
<a href="/documents/{{ doc.id }}/download-pdf" class="btn btn-secondary btn-sm">⬇️</a>
<button class="btn btn-secondary btn-sm"
data-id="{{ doc.id }}"
data-number="{{ doc.number|e }}"
data-email="{{ (client.email if client else '')|e }}"
data-phone="{{ (client.phone if client else '')|e }}"
data-client="{{ (client.name if client else '')|e }}"
onclick="openSendModal(this)">📤 Enviar</button>
{% endif %}
<button class="btn btn-secondary btn-sm" onclick="editDoc({{ doc.id }})">✏️</button>
{% if doc.status == 'draft' or doc.status == 'sent' %}
{% if is_invoice %}
<button class="btn btn-secondary btn-sm" onclick="setStatus({{ doc.id }},'paid')">✅ Pagado</button>
{% else %}
<button class="btn btn-secondary btn-sm" onclick="setStatus({{ doc.id }},'accepted')">✅ Aceptado</button>
{% endif %}
{% endif %}
<button class="btn btn-danger btn-sm" onclick="delDoc({{ doc.id }})">🗑️</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">{{ icon }}</div><h3>No hay {{ page_title }}</h3><p>Crea {{ 'tu primer invoice' if is_invoice else 'tu primera cotización' }}</p></div>
{% endif %}
<!-- ============ DOCUMENT MODAL ============ -->
<div class="modal-overlay" id="docModal" style="align-items:flex-start;padding-top:14px;">
<div class="modal modal-lg">
<h2 class="modal-title" id="docModalTitle">{{ icon }} Nuevo {{ 'Invoice' if is_invoice else 'Cotización' }}</h2>
<input type="hidden" id="doc-id">
<input type="hidden" id="doc-type" value="{{ doc_type }}">
<div class="grid-2">
<div class="form-group"><label>Compañía *</label>
<select id="doc-company" onchange="loadCompanyData()">
{% for c in companies %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</div>
<div class="form-group"><label>Cliente *</label><select id="doc-client"></select></div>
</div>
<!-- Auto number display -->
<div style="background:rgba(201,168,76,0.08);border:1px solid rgba(201,168,76,0.2);border-radius:10px;padding:12px 16px;margin-bottom:16px;">
<div class="flex justify-between items-center">
<div>
<div style="font-size:11px;color:var(--gray);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Número del documento</div>
<div class="number-display" id="auto-number-display">-</div>
<div style="font-size:11px;color:var(--gray);margin-top:3px;"><span class="auto-badge">AUTO</span> generado automáticamente</div>
</div>
<div>
<label style="margin-bottom:4px;">Ajuste manual (opcional)</label>
<input type="text" id="doc-number" placeholder="Solo si necesitas cambiar" style="width:200px;">
<div style="font-size:10px;color:var(--gray);margin-top:3px;">El contador interno no se altera</div>
</div>
</div>
</div>
<div class="grid-3">
<div class="form-group"><label>Fecha</label><input type="date" id="doc-date"></div>
<div class="form-group"><label>Vencimiento</label><input type="date" id="doc-due"></div>
<div class="form-group"><label>Estado</label>
<select id="doc-status">
<option value="draft">Borrador</option>
<option value="sent">Enviado</option>
{% if is_invoice %}
<option value="paid">Pagado</option>
<option value="cancelled">Cancelado</option>
{% else %}
<option value="accepted">Aceptado</option>
<option value="rejected">Rechazado</option>
{% endif %}
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group"><label>Idioma del PDF</label>
<select id="doc-lang">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<div class="form-group" id="tax-display-group">
<label>Sales Tax</label>
<div style="padding:10px 14px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:10px;font-size:14px;color:var(--gold);">
<span id="doc-tax-pct">7</span>% (configurado en la compañía)
</div>
</div>
</div>
<div class="form-group"><label>Descripción del Trabajo</label><textarea id="doc-desc" placeholder="Marine electrical inspection and repair..."></textarea></div>
<div class="divider"></div>
<div class="flex justify-between items-center" style="margin-bottom:10px;">
<span style="font-weight:600;font-size:14px;">Líneas de Servicio / Producto</span>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" onclick="addFromCatalog()">📋 Del catálogo</button>
<button class="btn btn-secondary btn-sm" onclick="addManualLine()">+ Manual</button>
</div>
</div>
<div id="line-items-container"><div style="text-align:center;padding:20px;color:var(--gray);font-size:13px;">Agrega líneas de servicio</div></div>
<div class="flex" style="justify-content:flex-end;margin-top:12px;">
<div class="totals-box">
<div class="totals-row"><span>Subtotal:</span><span id="doc-subtotal">$0.00</span></div>
<div class="totals-row"><span>Tax (<span id="doc-tax-pct2">7</span>%):</span><span id="doc-tax-amt">$0.00</span></div>
<div class="totals-row total"><span>TOTAL:</span><span id="doc-total">$0.00</span></div>
</div>
</div>
<div class="grid-2 mt-3">
<div class="form-group">
<label>Elaborado por</label>
<input type="text" id="doc-prepared-by" readonly style="background:var(--input-bg);opacity:0.7;cursor:not-allowed;">
</div>
<div class="form-group">
<label>Autorizado por</label>
<select id="doc-signed-by">
<option value="">— Seleccionar —</option>
</select>
</div>
</div>
<!-- Firma digital del usuario loggeado -->
<div class="form-group mt-3">
<label>Firma <span style="font-weight:400;color:var(--gray);font-size:12px;">(aparece en el PDF bajo "Elaborado por")</span></label>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
<button type="button" class="btn btn-secondary btn-sm" onclick="openSignaturePad()">✏️ Dibujar firma</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="saveSignatureToProfile()" id="btn-save-sig" style="display:none;">💾 Guardar en mi perfil</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="clearSignature()" style="color:var(--danger);">🗑️ Limpiar</button>
</div>
<canvas id="sig-preview" width="300" height="80" style="border:1px dashed var(--border);border-radius:6px;background:#fff;display:block;"></canvas>
<input type="hidden" id="doc-signature">
</div>
<div class="form-group"><label>Notas</label><textarea id="doc-notes" placeholder="Thank you for your business!"></textarea></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('docModal')">Cancelar</button>
<button class="btn btn-primary" onclick="saveDoc()">💾 Guardar {{ 'Invoice' if is_invoice else 'Cotización' }}</button>
</div>
</div>
</div>
<!-- ============ SIGNATURE PAD MODAL ============ -->
<div id="sigPadModal" class="modal-overlay" style="display:none;z-index:1100;">
<div class="modal" style="max-width:500px;">
<h2 class="modal-title">✍️ Dibujar Firma</h2>
<p style="font-size:12px;color:var(--gray);margin-bottom:12px;">Dibuja tu firma con el mouse o el dedo:</p>
<canvas id="sig-pad" width="460" height="160"
style="border:2px solid var(--border);border-radius:8px;background:#fff;cursor:crosshair;touch-action:none;display:block;width:100%;"></canvas>
<div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
<button class="btn btn-secondary btn-sm" onclick="clearSigPad()">🗑️ Borrar</button>
<button class="btn btn-secondary" onclick="document.getElementById('sigPadModal').style.display='none'">Cancelar</button>
<button class="btn btn-primary" onclick="saveSigPad()">✅ Usar esta firma</button>
</div>
</div>
</div>
<!-- ============ CATALOG PICKER ============ -->
<div class="modal-overlay" id="catalogModal">
<div class="modal" style="max-width:500px;">
<h2 class="modal-title">📋 Seleccionar del Catálogo</h2>
<div id="catalog-list"></div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal('catalogModal')">Cerrar</button></div>
</div>
</div>
<!-- ============ SEND MODAL ============ -->
<div class="modal-overlay" id="sendModal">
<div class="modal" style="max-width:520px;">
<h2 class="modal-title">📤 Enviar Documento</h2>
<input type="hidden" id="send-doc-id">
<div style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:14px 16px;margin-bottom:20px;">
<div style="font-size:12px;color:var(--gray);margin-bottom:4px;">Documento</div>
<div class="number-display" id="send-doc-number">-</div>
<div style="font-size:12px;color:var(--gray);margin-top:4px;">Cliente: <span id="send-client-name" style="color:var(--white);"></span></div>
</div>
<!-- TABS -->
<div style="display:flex;gap:8px;margin-bottom:20px;">
<button class="send-tab active" onclick="switchTab('email')" id="tab-email">📧 Email</button>
<button class="send-tab" onclick="switchTab('whatsapp')" id="tab-whatsapp">💬 WhatsApp</button>
</div>
<!-- EMAIL TAB -->
<div id="tab-content-email">
<div class="form-group"><label>Email del cliente *</label><input type="email" id="email-to" placeholder="cliente@email.com"></div>
<div class="form-group"><label>Asunto</label><input type="text" id="email-subject"></div>
<div class="form-group"><label>Mensaje</label><textarea id="email-body" rows="4"></textarea></div>
<button class="btn btn-success" style="width:100%;" onclick="sendByEmail()">📧 Enviar Email con PDF adjunto</button>
</div>
<!-- WHATSAPP TAB -->
<div id="tab-content-whatsapp" style="display:none;">
<div class="form-group">
<label>Número de WhatsApp del cliente (con código de país)</label>
<input type="text" id="wa-phone" placeholder="+13055551234">
<small style="color:var(--gray);font-size:11px;">+1 USA · +57 Colombia · +34 España · +52 México</small>
</div>
<div class="form-group"><label>Mensaje</label><textarea id="wa-message" rows="4"></textarea></div>
<!-- Step by step instructions -->
<div style="background:rgba(37,211,102,0.07);border:1px solid rgba(37,211,102,0.25);border-radius:10px;padding:14px 16px;margin-bottom:16px;">
<div style="font-size:12px;font-weight:700;color:#25d366;margin-bottom:10px;">📋 Al hacer clic ocurrirá lo siguiente:</div>
<div style="display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;gap:10px;align-items:flex-start;font-size:12px;">
<span style="background:#25d366;color:white;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0;">1</span>
<span style="color:var(--white);">El PDF se descarga automáticamente a tu carpeta de <strong>Descargas</strong></span>
</div>
<div style="display:flex;gap:10px;align-items:flex-start;font-size:12px;">
<span style="background:#25d366;color:white;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0;">2</span>
<span style="color:var(--white);">Se abre WhatsApp Web con el chat del cliente y el mensaje prellenado</span>
</div>
<div style="display:flex;gap:10px;align-items:flex-start;font-size:12px;">
<span style="background:#128c7e;color:white;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0;">3</span>
<span style="color:#25d366;"><strong>Tú:</strong> en WhatsApp haz clic en el clip 📎 → selecciona el PDF de tu carpeta Descargas → Enviar</span>
</div>
</div>
</div>
<button class="btn btn-success" style="width:100%;background:linear-gradient(135deg,#25d366,#128c7e);font-size:15px;padding:14px;" onclick="sendByWhatsApp()">
💬 Descargar PDF y Abrir WhatsApp
</button>
</div>
<div style="margin-top:16px;border-top:1px solid rgba(255,255,255,0.08);padding-top:14px;">
<button class="btn btn-secondary" style="width:100%;" onclick="closeModal('sendModal')">Cerrar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const DOC_TYPE = '{{ doc_type }}';
const IS_INVOICE = DOC_TYPE === 'invoice';
let lineItems = [];
let catalogProducts = [];
let currentCompanyData = null;
let autoNumber = '';
// ---- LOAD COMPANY DATA ----
async function loadCompanyData() {
const cid = document.getElementById('doc-company').value;
if (!cid) return;
const [compR, clientR, prodR, numR] = await Promise.all([
fetch(`/api/company/${cid}`),
fetch(`/api/clients/${cid}`),
fetch(`/api/products/${cid}`),
fetch(`/api/next-number/${cid}/${DOC_TYPE}`)
]);
currentCompanyData = await compR.json();
const clients = await clientR.json();
catalogProducts = await prodR.json();
const numData = await numR.json();
autoNumber = numData.number;
// Update client dropdown
const sel = document.getElementById('doc-client');
sel.innerHTML = clients.map(c =>
`<option value="${c.id}" data-email="${c.email||''}">${c.name}${c.yacht?' (⛵'+c.yacht+')':''}</option>`
).join('');
// Update tax display
const tax = currentCompanyData.tax_rate || 7;
document.getElementById('doc-tax-pct').textContent = tax;
document.getElementById('doc-tax-pct2').textContent = tax;
// Show auto number
document.getElementById('auto-number-display').textContent = autoNumber;
document.getElementById('doc-number').placeholder = autoNumber + ' (opcional)';
updateTotals();
}
// ---- LOAD CURRENT USER ----
let currentUserData = null;
async function loadCurrentUser() {
if(currentUserData) return currentUserData;
const r = await fetch('/api/me');
currentUserData = await r.json();
return currentUserData;
}
// ---- POPULATE AUTHORIZED DROPDOWN ----
async function loadAuthorizedUsers(companyId, selectedId='') {
const sel = document.getElementById('doc-signed-by');
sel.innerHTML = '<option value="">— Seleccionar —</option>';
if(!companyId) return;
const users = await (await fetch(`/api/users/${companyId}`)).json();
users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.id;
opt.textContent = u.full_name;
if(String(u.id) === String(selectedId)) opt.selected = true;
sel.appendChild(opt);
});
}
// ---- OPEN NEW ----
async function openNewDoc() {
document.getElementById('docModalTitle').textContent = (IS_INVOICE ? '📄 Nuevo Invoice' : '📋 Nueva Cotización');
document.getElementById('doc-id').value = '';
document.getElementById('doc-number').value = '';
document.getElementById('doc-status').value = 'draft';
document.getElementById('doc-lang').value = 'en';
document.getElementById('doc-desc').value = '';
document.getElementById('doc-notes').value = '';
lineItems = [];
const today = new Date().toISOString().split('T')[0];
const due = new Date(Date.now()+30*24*60*60*1000).toISOString().split('T')[0];
document.getElementById('doc-date').value = today;
document.getElementById('doc-due').value = due;
await loadCompanyData();
// Auto-fill elaborado por con usuario loggeado
const me = await loadCurrentUser();
document.getElementById('doc-prepared-by').value = me.full_name;
// Cargar dropdown de autorizado
const compId = document.getElementById('doc-company').value;
await loadAuthorizedUsers(compId);
// Cargar firma del usuario loggeado
clearSignature();
if(me.signature) setSignaturePreview(me.signature);
renderLines();
openModal('docModal');
}
// ---- EDIT ----
async function editDoc(id) {
const r = await fetch(`/documents/${id}/data`);
const doc = await r.json();
document.getElementById('doc-id').value = doc.id;
document.getElementById('doc-company').value = doc.company_id;
// Load company data first (resets client dropdown), then set client
await loadCompanyData();
// Set client after dropdown is populated
document.getElementById('doc-client').value = doc.client_id;
document.getElementById('doc-number').value = doc.number;
document.getElementById('auto-number-display').textContent = doc.number;
document.getElementById('doc-date').value = doc.date;
document.getElementById('doc-due').value = doc.due_date || '';
document.getElementById('doc-status').value = doc.status;
document.getElementById('doc-lang').value = doc.language;
document.getElementById('doc-desc').value = doc.description || '';
document.getElementById('doc-prepared-by').value = doc.prepared_by || '';
// Load authorized dropdown and select saved value
await loadAuthorizedUsers(doc.company_id, doc.signed_by || '');
// Restore signature — use saved signature or fall back to current user's
const me = await loadCurrentUser();
const sigVal = doc.signature || me.signature || '';
setSignaturePreview(sigVal);
document.getElementById('doc-notes').value = doc.notes || '';
lineItems = doc.line_items || [];
document.getElementById('docModalTitle').textContent = (IS_INVOICE ? '📄 Editar Invoice' : '📋 Editar Cotización');
renderLines();
openModal('docModal');
}
// ---- LINE ITEMS ----
function addManualLine() {
lineItems.push({desc:'',qty:1,price:0,unit:'ea'});
renderLines();
}
function addFromCatalog() {
if (!catalogProducts.length) { showToast('No hay productos para esta compañía','error'); return; }
document.getElementById('catalog-list').innerHTML = catalogProducts.map((p,i) => `
<div class="list-item" style="cursor:pointer;" onclick="addCatalogItem(${i})">
<div class="list-item-info">
<h4>${p.name}</h4>
<p>$${parseFloat(p.price).toFixed(2)} / ${p.unit}${p.desc?' · '+p.desc:''}</p>
</div>
<span style="color:var(--gold);font-size:22px;">+</span>
</div>`).join('');
openModal('catalogModal');
}
function addCatalogItem(idx) {
const p = catalogProducts[idx];
// Products and materials are taxable; services and labor are tax-exempt in Florida
const taxable = (p.item_type === 'product' || p.item_type === 'material');
lineItems.push({desc:p.name+(p.desc?' — '+p.desc:''),qty:1,price:parseFloat(p.price),unit:p.unit,taxable:taxable,item_type:p.item_type||'service'});
renderLines(); closeModal('catalogModal');
}
function removeLine(i) { lineItems.splice(i,1); renderLines(); }
function isTaxable(item) {
if (item.taxable === true) return true;
if (item.taxable === false) return false;
return (item.item_type === 'product' || item.item_type === 'material');
}
function renderLines() {
const c = document.getElementById('line-items-container');
if (!lineItems.length) {
c.innerHTML='<div style="text-align:center;padding:20px;color:var(--gray);font-size:13px;">Agrega líneas de servicio</div>';
updateTotals(); return;
}
c.innerHTML=`<table class="line-items-table"><thead><tr>
<th style="width:36%">Descripción</th><th style="width:9%">Cant.</th>
<th style="width:10%">Unidad</th><th style="width:14%">Precio Unit.</th>
<th style="width:13%">Total</th>
<th style="width:12%" title="Marcar si aplica Sales Tax (Florida: productos sí, servicios no)">Tax?</th>
<th style="width:6%"></th>
</tr></thead><tbody>${lineItems.map((item,i)=>`
<tr>
<td><input type="text" value="${item.desc}" onchange="lineItems[${i}].desc=this.value" placeholder="Descripción..."></td>
<td><input type="number" value="${item.qty}" min="0.01" step="0.01" onchange="lineItems[${i}].qty=parseFloat(this.value)||0;updateLineTotal(${i});updateTotals()"></td>
<td><select onchange="lineItems[${i}].unit=this.value">${['hr','ea','ft','job','day'].map(u=>`<option value="${u}"${item.unit===u?' selected':''}>${u}</option>`).join('')}</select></td>
<td><input type="number" value="${item.price}" min="0" step="0.01" onchange="lineItems[${i}].price=parseFloat(this.value)||0;updateLineTotal(${i});updateTotals()"></td>
<td id="line-total-${i}" style="font-weight:600;color:#c9a84c">$${(item.qty*item.price).toFixed(2)}</td>
<td style="text-align:center;">
<label style="display:flex;align-items:center;justify-content:center;gap:4px;font-size:11px;cursor:pointer;white-space:nowrap;">
<input type="checkbox" ${isTaxable(item)?'checked':''} onchange="lineItems[${i}].taxable=this.checked;updateTotals()" style="width:14px;height:14px;accent-color:#ffa500;">
<span style="color:${isTaxable(item)?'#ffa500':'var(--gray)'};">${isTaxable(item)?'Taxable':'Exempt'}</span>
</label>
</td>
<td><button class="btn btn-danger" style="padding:3px 7px;font-size:11px;" onclick="removeLine(${i})"></button></td>
</tr>`).join('')}</tbody></table>`;
updateTotals();
}
function updateLineTotal(i) {
const cell = document.getElementById(`line-total-${i}`);
if (cell) cell.textContent = '$' + (lineItems[i].qty * lineItems[i].price).toFixed(2);
}
function updateTotals() {
const sub = lineItems.reduce((s,i)=>s+(i.qty*i.price),0);
const taxPct = parseFloat(document.getElementById('doc-tax-pct2').textContent)||7;
const taxableAmt = lineItems.reduce((s,i)=>isTaxable(i)?s+(i.qty*i.price):s, 0);
const tax = taxableAmt*(taxPct/100);
const hasExempt = taxableAmt < sub && sub > 0;
document.getElementById('doc-subtotal').textContent='$'+sub.toFixed(2);
document.getElementById('doc-tax-amt').textContent='$'+tax.toFixed(2)+(hasExempt?' *':'');
document.getElementById('doc-total').textContent='$'+(sub+tax).toFixed(2);
}
// ---- SAVE ----
async function saveDoc() {
const cid = document.getElementById('doc-company').value;
const clientId = document.getElementById('doc-client').value;
if (!cid||!clientId) { showToast('Completa los campos requeridos','error'); return; }
const id = document.getElementById('doc-id').value;
const manualNumber = document.getElementById('doc-number').value.trim();
const data = {
doc_type: DOC_TYPE, company_id: cid, client_id: clientId,
number: manualNumber || autoNumber,
date: document.getElementById('doc-date').value,
due_date: document.getElementById('doc-due').value,
status: document.getElementById('doc-status').value,
language: document.getElementById('doc-lang').value,
description: document.getElementById('doc-desc').value,
prepared_by: document.getElementById('doc-prepared-by').value,
signed_by: (() => {
const sel = document.getElementById('doc-signed-by');
return sel.options[sel.selectedIndex]?.text || '';
})(),
signature: document.getElementById('doc-signature').value,
line_items: lineItems,
notes: document.getElementById('doc-notes').value
};
const url = id ? `/documents/${id}` : '/documents/new';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, {method, headers:{'Content-Type':'application/json'}, body:JSON.stringify(data)});
const res = await r.json();
if (res.success) { showToast(id?'✅ Actualizado':'✅ Guardado'); setTimeout(()=>location.reload(),1000); }
else showToast(res.error||'Error','error');
}
// ---- STATUS ----
async function setStatus(id, status) {
const r = await fetch(`/documents/${id}/status`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({status})});
const res = await r.json();
if (res.success) { showToast('✅ Estado actualizado'); setTimeout(()=>location.reload(),800); }
}
// ---- DELETE ----
async function delDoc(id) {
if (!confirm('¿Eliminar este documento?')) return;
const r = await fetch(`/documents/${id}`,{method:'DELETE'});
const res = await r.json();
if (res.success) { showToast('🗑️ Eliminado'); document.getElementById(`doc-row-${id}`).remove(); }
}
// ---- SEND MODAL ----
const TAILSCALE_BASE = 'http://100.96.43.86:5000';
function openSendModal(btn) {
const id = btn.dataset.id;
const number = btn.dataset.number;
const clientEmail = btn.dataset.email || '';
const clientPhone = btn.dataset.phone || '';
const clientName = btn.dataset.client || '';
document.getElementById('send-doc-id').value = id;
document.getElementById('send-doc-number').textContent = number;
document.getElementById('send-client-name').textContent = clientName;
// Pre-fill email
document.getElementById('email-to').value = clientEmail;
document.getElementById('email-subject').value = `${IS_INVOICE?'Invoice':'Quote'} ${number}`;
document.getElementById('email-body').value =
`Dear ${clientName||'Client'},\n\nPlease find attached ${IS_INVOICE?'invoice':'quotation'} ${number}.\n\nThank you for your business.\n\nBest regards`;
// Pre-fill WhatsApp
const phone = clientPhone.replace(/[^\d+]/g,'');
document.getElementById('wa-phone').value = phone;
const pdfLink = `${TAILSCALE_BASE}/documents/${id}/download-pdf`;
document.getElementById('wa-message').value =
`Hello ${clientName||''},\n\nPlease find your ${IS_INVOICE?'invoice':'quote'} *${number}*.\n\nDownload PDF: ${pdfLink}\n\nThank you for your business!`;
switchTab('email');
openModal('sendModal');
}
function switchTab(tab) {
['email','whatsapp'].forEach(t => {
document.getElementById(`tab-content-${t}`).style.display = t===tab ? 'block' : 'none';
document.getElementById(`tab-${t}`).classList.toggle('active', t===tab);
});
}
async function sendByEmail() {
const id = document.getElementById('send-doc-id').value;
const to = document.getElementById('email-to').value.trim();
if (!to) { showToast('Email del destinatario requerido','error'); return; }
const data = {
to_email: to,
subject: document.getElementById('email-subject').value,
body: document.getElementById('email-body').value
};
showToast('⏳ Enviando email...');
const r = await fetch(`/documents/${id}/send-email`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const res = await r.json();
if (res.success) {
showToast('📧 Email enviado exitosamente');
closeModal('sendModal');
setTimeout(()=>location.reload(), 1200);
} else showToast(res.error||'Error al enviar','error');
}
async function sendByWhatsApp() {
let phone = document.getElementById('wa-phone').value.replace(/[^\d+]/g,'');
if (!phone) { showToast('Número de WhatsApp requerido','error'); return; }
const docId = document.getElementById('send-doc-id').value;
const msg = encodeURIComponent(document.getElementById('wa-message').value);
phone = phone.replace('+','');
// Step 1: Download PDF automatically
showToast('⏳ Descargando PDF...');
const dlLink = document.createElement('a');
dlLink.href = `/documents/${docId}/download-pdf`;
dlLink.download = '';
document.body.appendChild(dlLink);
dlLink.click();
document.body.removeChild(dlLink);
// Step 2: Wait 1.5 seconds then open WhatsApp
showToast('✅ PDF descargado — abriendo WhatsApp...');
setTimeout(() => {
window.open(`https://wa.me/${phone}?text=${msg}`, '_blank');
// Step 3: Show reminder toast
setTimeout(() => {
showToast('📎 En WhatsApp: clip → selecciona el PDF de Descargas → Enviar', 'info', 6000);
}, 1500);
}, 1500);
}
function previewPDF(id) {
window.open(`/documents/${id}/preview-pdf`, '_blank');
}
// ---- PDF GENERATION + SAVE ON SERVER (using Blob/FormData — no corruption) ----
async function genAndSavePDF(docId) {
showToast('⏳ Generando PDF...');
const r = await fetch(`/documents/${docId}/data`);
const doc = await r.json();
const compR = await fetch(`/api/company/${doc.company_id}`);
const comp = await compR.json();
const cliR = await fetch(`/api/clients/${doc.company_id}`);
const clientList = await cliR.json();
const client = clientList.find(c=>c.id==doc.client_id);
if (!comp||!client) { showToast('Datos incompletos','error'); return; }
const lang = doc.language||'en';
const T = {
en:{
header: IS_INVOICE?'INVOICE':'QUOTATION',
billTo:'Bill To:', num:(IS_INVOICE?'Invoice #':'Quote #'),
date:'Date:', due:(IS_INVOICE?'Due Date:':'Valid Until:'),
desc:'Description', qty:'Qty', unit:'Unit', price:'Unit Price', amount:'Amount',
subtotal:'Subtotal:', tax:'Sales Tax', total:(IS_INVOICE?'TOTAL DUE:':'TOTAL:'),
notes:'Notes:', ein:'EIN:', license:'License #:', manager:'Manager:',
authorized:'Authorized:', vessel:'Vessel:', workDesc:'Work Description:',
taxNote:'* Applied to taxable items only'
},
es:{
header: IS_INVOICE?'FACTURA':'COTIZACION',
billTo:'Facturar a:', num:(IS_INVOICE?'Factura #':'Cotizacion #'),
date:'Fecha:', due:(IS_INVOICE?'Vencimiento:':'Valida hasta:'),
desc:'Descripcion', qty:'Cant.', unit:'Unidad', price:'Precio Unit.', amount:'Total',
subtotal:'Subtotal:', tax:'Impuesto', total:(IS_INVOICE?'TOTAL A PAGAR:':'TOTAL:'),
notes:'Notas:', ein:'EIN:', license:'Licencia:', manager:'Gerente:',
authorized:'Autorizado:', vessel:'Embarcacion:', workDesc:'Descripcion del Trabajo:',
taxNote:'* Solo aplica en productos/materiales'
}
}[lang];
const {jsPDF} = window.jspdf;
const pd = new jsPDF({unit:'mm',format:'letter'});
const pw=215.9, mg=18;
const navy=[10,22,40], gold=[201,168,76], gray=[100,110,130], white=[255,255,255], lgray=[242,244,248];
const accentColor = gold; // Always gold for readability on navy background
// Header background — taller to fit all company info
pd.setFillColor(...navy); pd.rect(0,0,pw,64,'F');
pd.setFillColor(...gold); pd.rect(0,0,pw,3,'F');
// Logo
let lx=mg;
if (comp.logo_path) {
try {
const imgR=await fetch(`/static/${comp.logo_path}`);
const blob=await imgR.blob();
const b64=await new Promise(res=>{const fr=new FileReader();fr.onload=()=>res(fr.result);fr.readAsDataURL(blob);});
pd.addImage(b64,'JPEG',mg,8,42,36); lx=mg+48;
} catch(e){}
}
// Company info
pd.setTextColor(...gold); pd.setFontSize(16); pd.setFont('helvetica','bold');
pd.text(comp.name, lx, 18);
pd.setTextColor(...white); pd.setFontSize(7.5); pd.setFont('helvetica','normal');
let cy=25;
if(comp.address){pd.text(comp.address+(comp.city?', '+comp.city:'')+(comp.state?' '+comp.state:''),lx,cy);cy+=5;}
if(comp.phone){pd.text(comp.phone,lx,cy);cy+=5;}
if(comp.email){pd.text(comp.email,lx,cy);cy+=5;}
if(comp.ein){pd.text(T.ein+' '+comp.ein,lx,cy);cy+=5;}
if(comp.license_num){pd.text(T.license+' '+comp.license_num,lx,cy);}
// Document type + number — right side
pd.setTextColor(...white); pd.setFontSize(26); pd.setFont('helvetica','bold');
pd.text(T.header, pw-mg, 22, {align:'right'});
pd.setFontSize(8.5); pd.setFont('helvetica','normal');
pd.setTextColor(...gold);
pd.text(T.num+' '+doc.number, pw-mg, 33, {align:'right'});
pd.setTextColor(...white);
pd.text(T.date+' '+doc.date, pw-mg, 41, {align:'right'});
if(doc.due_date) pd.text(T.due+' '+doc.due_date, pw-mg, 49, {align:'right'});
let y=72; const hw=(pw-mg*2)/2;
// Bill to box
pd.setFillColor(...lgray); pd.rect(mg,y,hw-4,42,'F');
pd.setTextColor(...navy); pd.setFont('helvetica','bold'); pd.setFontSize(8);
pd.text(T.billTo, mg+4, y+7);
pd.setFont('helvetica','normal'); pd.setFontSize(9);
pd.text(client.name, mg+4, y+14);
pd.setFontSize(8); let by=y+20;
if(client.contact){pd.text(client.contact,mg+4,by);by+=5;}
if(client.address){pd.text(client.address,mg+4,by);by+=5;}
if(client.city){pd.text(client.city+(client.state?', '+client.state:''),mg+4,by);by+=5;}
if(client.phone){pd.text(client.phone,mg+4,by);by+=5;}
if(client.email){pd.text(client.email,mg+4,by);by+=5;}
if(client.yacht){pd.setFont('helvetica','bold');pd.text(T.vessel+' '+client.yacht+(client.yacht_info?' · '+client.yacht_info:''),mg+4,by);}
// Work description box
if(doc.description){
pd.setFillColor(228,235,248); pd.rect(mg+hw,y,hw-4,42,'F');
pd.setFont('helvetica','bold'); pd.setFontSize(8); pd.setTextColor(...navy);
pd.text(T.workDesc, mg+hw+4, y+7);
pd.setFont('helvetica','normal');
const dlines=pd.splitTextToSize(doc.description,hw-12);
pd.text(dlines.slice(0,7),mg+hw+4,y+13);
}
y+=50;
// Helper: strip accented/special chars that jsPDF helvetica can't render
const safeText = s => (s||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[^\x00-\x7F]/g,'');
// Line items table
pd.autoTable({
startY:y,
head:[[T.desc,T.qty,T.unit,T.price,T.amount]],
body:doc.line_items.map(item=>{
const taxable = (item.taxable===true||(item.taxable!==false&&(item.item_type==='product'||item.item_type==='material')));
return [safeText(item.desc)+(taxable?' *':''), item.qty.toString(), item.unit||'ea',
'$'+parseFloat(item.price).toFixed(2), '$'+(item.qty*item.price).toFixed(2)];
}),
margin:{left:mg,right:mg},
headStyles:{fillColor:navy,textColor:white,fontStyle:'bold',fontSize:8},
bodyStyles:{fontSize:8,textColor:[40,50,70]},
alternateRowStyles:{fillColor:lgray},
columnStyles:{0:{cellWidth:'auto'},1:{cellWidth:16,halign:'center'},2:{cellWidth:18,halign:'center'},3:{cellWidth:28,halign:'right'},4:{cellWidth:28,halign:'right',fontStyle:'bold'}}
});
y=pd.lastAutoTable.finalY+6;
// Recalculate totals with per-line taxable flag
const pdfSub = doc.line_items.reduce((s,i)=>s+(i.qty*i.price),0);
const pdfTaxableAmt = doc.line_items.reduce((s,i)=>{
const taxable=(i.taxable===true||(i.taxable!==false&&(i.item_type==='product'||i.item_type==='material')));
return taxable?s+(i.qty*i.price):s;
},0);
const pdfTaxAmt = pdfTaxableAmt*(doc.tax_rate/100);
const pdfTotal = pdfSub+pdfTaxAmt;
const hasTaxExempt = pdfTaxableAmt < pdfSub && pdfSub > 0;
// Totals box
const tw=82, tx=pw-mg-tw;
const totH = hasTaxExempt ? 40 : 30;
pd.setFillColor(...lgray); pd.rect(tx,y,tw,totH,'F');
pd.setFontSize(8); pd.setTextColor(...gray); pd.setFont('helvetica','normal');
pd.text(T.subtotal,tx+4,y+8); pd.text('$'+pdfSub.toFixed(2),tx+tw-4,y+8,{align:'right'});
pd.text(T.tax+' ('+doc.tax_rate+'%):',tx+4,y+15); pd.text('$'+pdfTaxAmt.toFixed(2),tx+tw-4,y+15,{align:'right'});
if(hasTaxExempt){
pd.setFontSize(6.5); pd.setTextColor(...gray);
pd.text(T.taxNote, tx+4, y+22);
}
const totalY = hasTaxExempt ? y+28 : y+19;
pd.setFillColor(...navy); pd.rect(tx,totalY,tw,12,'F');
pd.setTextColor(...gold); pd.setFont('helvetica','bold'); pd.setFontSize(9.5);
pd.text(T.total,tx+5,totalY+8); pd.text('$'+pdfTotal.toFixed(2),tx+tw-4,totalY+8,{align:'right'});
y+=totH+8;
// Notes + Signatures — add page 2 if needed
const preparedBy = doc.prepared_by || '';
const signedBy = doc.signed_by || '';
const SIG_HEIGHT = (preparedBy || signedBy) ? 20 : 0;
const FOOTER_Y = 263;
const MAX_NOTES_Y = FOOTER_Y - SIG_HEIGHT - 8; // leave room for sigs + footer
// Notes — invoice_notes o quote_notes según tipo, más doc.notes si es diferente
const compNotes = (IS_INVOICE ? (comp.invoice_notes || '') : (comp.quote_notes || '')).trim();
const docNotes = (doc.notes || '').trim();
const extraNotes = (docNotes && docNotes !== compNotes && !compNotes.includes(docNotes)) ? docNotes : '';
const noteText = [compNotes, extraNotes].filter(Boolean).join('\n');
if(noteText){
pd.setFontSize(8); pd.setFont('helvetica','bold'); pd.setTextColor(...navy);
// If no room on page 1 for even the Notes label, add page 2
if(y + 10 > MAX_NOTES_Y) {
pd.addPage();
y = 18;
}
pd.text(T.notes, mg, y); y+=6;
const noteLines = noteText.split('\n');
for(const line of noteLines){
// Add page 2 if we're running out of space (leave room for sigs)
if(y + SIG_HEIGHT + 12 > FOOTER_Y) {
pd.addPage();
y = 18;
}
const trimmed = line.trim();
const isDashes = /^-+$/.test(trimmed);
const isHeader = trimmed === trimmed.toUpperCase() && trimmed.length > 2 && /[A-Z]/.test(trimmed);
if(isDashes){
pd.setDrawColor(...gray); pd.setLineWidth(0.3);
pd.line(mg, y-1, mg+80, y-1); y+=3;
} else if(isHeader){
pd.setFont('helvetica','bold'); pd.setTextColor(...navy); pd.setFontSize(8);
pd.text(trimmed, mg, y); y+=5;
} else {
pd.setFont('helvetica','normal'); pd.setTextColor(...gray); pd.setFontSize(7.5);
const wrapped = pd.splitTextToSize(line, pw-mg*2);
pd.text(wrapped, mg, y); y+=wrapped.length*4.5;
}
}
}
// Signature area — always after notes, never overlapping
if(preparedBy || signedBy) {
if(y + 28 > FOOTER_Y) { pd.addPage(); y = 18; }
const sigY = y + 8;
const sigW = 72;
const sigImgData = doc.signature || '';
if(preparedBy) {
pd.setDrawColor(...gray); pd.setLineWidth(0.3);
pd.line(mg, sigY, mg+sigW, sigY);
pd.setFontSize(7.5); pd.setFont('helvetica','bold'); pd.setTextColor(...navy);
pd.text(preparedBy, mg, sigY+5);
pd.setFont('helvetica','normal'); pd.setTextColor(...gray); pd.setFontSize(7);
pd.text(lang==='es'?'Elaborado por':'Prepared by', mg, sigY+10);
}
if(signedBy) {
const sigX = pw-mg-sigW;
pd.setDrawColor(...gray); pd.setLineWidth(0.3);
pd.line(sigX, sigY, sigX+sigW, sigY);
// Draw signature image above the line if available
if(sigImgData) {
try {
let imgData = sigImgData;
// If it's a URL (company signature), fetch it first
if(sigImgData.startsWith('/')) {
const r = await fetch(sigImgData);
const blob = await r.blob();
imgData = await new Promise(res=>{const fr=new FileReader();fr.onload=()=>res(fr.result);fr.readAsDataURL(blob);});
}
pd.addImage(imgData, 'PNG', sigX, sigY-18, sigW, 16);
} catch(e) {}
}
pd.setFontSize(7.5); pd.setFont('helvetica','bold'); pd.setTextColor(...navy);
pd.text(signedBy, sigX+sigW/2, sigY+5, {align:'center'});
pd.setFont('helvetica','normal'); pd.setTextColor(...gray); pd.setFontSize(7);
pd.text(lang==='es'?'Autorizado por':'Authorized by', sigX+sigW/2, sigY+10, {align:'center'});
}
}
// Footer bar — fixed at bottom
pd.setFillColor(...navy); pd.rect(0,263,pw,14,'F');
pd.setFillColor(...gold); pd.rect(0,263,pw,1,'F');
pd.setTextColor(...gold); pd.setFontSize(7); pd.setFont('helvetica','normal');
const fp=[comp.name];
if(comp.website) fp.push(comp.website);
if(comp.phone) fp.push(comp.phone);
if(comp.email) fp.push(comp.email);
pd.text(fp.join(' · '), pw/2, 271, {align:'center'});
// ---- Save to server using Blob + FormData (no base64 corruption) ----
const pdfBlob = pd.output('blob');
// Open preview immediately from local blob (no server round-trip needed for preview)
const blobUrl = URL.createObjectURL(pdfBlob);
const previewWin = window.open(blobUrl, '_blank');
if (!previewWin) showToast('⚠️ Permite pop-ups para ver el preview', 'error');
// Save to server in background
showToast('⏳ Guardando PDF en servidor...');
const formData = new FormData();
formData.append('pdf', pdfBlob, `${doc.number}.pdf`);
try {
const saveR = await fetch(`/documents/${docId}/save-pdf`, {method:'POST', body: formData});
const saveRes = await saveR.json();
if (saveRes.success) {
showToast('✅ PDF guardado — usa ⬇️ descargar o 📤 Enviar');
setTimeout(()=>location.reload(), 1500);
} else {
showToast('⚠️ Preview OK pero no se pudo guardar: '+(saveRes.error||''), 'error');
}
} catch(e) {
showToast('⚠️ Preview OK pero error de red al guardar: '+e.message, 'error');
}
}
// ============ SIGNATURE PAD ============
let sigPadDrawing = false;
let sigPadCtx = null;
function setSignaturePreview(dataUrl) {
document.getElementById('doc-signature').value = dataUrl;
const prev = document.getElementById('sig-preview');
const ctx = prev.getContext('2d');
ctx.clearRect(0, 0, prev.width, prev.height);
if(dataUrl) {
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0, prev.width, prev.height);
img.src = dataUrl;
document.getElementById('btn-save-sig').style.display = 'inline-flex';
}
}
function openSignaturePad() {
const modal = document.getElementById('sigPadModal');
modal.style.display = 'flex';
// Wait for layout so canvas has correct dimensions
requestAnimationFrame(() => {
const canvas = document.getElementById('sig-pad');
// Match canvas pixel size to its CSS display size to avoid coordinate mismatch
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width || 460;
canvas.height = 160;
sigPadCtx = canvas.getContext('2d');
sigPadCtx.clearRect(0, 0, canvas.width, canvas.height);
sigPadCtx.strokeStyle = '#1a2744';
sigPadCtx.lineWidth = 2.5;
sigPadCtx.lineCap = 'round';
sigPadCtx.lineJoin = 'round';
const getPos = (e) => {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
// Scale coordinates in case CSS size differs from canvas size
const scaleX = canvas.width / r.width;
const scaleY = canvas.height / r.height;
return { x: (src.clientX - r.left) * scaleX, y: (src.clientY - r.top) * scaleY };
};
canvas.onmousedown = canvas.ontouchstart = (e) => {
e.preventDefault(); sigPadDrawing = true;
const p = getPos(e); sigPadCtx.beginPath(); sigPadCtx.moveTo(p.x, p.y);
};
canvas.onmousemove = canvas.ontouchmove = (e) => {
e.preventDefault(); if (!sigPadDrawing) return;
const p = getPos(e); sigPadCtx.lineTo(p.x, p.y); sigPadCtx.stroke();
};
canvas.onmouseup = canvas.onmouseleave = canvas.ontouchend = () => { sigPadDrawing = false; };
});
}
function clearSigPad() {
const canvas = document.getElementById('sig-pad');
sigPadCtx && sigPadCtx.clearRect(0, 0, canvas.width, canvas.height);
}
function saveSigPad() {
const canvas = document.getElementById('sig-pad');
setSignaturePreview(canvas.toDataURL('image/png'));
document.getElementById('sigPadModal').style.display = 'none';
}
function clearSignature() {
document.getElementById('doc-signature').value = '';
const prev = document.getElementById('sig-preview');
prev.getContext('2d').clearRect(0, 0, prev.width, prev.height);
document.getElementById('btn-save-sig').style.display = 'none';
}
async function saveSignatureToProfile() {
const sig = document.getElementById('doc-signature').value;
if(!sig) return showToast('No hay firma para guardar', 'error');
const r = await fetch('/api/me/signature', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({signature: sig})
});
const res = await r.json();
if(res.success) {
currentUserData = null; // reset cache
showToast('✅ Firma guardada en tu perfil');
document.getElementById('btn-save-sig').style.display = 'none';
} else {
showToast('Error al guardar firma', 'error');
}
}</script>
{% endblock %}
+174
View File
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MarineInvoice Pro — Login</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DM Sans', sans-serif;
background: #0a1628;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
overflow: hidden;
}
body::before {
content: '';
position: absolute;
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(201,168,76,0.08) 0%, transparent 70%);
top: -200px; right: -200px;
border-radius: 50%;
}
body::after {
content: '';
position: absolute;
width: 400px; height: 400px;
background: radial-gradient(circle, rgba(26,58,107,0.4) 0%, transparent 70%);
bottom: -100px; left: -100px;
border-radius: 50%;
}
.login-card {
background: #112240;
border: 1px solid rgba(201,168,76,0.2);
border-radius: 24px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
position: relative;
z-index: 1;
}
.logo {
text-align: center;
margin-bottom: 36px;
}
.logo-icon {
width: 64px; height: 64px;
background: linear-gradient(135deg, #c9a84c, #e8c97a);
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: 16px;
box-shadow: 0 8px 24px rgba(201,168,76,0.3);
}
.logo h1 {
font-family: 'Playfair Display', serif;
font-size: 24px;
color: #f8f9fc;
font-weight: 700;
}
.logo h1 span { color: #c9a84c; }
.logo p {
color: #8892a4;
font-size: 13px;
margin-top: 6px;
}
.form-group { margin-bottom: 20px; }
label {
display: block;
font-size: 11px;
font-weight: 600;
color: #8892a4;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 8px;
}
input {
width: 100%;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
padding: 14px 16px;
color: #f8f9fc;
font-family: 'DM Sans', sans-serif;
font-size: 15px;
outline: none;
transition: all 0.2s;
}
input:focus {
border-color: #c9a84c;
background: rgba(201,168,76,0.08);
box-shadow: 0 0 0 3px rgba(201,168,76,0.1);
}
.btn-login {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #c9a84c, #e8c97a);
border: none;
border-radius: 12px;
color: #0a1628;
font-family: 'DM Sans', sans-serif;
font-size: 15px;
font-weight: 700;
cursor: pointer;
margin-top: 8px;
transition: all 0.2s;
letter-spacing: 0.3px;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(201,168,76,0.4);
}
.alert {
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 20px;
font-size: 13px;
}
.alert-error {
background: rgba(231,76,60,0.15);
border: 1px solid rgba(231,76,60,0.3);
color: #e74c3c;
}
.footer-text {
text-align: center;
margin-top: 24px;
color: #8892a4;
font-size: 12px;
}
.wave {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg, transparent, #c9a84c, transparent);
border-radius: 0 0 24px 24px;
}
</style>
</head>
<body>
<div class="login-card">
<div class="wave"></div>
<div class="logo">
<div class="logo-icon"></div>
<h1>Marine<span>Invoice</span> Pro</h1>
<p>Sistema de Facturación Náutica</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="POST">
<div class="form-group">
<label>Usuario</label>
<input type="text" name="username" placeholder="Tu usuario" required autofocus>
</div>
<div class="form-group">
<label>Contraseña</label>
<input type="password" name="password" placeholder="••••••••" required>
</div>
<button type="submit" class="btn-login">🚀 Ingresar</button>
</form>
<div class="footer-text">MarineInvoice Pro © 2024</div>
</div>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block title %}Productos — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div><h1 class="page-title">Productos & Servicios</h1><p class="page-subtitle">Catálogo de servicios</p></div>
<button class="btn btn-primary" onclick="openNewProduct()">+ Nuevo Item</button>
</div>
{% if products %}
{% set type_labels = {'service':'Servicio', 'product':'Producto', 'labor':'Mano de Obra', 'material':'Material'} %}
{% for p in products %}
<div class="list-item" id="prod-{{ p.id }}">
<div class="list-item-info">
<h4>{{ p.name }}
<span class="badge badge-gold">{{ type_labels.get(p.item_type, p.item_type) }}</span>
{% if p.item_type in ['product','material'] %}
<span class="badge" style="background:rgba(255,165,0,0.15);color:#ffa500;">📦 Taxable</span>
{% else %}
<span class="badge badge-gray">Tax-exempt</span>
{% endif %}
</h4>
<p>${{ "%.2f"|format(p.price) }} / {{ p.unit }}{% if p.company %} · {{ p.company.name }}{% endif %}</p>
{% if p.description %}<p style="font-size:11px;margin-top:2px;">{{ p.description }}</p>{% endif %}
</div>
<div class="list-item-actions">
<button class="btn btn-secondary btn-sm" data-id="{{ p.id }}" onclick="editProduct(this)">✏️ Editar</button>
<button class="btn btn-danger btn-sm" onclick="delProduct({{ p.id }})">🗑️</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">🔧</div><h3>No hay productos/servicios</h3><p>Agrega tu catálogo</p></div>
{% endif %}
<!-- Embedded JSON data store -->
<script type="application/json" id="products-data">
[{% for p in products %}{
"id": {{ p.id }},
"company_id": {{ p.company_id }},
"name": {{ p.name|tojson }},
"price": {{ p.price }},
"item_type": {{ p.item_type|tojson }},
"unit": {{ p.unit|tojson }},
"description": {{ (p.description or '')|tojson }}
}{% if not loop.last %},{% endif %}{% endfor %}]
</script>
<div class="modal-overlay" id="productModal">
<div class="modal">
<h2 class="modal-title" id="prodModalTitle">🔧 Nuevo Producto / Servicio</h2>
<input type="hidden" id="prod-id">
{% if current_user.is_superadmin() %}
<div class="form-group"><label>Compañía *</label>
<select id="prod-company">
{% for c in companies %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</div>
{% else %}
<input type="hidden" id="prod-company" value="{{ current_user.company_id }}">
{% endif %}
<div class="grid-2">
<div class="form-group"><label>Nombre *</label><input type="text" id="prod-name" placeholder="Ej: Electrical Inspection"></div>
<div class="form-group"><label>Tipo</label>
<select id="prod-type" onchange="updateTaxNote()">
<option value="service">Servicio</option>
<option value="labor">Mano de Obra</option>
<option value="product">Producto</option>
<option value="material">Material</option>
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group"><label>Precio ($) *</label><input type="number" id="prod-price" placeholder="0.00" min="0" step="0.01"></div>
<div class="form-group"><label>Unidad</label>
<select id="prod-unit">
<option value="hr">hr</option>
<option value="ea">ea</option>
<option value="ft">ft</option>
<option value="job">job</option>
<option value="day">day</option>
</select>
</div>
</div>
<div id="tax-note" style="padding:8px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;background:rgba(46,204,113,0.1);color:#2ecc71;border:1px solid rgba(46,204,113,0.2);">
✅ Servicios y mano de obra son <strong>tax-exempt</strong> en Florida
</div>
<div class="form-group"><label>Descripción</label><textarea id="prod-desc" placeholder="Descripción del servicio..."></textarea></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('productModal')">Cancelar</button>
<button class="btn btn-primary" onclick="saveProduct()">💾 Guardar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const ALL_PRODUCTS = JSON.parse(document.getElementById('products-data').textContent);
function updateTaxNote() {
const type = document.getElementById('prod-type').value;
const note = document.getElementById('tax-note');
if (type === 'product' || type === 'material') {
note.style.background='rgba(255,165,0,0.1)'; note.style.color='#ffa500'; note.style.borderColor='rgba(255,165,0,0.3)';
note.innerHTML='📦 Productos y materiales aplican <strong>Sales Tax</strong> en Florida';
} else {
note.style.background='rgba(46,204,113,0.1)'; note.style.color='#2ecc71'; note.style.borderColor='rgba(46,204,113,0.2)';
note.innerHTML='✅ Servicios y mano de obra son <strong>tax-exempt</strong> en Florida';
}
}
function openNewProduct() {
document.getElementById('prodModalTitle').textContent = '🔧 Nuevo Producto / Servicio';
document.getElementById('prod-id').value = '';
['prod-name','prod-price','prod-desc'].forEach(id => document.getElementById(id).value = '');
document.getElementById('prod-type').value = 'service';
document.getElementById('prod-unit').value = 'hr';
updateTaxNote();
openModal('productModal');
}
function editProduct(btn) {
const id = parseInt(btn.dataset.id);
const p = ALL_PRODUCTS.find(x => x.id === id);
if (!p) { showToast('Producto no encontrado', 'error'); return; }
document.getElementById('prodModalTitle').textContent = '🔧 Editar Producto / Servicio';
document.getElementById('prod-id').value = p.id;
document.getElementById('prod-name').value = p.name;
document.getElementById('prod-price').value = p.price;
document.getElementById('prod-type').value = p.item_type;
document.getElementById('prod-unit').value = p.unit;
document.getElementById('prod-desc').value = p.description;
const comp = document.getElementById('prod-company');
if (comp && comp.tagName === 'SELECT') comp.value = p.company_id;
updateTaxNote();
openModal('productModal');
}
async function saveProduct() {
const name = document.getElementById('prod-name').value.trim();
const price = document.getElementById('prod-price').value;
if (!name || !price) { showToast('Nombre y precio son requeridos', 'error'); return; }
const id = document.getElementById('prod-id').value;
const data = {
company_id: document.getElementById('prod-company').value,
name, price: parseFloat(price),
item_type: document.getElementById('prod-type').value,
unit: document.getElementById('prod-unit').value,
description: document.getElementById('prod-desc').value
};
const url = id ? `/products/${id}` : '/products/new';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, {method, headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)});
const res = await r.json();
if (res.success) { showToast(id ? '✅ Actualizado' : '✅ Creado'); setTimeout(()=>location.reload(), 900); }
else showToast(res.error || 'Error', 'error');
}
async function delProduct(id) {
if (!confirm('¿Eliminar este item?')) return;
const r = await fetch(`/products/${id}`, {method:'DELETE'});
const res = await r.json();
if (res.success) { showToast('🗑️ Eliminado'); document.getElementById(`prod-${id}`).remove(); }
}
</script>
{% endblock %}
+177
View File
@@ -0,0 +1,177 @@
{% extends "base.html" %}
{% block title %}Mi Perfil — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex items-center gap-3 mb-4">
<h1 class="page-title">👤 Mi Perfil</h1>
</div>
<div class="card" style="max-width:600px;">
<p class="section-label">Información Personal</p>
<div class="form-group">
<label>Nombre completo</label>
<input type="text" id="p-name" value="{{ current_user.full_name or '' }}" placeholder="Alvaro Romero D.">
</div>
<div class="form-group">
<label>Usuario <span style="font-size:11px;color:var(--gray);">(no se puede cambiar)</span></label>
<input type="text" value="{{ current_user.username }}" disabled style="opacity:0.5;">
</div>
<hr style="border-color:var(--border);margin:16px 0 12px;">
<p class="section-label">📧 Email para envío de documentos</p>
<p style="font-size:11px;color:var(--gray);margin-bottom:12px;">
Con este email envías cotizaciones e invoices a los clientes.<br>
El servidor SMTP está configurado en la compañía — aquí solo va tu email y contraseña.
</p>
<div class="form-group">
<label>Email corporativo</label>
<input type="email" id="p-smtp-user" value="{{ current_user.smtp_user or '' }}" placeholder="alvaro@prisayachts.com">
</div>
<div class="form-group">
<label>Título / Cargo <span style="font-size:11px;color:var(--gray);">(aparece en el "De:" del email)</span></label>
<input type="text" id="p-email-title" value="{{ current_user.email_title or '' }}" placeholder="Marine Electrician · Prisa Yachts LLC">
</div>
<div class="form-group">
<label>Contraseña del email <span style="font-size:11px;color:var(--gray);">(dejar vacío para no cambiar)</span></label>
<input type="password" id="p-smtp-password" placeholder="Contraseña del email corporativo">
</div>
<hr style="border-color:var(--border);margin:16px 0 12px;">
<p class="section-label">✍️ Mi Firma</p>
<p style="font-size:11px;color:var(--gray);margin-bottom:12px;">Aparece en los PDFs de cotizaciones e invoices que elabores.</p>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px;">
<button type="button" class="btn btn-secondary btn-sm" onclick="openSigPad()">✏️ Dibujar firma</button>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0;">
📁 Subir imagen
<input type="file" id="sig-upload" accept="image/*" style="display:none;" onchange="loadSigImage(this)">
</label>
<button type="button" class="btn btn-secondary btn-sm" onclick="clearSig()" style="color:var(--danger);">🗑️ Limpiar</button>
</div>
<canvas id="sig-preview" width="400" height="100"
style="border:1px dashed var(--border);border-radius:8px;background:#fff;display:block;max-width:100%;"></canvas>
<input type="hidden" id="p-signature" value="{{ current_user.signature or '' }}">
<button class="btn btn-secondary btn-sm mt-2" onclick="saveSignature()" id="btn-save-sig" style="display:none;">💾 Guardar firma</button>
<!-- Signature pad modal -->
<div id="sigModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:1100;align-items:center;justify-content:center;">
<div style="background:var(--navy-mid);border:1px solid rgba(255,255,255,0.12);border-radius:16px;padding:24px;width:520px;max-width:95vw;">
<h3 style="margin-bottom:12px;">✍️ Dibuja tu firma</h3>
<canvas id="sig-pad" width="460" height="160"
style="border:2px solid var(--border);border-radius:8px;background:#fff;cursor:crosshair;touch-action:none;display:block;width:100%;"></canvas>
<div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
<button class="btn btn-secondary btn-sm" onclick="clearPad()">🗑️ Borrar</button>
<button class="btn btn-secondary" onclick="document.getElementById('sigModal').style.display='none'">Cancelar</button>
<button class="btn btn-primary" onclick="usePad()">✅ Usar esta firma</button>
</div>
</div>
</div>
<hr style="border-color:var(--border);margin:16px 0 12px;">
<p class="section-label">🔐 Contraseña del sistema</p>
<div class="form-group">
<label>Nueva contraseña <span style="font-size:11px;color:var(--gray);">(dejar vacío para no cambiar)</span></label>
<input type="password" id="p-password" placeholder="Nueva contraseña de acceso">
</div>
<div class="flex gap-2 justify-end mt-3">
<button class="btn btn-primary" onclick="saveProfile()">💾 Guardar cambios</button>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Load existing signature into preview on page load
window.addEventListener('load', () => {
const sig = document.getElementById('p-signature').value;
if (sig) drawPreview(sig);
});
function drawPreview(dataUrl) {
const prev = document.getElementById('sig-preview');
const ctx = prev.getContext('2d');
ctx.clearRect(0, 0, prev.width, prev.height);
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0, prev.width, prev.height);
img.src = dataUrl;
}
function clearSig() {
document.getElementById('p-signature').value = '';
const prev = document.getElementById('sig-preview');
prev.getContext('2d').clearRect(0, 0, prev.width, prev.height);
document.getElementById('btn-save-sig').style.display = 'none';
}
function loadSigImage(input) {
const file = input.files[0]; if (!file) return;
const fr = new FileReader();
fr.onload = (e) => {
document.getElementById('p-signature').value = e.target.result;
drawPreview(e.target.result);
document.getElementById('btn-save-sig').style.display = 'inline-flex';
};
fr.readAsDataURL(file);
}
// Signature pad
let padCtx = null, padDrawing = false;
function openSigPad() {
const modal = document.getElementById('sigModal');
modal.style.display = 'flex';
requestAnimationFrame(() => {
const canvas = document.getElementById('sig-pad');
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width || 460;
canvas.height = 160;
padCtx = canvas.getContext('2d');
padCtx.clearRect(0, 0, canvas.width, canvas.height);
padCtx.strokeStyle = '#1a2744';
padCtx.lineWidth = 2.5;
padCtx.lineCap = 'round';
padCtx.lineJoin = 'round';
const getPos = (e) => {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return { x: (src.clientX - r.left) * (canvas.width / r.width), y: (src.clientY - r.top) * (canvas.height / r.height) };
};
canvas.onmousedown = canvas.ontouchstart = (e) => { e.preventDefault(); padDrawing = true; const p = getPos(e); padCtx.beginPath(); padCtx.moveTo(p.x, p.y); };
canvas.onmousemove = canvas.ontouchmove = (e) => { e.preventDefault(); if (!padDrawing) return; const p = getPos(e); padCtx.lineTo(p.x, p.y); padCtx.stroke(); };
canvas.onmouseup = canvas.onmouseleave = canvas.ontouchend = () => { padDrawing = false; };
});
}
function clearPad() { padCtx && padCtx.clearRect(0, 0, document.getElementById('sig-pad').width, 160); }
function usePad() {
const dataUrl = document.getElementById('sig-pad').toDataURL('image/png');
document.getElementById('p-signature').value = dataUrl;
drawPreview(dataUrl);
document.getElementById('btn-save-sig').style.display = 'inline-flex';
document.getElementById('sigModal').style.display = 'none';
}
async function saveSignature() {
const sig = document.getElementById('p-signature').value;
if (!sig) return;
const r = await fetch('/api/me/signature', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({signature: sig}) });
const res = await r.json();
if (res.success) { showToast('✅ Firma guardada'); document.getElementById('btn-save-sig').style.display = 'none'; }
else showToast('Error al guardar firma', 'error');
}
async function saveProfile() {
const data = {
full_name: document.getElementById('p-name').value,
smtp_user: document.getElementById('p-smtp-user').value,
email_title: document.getElementById('p-email-title').value,
smtp_password: document.getElementById('p-smtp-password').value,
password: document.getElementById('p-password').value
};
const r = await fetch('/profile/save', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
const res = await r.json();
if (res.success) showToast('✅ Perfil actualizado');
else showToast(res.error || 'Error', 'error');
}
</script>
<style>
.section-label { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600;display:block; }
</style>
{% endblock %}
+212
View File
@@ -0,0 +1,212 @@
{% extends "base.html" %}
{% block title %}Usuarios — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div><h1 class="page-title">Usuarios</h1><p class="page-subtitle">Gestión de accesos por compañía</p></div>
<button class="btn btn-primary" onclick="openNewUser()">+ Nuevo Usuario</button>
</div>
<div class="card">
{% if users %}
{% for u in users %}
<div class="list-item">
<div class="list-item-info">
<h4>{{ u.full_name or u.username }}
<span class="badge-role badge-{{ u.role }}">{{ u.role }}</span>
</h4>
<p>@{{ u.username }}
{% if u.smtp_user %} · 📧 {{ u.smtp_user }}{% elif u.email %} · {{ u.email }}{% endif %}
{% if u.email_title %} · <em>{{ u.email_title }}</em>{% endif %}
{% if u.company %} · 🏢 {{ u.company.name }}{% endif %}
</p>
</div>
<div class="list-item-actions">
<button class="btn btn-secondary btn-sm" data-id="{{ u.id }}"
data-name="{{ u.full_name or '' }}"
data-username="{{ u.username }}"
data-email="{{ u.email or '' }}"
data-role="{{ u.role }}"
data-company="{{ u.company_id or '' }}"
data-smtp="{{ u.smtp_user or '' }}"
data-title="{{ u.email_title or '' }}"
onclick="editUserFromBtn(this)">✏️ Editar</button>
<button class="btn btn-secondary btn-sm" onclick="resetPwd({{ u.id }}, '{{ u.username }}')">🔑 Contraseña</button>
{% if u.id != current_user.id %}
<button class="btn btn-danger btn-sm" onclick="delUser({{ u.id }})">🗑️</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">👤</div><h3>No hay usuarios</h3></div>
{% endif %}
</div>
<!-- USER MODAL -->
<div class="modal-overlay" id="userModal">
<div class="modal">
<h2 class="modal-title" id="userModalTitle">👤 Nuevo Usuario</h2>
<input type="hidden" id="u-id">
<p class="section-label">🔐 Acceso al sistema</p>
<div class="grid-2">
<div class="form-group"><label>Nombre completo</label><input type="text" id="u-name" placeholder="Alvaro Romero D."></div>
<div class="form-group"><label>Usuario * <span class="hint">(para login)</span></label><input type="text" id="u-username" placeholder="alvaro"></div>
</div>
<div class="grid-2">
<div class="form-group">
<label>Contraseña <span class="hint" id="pwd-hint">(mínimo 6 caracteres)</span></label>
<input type="password" id="u-password" placeholder="Contraseña de acceso al sistema">
</div>
<div class="form-group">
<label>Rol</label>
<select id="u-role" onchange="toggleCompanyField()">
<option value="user">Usuario</option>
<option value="admin">Admin de Compañía</option>
<option value="superadmin">Super Admin</option>
</select>
</div>
</div>
<div class="form-group" id="company-field">
<label>Compañía</label>
<select id="u-company">
<option value="">Sin compañía</option>
{% for c in companies %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</div>
<hr style="border-color:var(--border);margin:14px 0 10px;">
<p class="section-label">📧 Email para envío de documentos</p>
<p class="hint" style="margin-bottom:12px;">
Con este email se envían cotizaciones e invoices a los clientes, y se recibe copia automática de cada envío.<br>
El servidor SMTP (host/puerto) se configura en la Compañía — aquí solo va el email y contraseña de esta persona.
</p>
<div class="grid-2">
<div class="form-group">
<label>Email corporativo</label>
<input type="email" id="u-smtp-user" placeholder="alvaro@prisayachts.com">
<small class="hint">Ej: technical@prisayachts.com</small>
</div>
<div class="form-group">
<label>Título / Cargo</label>
<input type="text" id="u-email-title" placeholder="Marine Electrician · Prisa Yachts LLC">
<small class="hint">Aparece en el campo "De:" del email enviado al cliente</small>
</div>
</div>
<div class="form-group">
<label>Contraseña del email <span class="hint">(dejar vacío para no cambiar)</span></label>
<input type="password" id="u-smtp-password" placeholder="Contraseña del email corporativo">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('userModal')">Cancelar</button>
<button class="btn btn-primary" onclick="saveUser()">💾 Guardar</button>
</div>
</div>
</div>
<!-- PASSWORD MODAL -->
<div class="modal-overlay" id="pwdModal">
<div class="modal" style="max-width:400px;">
<h2 class="modal-title">🔑 Cambiar Contraseña de Sistema</h2>
<p id="pwd-user-name" style="color:var(--gray);margin-bottom:16px;font-size:13px;"></p>
<input type="hidden" id="pwd-user-id">
<div class="form-group"><label>Nueva Contraseña</label><input type="password" id="new-pwd" placeholder="Nueva contraseña"></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('pwdModal')">Cancelar</button>
<button class="btn btn-primary" onclick="savePwd()">💾 Guardar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleCompanyField() {
const role = document.getElementById('u-role').value;
document.getElementById('company-field').style.display = role === 'superadmin' ? 'none' : 'block';
}
function openNewUser() {
document.getElementById('userModalTitle').textContent = '👤 Nuevo Usuario';
document.getElementById('u-id').value = '';
document.getElementById('u-name').value = '';
document.getElementById('u-username').value = '';
document.getElementById('u-username').disabled = false;
document.getElementById('u-password').value = '';
document.getElementById('pwd-hint').textContent = '(mínimo 6 caracteres)';
document.getElementById('u-role').value = 'user';
document.getElementById('u-company').value = '';
document.getElementById('u-smtp-user').value = '';
document.getElementById('u-smtp-password').value = '';
document.getElementById('u-email-title').value = '';
toggleCompanyField();
openModal('userModal');
}
function editUserFromBtn(btn) {
document.getElementById('userModalTitle').textContent = '✏️ Editar Usuario';
document.getElementById('u-id').value = btn.dataset.id;
document.getElementById('u-name').value = btn.dataset.name;
document.getElementById('u-username').value = btn.dataset.username;
document.getElementById('u-username').disabled = true;
document.getElementById('u-password').value = '';
document.getElementById('pwd-hint').textContent = '(dejar vacío para no cambiar)';
document.getElementById('u-role').value = btn.dataset.role;
document.getElementById('u-company').value = btn.dataset.company;
document.getElementById('u-smtp-user').value = btn.dataset.smtp;
document.getElementById('u-smtp-password').value = '';
document.getElementById('u-email-title').value = btn.dataset.title;
toggleCompanyField();
openModal('userModal');
}
async function saveUser() {
const id = document.getElementById('u-id').value;
const data = {
full_name: document.getElementById('u-name').value,
username: document.getElementById('u-username').value,
email: document.getElementById('u-smtp-user').value,
password: document.getElementById('u-password').value,
role: document.getElementById('u-role').value,
company_id: document.getElementById('u-company').value || null,
smtp_user: document.getElementById('u-smtp-user').value,
smtp_password: document.getElementById('u-smtp-password').value,
email_title: document.getElementById('u-email-title').value
};
if (!id && (!data.username || !data.password)) {
showToast('Usuario y contraseña son requeridos', 'error'); return;
}
const url = id ? `/users/${id}/edit` : '/users/new';
const r = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
const res = await r.json();
if (res.success) { showToast(id ? '✅ Usuario actualizado' : '✅ Usuario creado'); setTimeout(()=>location.reload(), 1000); }
else showToast(res.error || 'Error', 'error');
}
function resetPwd(id, username) {
document.getElementById('pwd-user-id').value = id;
document.getElementById('pwd-user-name').textContent = 'Usuario: @' + username;
document.getElementById('new-pwd').value = '';
openModal('pwdModal');
}
async function savePwd() {
const id = document.getElementById('pwd-user-id').value;
const pwd = document.getElementById('new-pwd').value;
if (!pwd) { showToast('Ingresa una contraseña', 'error'); return; }
const r = await fetch(`/users/${id}/reset-password`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({password: pwd}) });
const res = await r.json();
if (res.success) { showToast('✅ Contraseña actualizada'); closeModal('pwdModal'); }
else showToast(res.error || 'Error', 'error');
}
async function delUser(id) {
if (!confirm('¿Eliminar este usuario?')) return;
const r = await fetch(`/users/${id}/delete`, { method:'POST' });
const res = await r.json();
if (res.success) { showToast('🗑️ Usuario eliminado'); setTimeout(()=>location.reload(), 800); }
}
</script>
<style>
.section-label { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600;display:block; }
.hint { font-size:10px; color:var(--gray); }
</style>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
@echo off
title MarineInvoice Pro
color 0A
echo.
echo ================================================
echo MarineInvoice Pro - Servidor de Facturacion
echo ================================================
echo.
echo Iniciando servidor...
echo.
echo Acceso LOCAL: http://localhost:5000
echo Acceso TAILSCALE: http://100.96.43.86:5000
echo.
echo Usuario inicial: admin
echo Contrasena: admin123
echo (Cambiala despues del primer login!)
echo.
echo Para detener el servidor: Ctrl+C
echo ================================================
echo.
cd /d "%~dp0"
C:\Users\aerom\AppData\Local\Python\pythoncore-3.14-64\python.exe app.py
pause
+1044
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
"""
Ejecutar UNA SOLA VEZ para agregar las columnas nuevas a la DB existente.
Comando: python fix_db.py
"""
import sqlite3, os
DB_PATH = os.path.join('instance', 'marineinvoice.db')
columns_to_add = [
("document", "payment_token", "TEXT"),
("company", "stripe_secret_key", "TEXT"),
("company", "stripe_publishable_key", "TEXT"),
]
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
for table, column, col_type in columns_to_add:
# Verificar si la columna ya existe antes de agregarla
cur.execute(f"PRAGMA table_info({table})")
existing = [row[1] for row in cur.fetchall()]
if column not in existing:
cur.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
print(f"✅ Agregada: {table}.{column}")
else:
print(f"⏭️ Ya existe: {table}.{column}")
conn.commit()
conn.close()
print("\n✅ DB actualizada. Ya puedes reiniciar el servidor.")
+17
View File
@@ -0,0 +1,17 @@
import re
# ── FIX 1: report_generator.py ──────────────────────────────────────────
path1 = r'C:\ALVARO_PROJECTS\Applications\MarineMaintenance\report_generator.py'
with open(path1, 'r', encoding='utf-8') as f:
src = f.read()
old = 'def photo_section(photo_list, label):\n if not photo_list: return'
new = 'def photo_section(photo_list, label):\n nonlocal story\n if not photo_list: return'
if old in src:
src = re.sub(old, new, src) # Use re.sub instead of replace for regex replacement
with open(path1, 'w', encoding='utf-8') as f:
f.write(src)
print('FIX 1 OK: nonlocal story added')
else:
print('FIX 1 SKIP: already applied or not found')
+208
View File
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}MarineInvoice Pro{% endblock %}</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--navy: #0a1628; --navy-mid: #112240; --navy-light: #1a3a6b;
--gold: #c9a84c; --gold-light: #e8c97a; --white: #f8f9fc;
--gray: #8892a4; --gray-light: #e2e8f0; --success: #2ecc71;
--danger: #e74c3c; --shadow: 0 8px 32px rgba(0,0,0,0.3);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'DM Sans', sans-serif; background: var(--navy); color: var(--white); min-height: 100vh; }
.app-header {
background: linear-gradient(135deg, var(--navy-mid), var(--navy));
border-bottom: 1px solid rgba(201,168,76,0.3);
padding: 0 24px; display: flex; align-items: center;
justify-content: space-between; height: 64px;
position: sticky; top: 0; z-index: 100;
}
.app-logo { display: flex; align-items: center; gap: 12px; text-decoration: none; }
.logo-icon {
width: 40px; height: 40px;
background: linear-gradient(135deg, var(--gold), var(--gold-light));
border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px;
}
.logo-text { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 700; color: var(--white); }
.logo-text span { color: var(--gold); }
nav { display: flex; gap: 4px; align-items: center; }
.nav-btn {
background: none; border: none; color: var(--gray);
padding: 8px 14px; border-radius: 8px; cursor: pointer;
font-family: 'DM Sans', sans-serif; font-size: 13px; font-weight: 500;
transition: all 0.2s; text-decoration: none; display: flex; align-items: center; gap: 5px;
}
.nav-btn:hover { background: rgba(255,255,255,0.08); color: var(--white); }
.nav-btn.active { background: rgba(201,168,76,0.15); color: var(--gold); }
.nav-user {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; background: rgba(255,255,255,0.06);
border-radius: 20px; font-size: 13px; color: var(--gray);
}
.nav-user strong { color: var(--white); }
.badge-role {
padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; text-transform: uppercase;
}
.badge-superadmin { background: rgba(201,168,76,0.2); color: var(--gold); }
.badge-admin { background: rgba(100,181,246,0.2); color: #64b5f6; }
.badge-user { background: rgba(46,204,113,0.2); color: var(--success); }
.main { max-width: 1200px; margin: 0 auto; padding: 32px 24px; }
.card { background: var(--navy-mid); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; margin-bottom: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.card-title { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
@media(max-width:900px) { .grid-4 { grid-template-columns: 1fr 1fr; } }
@media(max-width:768px) { .grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; } nav { display: none; } .mobile-menu { display: flex !important; } }
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 11px; font-weight: 600; color: var(--gray); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
input,select,textarea {
width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px; padding: 10px 14px; color: var(--white);
font-family: 'DM Sans', sans-serif; font-size: 14px; transition: border-color 0.2s; outline: none;
}
input:focus,select:focus,textarea:focus { border-color: var(--gold); background: rgba(201,168,76,0.08); }
select option { background: var(--navy-mid); color: var(--white); }
textarea { resize: vertical; min-height: 80px; }
.btn { padding: 9px 18px; border-radius: 10px; border: none; font-family: 'DM Sans', sans-serif; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 5px; }
.btn-primary { background: linear-gradient(135deg, var(--gold), var(--gold-light)); color: var(--navy); }
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(201,168,76,0.4); }
.btn-secondary { background: rgba(255,255,255,0.08); color: var(--white); border: 1px solid rgba(255,255,255,0.15); }
.btn-secondary:hover { background: rgba(255,255,255,0.14); }
.btn-danger { background: rgba(231,76,60,0.15); color: var(--danger); border: 1px solid rgba(231,76,60,0.3); }
.btn-success { background: linear-gradient(135deg, #27ae60, #2ecc71); color: white; }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.list-item { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; transition: all 0.2s; }
.list-item:hover { background: rgba(255,255,255,0.07); border-color: rgba(201,168,76,0.2); }
.list-item-info h4 { font-size: 14px; font-weight: 600; margin-bottom: 3px; }
.list-item-info p { font-size: 12px; color: var(--gray); }
.list-item-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.badge { padding: 3px 9px; border-radius: 20px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
.badge-gold { background: rgba(201,168,76,0.2); color: var(--gold); }
.badge-blue { background: rgba(100,181,246,0.15); color: #64b5f6; }
.badge-green { background: rgba(46,204,113,0.15); color: var(--success); }
.badge-gray { background: rgba(136,146,164,0.15); color: var(--gray); }
.stat-card { background: var(--navy-mid); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 20px; }
.stat-icon { font-size: 24px; margin-bottom: 10px; }
.stat-label { font-size: 11px; color: var(--gray); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.stat-value { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--gray); }
.empty-state .emoji { font-size: 48px; margin-bottom: 16px; }
.empty-state h3 { font-size: 18px; margin-bottom: 8px; color: var(--white); }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center; padding: 20px; overflow-y: auto; }
.modal-overlay.active { display: flex; }
.modal { background: var(--navy-mid); border: 1px solid rgba(255,255,255,0.12); border-radius: 20px; padding: 32px; width: 100%; max-width: 650px; box-shadow: var(--shadow); }
@media (max-width: 768px) {
.modal-overlay { align-items: flex-start; padding: 12px; }
.modal { padding: 20px; border-radius: 14px; margin: auto; }
}
.modal-lg { max-width: 900px; }
.modal-title { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 700; margin-bottom: 24px; color: var(--gold); }
.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 20px; }
.divider { height: 1px; background: rgba(255,255,255,0.08); margin: 20px 0; }
.page-title { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; margin-bottom: 4px; }
.page-subtitle { color: var(--gray); font-size: 13px; margin-bottom: 24px; }
.flex { display: flex; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.mt-3 { margin-top: 12px; }
.mb-4 { margin-bottom: 20px; }
.totals-box { background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.2); border-radius: 12px; padding: 16px; width: 280px; margin-left: auto; }
.totals-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 13px; color: var(--gray); }
.totals-row.total { font-size: 17px; font-weight: 700; color: var(--gold); border-top: 1px solid rgba(201,168,76,0.3); margin-top: 8px; padding-top: 10px; }
.line-items-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.line-items-table th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--gray); padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,0.1); }
.line-items-table td { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); }
.line-items-table td input, .line-items-table td select { padding: 5px 8px; font-size: 12px; }
.toast { position: fixed; top: 76px; right: 20px; background: var(--navy-mid); border: 1px solid rgba(201,168,76,0.4); border-radius: 12px; padding: 12px 18px; font-size: 13px; z-index: 2000; transform: translateX(300%); transition: transform 0.3s; box-shadow: var(--shadow); }
.toast.show { transform: translateX(0); }
.toast.success { border-color: rgba(46,204,113,0.4); color: var(--success); }
.toast.error { border-color: rgba(231,76,60,0.4); color: var(--danger); }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: var(--navy-mid); border-top: 1px solid rgba(255,255,255,0.1); padding: 6px; z-index: 100; justify-content: space-around; }
@media(max-width:768px) { .mobile-nav { display: flex; } .main { padding-bottom: 80px; } }
.mobile-nav-btn { background: none; border: none; color: var(--gray); display: flex; flex-direction: column; align-items: center; gap: 3px; font-size: 9px; font-family: 'DM Sans', sans-serif; cursor: pointer; padding: 5px 10px; border-radius: 8px; text-decoration: none; }
.mobile-nav-btn.active { color: var(--gold); background: rgba(201,168,76,0.1); }
.mobile-nav-btn .ico { font-size: 18px; }
.flash-messages { margin-bottom: 20px; }
.flash { padding: 12px 16px; border-radius: 10px; margin-bottom: 10px; font-size: 13px; }
.flash-success { background: rgba(46,204,113,0.15); border: 1px solid rgba(46,204,113,0.3); color: var(--success); }
.flash-error { background: rgba(231,76,60,0.15); border: 1px solid rgba(231,76,60,0.3); color: var(--danger); }
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<header class="app-header">
<a href="/" class="app-logo">
<div class="logo-icon"></div>
<span class="logo-text">Marine<span>Invoice</span> Pro</span>
</a>
<nav>
<a href="/" class="nav-btn {% if request.endpoint == 'dashboard' %}active{% endif %}">📊 Dashboard</a>
{% if current_user.is_superadmin() %}
<a href="/companies" class="nav-btn {% if request.endpoint == 'companies' %}active{% endif %}">🏢 Compañías</a>
<a href="/users" class="nav-btn {% if request.endpoint == 'users' %}active{% endif %}">👤 Usuarios</a>
{% endif %}
<a href="/clients" class="nav-btn {% if request.endpoint == 'clients' %}active{% endif %}">👥 Clientes</a>
<a href="/products" class="nav-btn {% if request.endpoint == 'products' %}active{% endif %}">🔧 Productos</a>
<a href="/invoices" class="nav-btn {% if request.endpoint == 'invoices' %}active{% endif %}">📄 Invoices</a>
<a href="/quotes" class="nav-btn {% if request.endpoint == 'quotes' %}active{% endif %}">📋 Cotizaciones</a>
<div class="nav-user">
<strong>{{ current_user.full_name or current_user.username }}</strong>
<span class="badge-role badge-{{ current_user.role }}">{{ current_user.role }}</span>
</div>
<a href="/profile" class="btn btn-secondary btn-sm">👤 Mi Perfil</a>
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
</nav>
</header>
<nav class="mobile-nav">
<a href="/" class="mobile-nav-btn {% if request.endpoint == 'dashboard' %}active{% endif %}"><span class="ico">📊</span>Home</a>
{% if current_user.is_superadmin() %}<a href="/companies" class="mobile-nav-btn {% if request.endpoint == 'companies' %}active{% endif %}"><span class="ico">🏢</span>Cias</a>{% endif %}
<a href="/clients" class="mobile-nav-btn {% if request.endpoint == 'clients' %}active{% endif %}"><span class="ico">👥</span>Clientes</a>
<a href="/products" class="mobile-nav-btn {% if request.endpoint == 'products' %}active{% endif %}"><span class="ico">🔧</span>Prods</a>
<a href="/invoices" class="mobile-nav-btn {% if request.endpoint == 'invoices' %}active{% endif %}"><span class="ico">📄</span>Invoices</a>
<a href="/quotes" class="mobile-nav-btn {% if request.endpoint == 'quotes' %}active{% endif %}"><span class="ico">📋</span>Cotiz.</a>
<a href="/profile" class="mobile-nav-btn {% if request.endpoint == 'profile' %}active{% endif %}"><span class="ico">👤</span>Perfil</a>
<a href="/logout" class="mobile-nav-btn"><span class="ico">🚪</span>Salir</a>
</nav>
<div class="main">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<div class="toast" id="toast"></div>
<script>
function showToast(msg, type='success', duration=3000) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `toast ${type} show`;
// info style
if (type === 'info') {
t.style.background = 'linear-gradient(135deg,#1a6fa8,#0d5c94)';
t.style.borderColor = 'rgba(26,111,168,0.4)';
} else {
t.style.background = ''; t.style.borderColor = '';
}
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove('show'), duration);
}
function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
// Modal stays open on outside click — only closes via Cancel/X buttons
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+147
View File
@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Clientes — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div><h1 class="page-title">Clientes</h1><p class="page-subtitle">Base de datos de clientes</p></div>
<button class="btn btn-primary" onclick="openNewClient()">+ Nuevo Cliente</button>
</div>
{% if clients %}
{% for c in clients %}
<div class="list-item" id="cli-{{ c.id }}">
<div class="list-item-info">
<h4>{{ c.name }} {% if c.yacht_name %}<span class="badge badge-blue">⛵ {{ c.yacht_name }}</span>{% endif %}</h4>
<p>{{ c.email or '' }}{% if c.phone %} · {{ c.phone }}{% endif %}{% if c.city %} · {{ c.city }}{% endif %}</p>
{% if c.yacht_info %}<p style="font-size:11px;margin-top:2px;">{{ c.yacht_info }}</p>{% endif %}
</div>
<div class="list-item-actions">
<button class="btn btn-secondary btn-sm" data-id="{{ c.id }}" data-company="{{ c.company_id }}" onclick="editClient(this)">✏️ Editar</button>
<button class="btn btn-danger btn-sm" onclick="delClient({{ c.id }})">🗑️</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">👥</div><h3>No hay clientes</h3><p>Agrega tu primer cliente</p></div>
{% endif %}
<!-- Hidden data store for all clients -->
<script type="application/json" id="clients-data">
[{% for c in clients %}{
"id": {{ c.id }},
"company_id": {{ c.company_id }},
"name": {{ c.name|tojson }},
"contact": {{ (c.contact or '')|tojson }},
"email": {{ (c.email or '')|tojson }},
"phone": {{ (c.phone or '')|tojson }},
"address": {{ (c.address or '')|tojson }},
"city": {{ (c.city or '')|tojson }},
"state": {{ (c.state or '')|tojson }},
"yacht_name": {{ (c.yacht_name or '')|tojson }},
"yacht_info": {{ (c.yacht_info or '')|tojson }},
"notes": {{ (c.notes or '')|tojson }}
}{% if not loop.last %},{% endif %}{% endfor %}]
</script>
<div class="modal-overlay" id="clientModal">
<div class="modal">
<h2 class="modal-title" id="clientModalTitle">👥 Nuevo Cliente</h2>
<input type="hidden" id="cli-id">
{% if current_user.is_superadmin() %}
<div class="form-group"><label>Compañía *</label>
<select id="cli-company">
{% for c in companies %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</div>
{% else %}
<input type="hidden" id="cli-company" value="{{ current_user.company_id }}">
{% endif %}
<div class="grid-2">
<div class="form-group"><label>Nombre / Empresa *</label><input type="text" id="cli-name" placeholder="John Smith"></div>
<div class="form-group"><label>Contacto</label><input type="text" id="cli-contact" placeholder="Nombre del contacto"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Email</label><input type="email" id="cli-email" placeholder="cliente@email.com"></div>
<div class="form-group"><label>Teléfono</label><input type="text" id="cli-phone" placeholder="(305) XXX-XXXX"></div>
</div>
<div class="form-group"><label>Dirección</label><input type="text" id="cli-address" placeholder="Dirección"></div>
<div class="grid-2">
<div class="form-group"><label>Ciudad</label><input type="text" id="cli-city" placeholder="Miami"></div>
<div class="form-group"><label>Estado/ZIP</label><input type="text" id="cli-state" placeholder="FL 33010"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Nombre del Yate</label><input type="text" id="cli-yacht" placeholder="Lady K"></div>
<div class="form-group"><label>Tipo / Eslora</label><input type="text" id="cli-yacht-info" placeholder="65ft Azimut"></div>
</div>
<div class="form-group"><label>Notas</label><textarea id="cli-notes" placeholder="Marina, ubicación, notas..."></textarea></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('clientModal')">Cancelar</button>
<button class="btn btn-primary" onclick="saveClient()">💾 Guardar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Load client data from embedded JSON (safe, no escaping issues)
const ALL_CLIENTS = JSON.parse(document.getElementById('clients-data').textContent);
function openNewClient() {
document.getElementById('clientModalTitle').textContent = '👥 Nuevo Cliente';
document.getElementById('cli-id').value = '';
['cli-name','cli-contact','cli-email','cli-phone','cli-address','cli-city','cli-state','cli-yacht','cli-yacht-info','cli-notes'].forEach(id => document.getElementById(id).value = '');
openModal('clientModal');
}
function editClient(btn) {
const id = parseInt(btn.dataset.id);
const c = ALL_CLIENTS.find(x => x.id === id);
if (!c) { showToast('Cliente no encontrado', 'error'); return; }
document.getElementById('clientModalTitle').textContent = '👥 Editar Cliente';
document.getElementById('cli-id').value = c.id;
document.getElementById('cli-name').value = c.name;
document.getElementById('cli-contact').value = c.contact;
document.getElementById('cli-email').value = c.email;
document.getElementById('cli-phone').value = c.phone;
document.getElementById('cli-address').value = c.address;
document.getElementById('cli-city').value = c.city;
document.getElementById('cli-state').value = c.state;
document.getElementById('cli-yacht').value = c.yacht_name;
document.getElementById('cli-yacht-info').value = c.yacht_info;
document.getElementById('cli-notes').value = c.notes;
const comp = document.getElementById('cli-company');
if (comp && comp.tagName === 'SELECT') comp.value = c.company_id;
openModal('clientModal');
}
async function saveClient() {
const name = document.getElementById('cli-name').value.trim();
if (!name) { showToast('El nombre es requerido', 'error'); return; }
const id = document.getElementById('cli-id').value;
const data = {
company_id: document.getElementById('cli-company').value,
name,
contact: document.getElementById('cli-contact').value,
email: document.getElementById('cli-email').value,
phone: document.getElementById('cli-phone').value,
address: document.getElementById('cli-address').value,
city: document.getElementById('cli-city').value,
state: document.getElementById('cli-state').value,
yacht_name: document.getElementById('cli-yacht').value,
yacht_info: document.getElementById('cli-yacht-info').value,
notes: document.getElementById('cli-notes').value
};
const url = id ? `/clients/${id}` : '/clients/new';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, {method, headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)});
const res = await r.json();
if (res.success) { showToast(id ? '✅ Cliente actualizado' : '✅ Cliente creado'); setTimeout(()=>location.reload(), 900); }
else showToast(res.error || 'Error', 'error');
}
async function delClient(id) {
if (!confirm('¿Eliminar este cliente?')) return;
const r = await fetch(`/clients/${id}`, {method:'DELETE'});
const res = await r.json();
if (res.success) { showToast('🗑️ Cliente eliminado'); document.getElementById(`cli-${id}`).remove(); }
}
</script>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Compañías — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div><h1 class="page-title">Compañías</h1><p class="page-subtitle">Gestiona tus empresas</p></div>
<a href="/companies/new" class="btn btn-primary">+ Nueva Compañía</a>
</div>
{% if companies %}
{% for c in companies %}
<div class="list-item">
<div class="flex items-center gap-3">
{% if c.logo_path %}
<img src="/static/{{ c.logo_path }}" style="width:50px;height:50px;object-fit:contain;border-radius:8px;background:white;padding:4px;">
{% else %}
<div style="width:50px;height:50px;background:var(--navy-light);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:24px;">🏢</div>
{% endif %}
<div class="list-item-info">
<h4>{{ c.name }}</h4>
<p>EIN: {{ c.ein }} · Tax: {{ c.tax_rate }}% · {{ c.city or '' }} {{ c.state or '' }}</p>
{% if c.manager %}<p style="font-size:11px;margin-top:2px;">Gerente: {{ c.manager }}</p>{% endif %}
</div>
</div>
<div class="list-item-actions">
<a href="/companies/{{ c.id }}/edit" class="btn btn-secondary btn-sm">✏️ Editar</a>
<button class="btn btn-danger btn-sm" onclick="delCompany({{ c.id }})">🗑️</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">🏢</div><h3>No hay compañías</h3><p>Crea tu primera compañía</p></div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
async function delCompany(id) {
if (!confirm('¿Eliminar esta compañía?')) return;
const r = await fetch(`/companies/${id}/delete`, {method:'POST'});
const res = await r.json();
if (res.success) { showToast('🗑️ Compañía eliminada'); setTimeout(()=>location.reload(), 1000); }
}
</script>
{% endblock %}
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}{{ 'Editar' if company else 'Nueva' }} Compañía{% endblock %}
{% block content %}
<div class="flex items-center gap-3 mb-4">
<a href="/companies" class="btn btn-secondary btn-sm">← Volver</a>
<h1 class="page-title">{{ 'Editar' if company else 'Nueva' }} Compañía</h1>
</div>
<div class="card">
<form method="POST" enctype="multipart/form-data">
<p class="inv-section-title">Información General</p>
<div class="grid-2">
<div class="form-group"><label>Nombre *</label><input type="text" name="name" value="{{ company.name if company else '' }}" required placeholder="Ej: Prisa Yachts"></div>
<div class="form-group"><label>EIN / Tax ID</label><input type="text" name="ein" value="{{ company.ein if company else '' }}" placeholder="XX-XXXXXXX"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Licencia</label><input type="text" name="license" value="{{ company.license_num if company else '' }}" placeholder="Número de licencia"></div>
<div class="form-group"><label>Teléfono</label><input type="text" name="phone" value="{{ company.phone if company else '' }}" placeholder="(305) XXX-XXXX"></div>
</div>
<div class="form-group"><label>Dirección</label><input type="text" name="address" value="{{ company.address if company else '' }}" placeholder="Dirección"></div>
<div class="grid-2">
<div class="form-group"><label>Ciudad</label><input type="text" name="city" value="{{ company.city if company else '' }}" placeholder="Miami"></div>
<div class="form-group"><label>Estado / ZIP</label><input type="text" name="state" value="{{ company.state if company else '' }}" placeholder="FL 33010"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Email</label><input type="email" name="email" value="{{ company.email if company else '' }}" placeholder="info@empresa.com"></div>
<div class="form-group"><label>Website</label><input type="text" name="website" value="{{ company.website if company else '' }}" placeholder="www.empresa.com"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Gerente / Owner</label><input type="text" name="manager" value="{{ company.manager if company else '' }}" placeholder="Nombre completo"></div>
<div class="form-group"><label>Persona Autorizada</label><input type="text" name="authorized" value="{{ company.authorized if company else '' }}" placeholder="Nombre completo"></div>
</div>
<div class="grid-2">
<div class="form-group"><label>Sales Tax %</label><input type="number" name="tax_rate" value="{{ company.tax_rate if company else '7' }}" min="0" max="20" step="0.1"></div>
<div class="form-group">
<label>Logo</label>
{% if company and company.logo_path %}<img src="/static/{{ company.logo_path }}" style="height:44px;margin-bottom:6px;display:block;background:white;padding:3px;border-radius:5px;">{% endif %}
<input type="file" name="logo" accept="image/*" style="padding:7px;">
</div>
</div>
<div class="divider"></div>
<p class="inv-section-title">📋 Formato de Numeración Automática</p>
<div style="background:rgba(201,168,76,0.06);border:1px solid rgba(201,168,76,0.15);border-radius:10px;padding:12px 16px;font-size:12px;color:var(--gray);margin-bottom:16px;">
El número se genera como: <strong style="color:var(--gold)">PREFIJO-001-MMAAAA</strong> &nbsp;·&nbsp;
Ej: <code>IPY</code><strong style="color:var(--gold)">IPY-001-032026</strong> &nbsp;·&nbsp; <code>QPY</code><strong style="color:var(--gold)">QPY-001-032026</strong><br>
<span style="margin-top:4px;display:block;">El contador reinicia automáticamente cada mes. El usuario puede ajustar el número en el documento pero el contador interno no se altera.</span>
</div>
<div class="grid-2">
<div class="form-group">
<label>Prefijo de Invoice (solo letras)</label>
<input type="text" name="invoice_prefix" value="{{ company.invoice_prefix if company else 'INV' }}" placeholder="Ej: IPY" maxlength="8" style="text-transform:uppercase;" oninput="this.value=this.value.toUpperCase();updatePreview()">
<small style="color:var(--gray);font-size:11px;margin-top:4px;display:block;">Preview: <span id="inv-preview" style="color:var(--gold);font-weight:700;"></span></small>
</div>
<div class="form-group">
<label>Prefijo de Cotización (solo letras)</label>
<input type="text" name="quote_prefix" value="{{ company.quote_prefix if company else 'QUO' }}" placeholder="Ej: QPY" maxlength="8" style="text-transform:uppercase;" oninput="this.value=this.value.toUpperCase();updatePreview()">
<small style="color:var(--gray);font-size:11px;margin-top:4px;display:block;">Preview: <span id="quo-preview" style="color:var(--gold);font-weight:700;"></span></small>
</div>
</div>
<div class="divider"></div>
<p class="inv-section-title">📧 Servidor SMTP para envío de PDFs</p>
<div style="background:rgba(201,168,76,0.06);border:1px solid rgba(201,168,76,0.15);border-radius:10px;padding:12px 16px;font-size:12px;color:var(--gray);margin-bottom:14px;">
⚙️ Aquí solo se configura el <strong style="color:var(--gold)">servidor</strong> — es el mismo para todos.<br>
El email y contraseña de cada persona se configura en <strong style="color:var(--gold)">Usuarios</strong>.<br>
<span style="margin-top:4px;display:block;">Namecheap Private Email: <code>mail.privateemail.com</code> puerto <code>587</code></span>
</div>
<div class="grid-2">
<div class="form-group"><label>Servidor SMTP</label><input type="text" name="smtp_host" value="{{ company.smtp_host if company else '' }}" placeholder="mail.privateemail.com"></div>
<div class="form-group"><label>Puerto</label><input type="number" name="smtp_port" value="{{ company.smtp_port if company else '587' }}"></div>
</div>
<div class="divider"></div>
<p class="inv-section-title">💳 Stripe — Pagos con Tarjeta</p>
<div style="background:rgba(99,102,241,0.06);border:1px solid rgba(99,102,241,0.2);border-radius:10px;padding:12px 16px;font-size:12px;color:var(--gray);margin-bottom:14px;">
Cada compañía tiene sus propias claves. Obtén las claves en
<a href="https://dashboard.stripe.com/apikeys" target="_blank" style="color:#818cf8;">dashboard.stripe.com → Developers → API Keys</a>.<br>
<span style="margin-top:4px;display:block;">La Secret Key no se muestra después de guardada — déjala vacía si no quieres cambiarla.</span>
</div>
<div class="grid-2">
<div class="form-group">
<label>Publishable Key <span style="font-size:10px;color:var(--gray);">(pk_live_... o pk_test_...)</span></label>
<input type="text" name="stripe_publishable_key"
value="{{ company.stripe_publishable_key if company else '' }}"
placeholder="pk_live_...">
</div>
<div class="form-group">
<label>Secret Key <span style="font-size:10px;color:var(--gray);">(sk_live_... o sk_test_...)</span></label>
<input type="password" name="stripe_secret_key"
placeholder="{{ '••••••• (ya configurada — dejar vacío para no cambiar)' if company and company.stripe_secret_key else 'sk_live_...' }}">
</div>
</div>
<div class="divider"></div>
<div class="form-group">
<label>Notas para Invoices</label>
<textarea name="invoice_notes" rows="6" placeholder="Payment terms, methods of payment...">{{ company.invoice_notes if company else '' }}</textarea>
</div>
<div class="form-group">
<label>Notas para Cotizaciones</label>
<textarea name="quote_notes" rows="6" placeholder="Quotation validity, payment terms...">{{ company.quote_notes if company else '' }}</textarea>
</div>
<div class="flex gap-2 justify-between mt-3">
<a href="/companies" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary">💾 Guardar Compañía</button>
</div>
</form>
</div>
<style>
.inv-section-title { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:14px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600; }
code { background:rgba(201,168,76,0.12);padding:2px 6px;border-radius:4px;font-size:11px; }
</style>
<script>
function updatePreview() {
const now = new Date();
const monthStr = String(now.getMonth()+1).padStart(2,'0')+String(now.getFullYear());
const ip = document.querySelector('[name="invoice_prefix"]').value||'INV';
const qp = document.querySelector('[name="quote_prefix"]').value||'QUO';
document.getElementById('inv-preview').textContent = ip+'-001-'+monthStr;
document.getElementById('quo-preview').textContent = qp+'-001-'+monthStr;
}
updatePreview();
</script>
{% endblock %}
+92
View File
@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Dashboard — MarineInvoice Pro{% endblock %}
{% block content %}
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">Bienvenido, {{ current_user.full_name or current_user.username }}</p>
<!-- Stat cards -->
<div class="grid-4 mb-4">
<div class="stat-card"><div class="stat-icon">🏢</div><div class="stat-label">Compañías</div><div class="stat-value">{{ companies|length }}</div></div>
<div class="stat-card"><div class="stat-icon">👥</div><div class="stat-label">Clientes</div><div class="stat-value">{{ total_clients }}</div></div>
<div class="stat-card"><div class="stat-icon">📄</div><div class="stat-label">Invoices</div><div class="stat-value">{{ total_invoices }}</div></div>
<div class="stat-card"><div class="stat-icon">📋</div><div class="stat-label">Cotizaciones</div><div class="stat-value">{{ total_quotes }}</div></div>
</div>
<!-- Total facturado banner -->
<div style="background:linear-gradient(135deg,#1a2744 0%,#243560 100%);border-radius:12px;padding:20px 28px;margin-bottom:24px;display:flex;align-items:center;justify-content:space-between;">
<div>
<div style="color:#c9a84c;font-size:12px;font-weight:600;letter-spacing:1px;text-transform:uppercase;">Total Facturado (Invoices)</div>
<div style="color:white;font-size:32px;font-weight:700;margin-top:4px;">${{ "%.2f"|format(total_billed) }}</div>
</div>
<div style="font-size:40px;opacity:0.4;">💰</div>
</div>
<!-- Two columns: Invoices | Cotizaciones -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<!-- INVOICES -->
<div class="card" style="margin-bottom:0;">
<div class="card-header" style="border-left:4px solid #c9a84c;padding-left:12px;">
<span class="card-title" style="color:#c9a84c;">📄 Últimos Invoices</span>
<a href="/invoices" class="btn btn-primary btn-sm">Ver todos</a>
</div>
{% if recent_invoices %}
{% for inv in recent_invoices %}
{% set client = inv.client %}
<div class="list-item" style="padding:10px 0;">
<div class="list-item-info">
<h4 style="font-size:13px;margin-bottom:2px;">
{{ inv.number }}
{% if inv.status == 'draft' %}<span class="badge badge-gold">Borrador</span>
{% elif inv.status == 'sent' %}<span class="badge badge-blue">Enviado</span>
{% elif inv.status == 'paid' %}<span class="badge badge-green">Pagado</span>
{% else %}<span class="badge" style="background:#eee;color:#666;">Cancelado</span>{% endif %}
</h4>
<p style="font-size:11px;color:var(--gray);">{{ client.name if client else '—' }} · {{ inv.date }}</p>
</div>
<span style="font-weight:700;color:#c9a84c;font-size:13px;">${{ "%.2f"|format(inv.total) }}</span>
</div>
{% endfor %}
{% else %}
<div class="empty-state" style="padding:30px 0;">
<div class="emoji">📄</div>
<h3>No hay invoices aún</h3>
<p>Ve a <a href="/invoices">Invoices</a> para crear uno</p>
</div>
{% endif %}
</div>
<!-- COTIZACIONES -->
<div class="card" style="margin-bottom:0;">
<div class="card-header" style="border-left:4px solid #4a90d9;padding-left:12px;">
<span class="card-title" style="color:#4a90d9;">📋 Últimas Cotizaciones</span>
<a href="/quotes" class="btn btn-secondary btn-sm">Ver todas</a>
</div>
{% if recent_quotes %}
{% for qt in recent_quotes %}
{% set client = qt.client %}
<div class="list-item" style="padding:10px 0;">
<div class="list-item-info">
<h4 style="font-size:13px;margin-bottom:2px;">
{{ qt.number }}
{% if qt.status == 'draft' %}<span class="badge badge-gold">Borrador</span>
{% elif qt.status == 'sent' %}<span class="badge badge-blue">Enviado</span>
{% elif qt.status == 'accepted' %}<span class="badge badge-green">Aceptado</span>
{% else %}<span class="badge" style="background:#fde8e8;color:#c0392b;">Rechazado</span>{% endif %}
</h4>
<p style="font-size:11px;color:var(--gray);">{{ client.name if client else '—' }} · {{ qt.date }}</p>
</div>
<span style="font-weight:700;color:#4a90d9;font-size:13px;">${{ "%.2f"|format(qt.total) }}</span>
</div>
{% endfor %}
{% else %}
<div class="empty-state" style="padding:30px 0;">
<div class="emoji">📋</div>
<h3>No hay cotizaciones aún</h3>
<p>Ve a <a href="/quotes">Cotizaciones</a> para crear una</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MarineInvoice Pro — Login</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DM Sans', sans-serif;
background: #0a1628;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
overflow: hidden;
}
body::before {
content: '';
position: absolute;
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(201,168,76,0.08) 0%, transparent 70%);
top: -200px; right: -200px;
border-radius: 50%;
}
body::after {
content: '';
position: absolute;
width: 400px; height: 400px;
background: radial-gradient(circle, rgba(26,58,107,0.4) 0%, transparent 70%);
bottom: -100px; left: -100px;
border-radius: 50%;
}
.login-card {
background: #112240;
border: 1px solid rgba(201,168,76,0.2);
border-radius: 24px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
position: relative;
z-index: 1;
}
.logo {
text-align: center;
margin-bottom: 36px;
}
.logo-icon {
width: 64px; height: 64px;
background: linear-gradient(135deg, #c9a84c, #e8c97a);
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: 16px;
box-shadow: 0 8px 24px rgba(201,168,76,0.3);
}
.logo h1 {
font-family: 'Playfair Display', serif;
font-size: 24px;
color: #f8f9fc;
font-weight: 700;
}
.logo h1 span { color: #c9a84c; }
.logo p {
color: #8892a4;
font-size: 13px;
margin-top: 6px;
}
.form-group { margin-bottom: 20px; }
label {
display: block;
font-size: 11px;
font-weight: 600;
color: #8892a4;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 8px;
}
input {
width: 100%;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
padding: 14px 16px;
color: #f8f9fc;
font-family: 'DM Sans', sans-serif;
font-size: 15px;
outline: none;
transition: all 0.2s;
}
input:focus {
border-color: #c9a84c;
background: rgba(201,168,76,0.08);
box-shadow: 0 0 0 3px rgba(201,168,76,0.1);
}
.btn-login {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #c9a84c, #e8c97a);
border: none;
border-radius: 12px;
color: #0a1628;
font-family: 'DM Sans', sans-serif;
font-size: 15px;
font-weight: 700;
cursor: pointer;
margin-top: 8px;
transition: all 0.2s;
letter-spacing: 0.3px;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(201,168,76,0.4);
}
.alert {
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 20px;
font-size: 13px;
}
.alert-error {
background: rgba(231,76,60,0.15);
border: 1px solid rgba(231,76,60,0.3);
color: #e74c3c;
}
.footer-text {
text-align: center;
margin-top: 24px;
color: #8892a4;
font-size: 12px;
}
.wave {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg, transparent, #c9a84c, transparent);
border-radius: 0 0 24px 24px;
}
</style>
</head>
<body>
<div class="login-card">
<div class="wave"></div>
<div class="logo">
<div class="logo-icon"></div>
<h1>Marine<span>Invoice</span> Pro</h1>
<p>Sistema de Facturación Náutica</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="POST">
<div class="form-group">
<label>Usuario</label>
<input type="text" name="username" placeholder="Tu usuario" required autofocus>
</div>
<div class="form-group">
<label>Contraseña</label>
<input type="password" name="password" placeholder="••••••••" required>
</div>
<button type="submit" class="btn-login">🚀 Ingresar</button>
</form>
<div class="footer-text">MarineInvoice Pro © 2024</div>
</div>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pay Invoice {{ doc.number }}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'DM Sans', sans-serif; background: #f0f4f8; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.card { background: white; border-radius: 20px; padding: 40px; max-width: 480px; width: 100%; box-shadow: 0 8px 40px rgba(0,0,0,0.12); }
.logo { font-size: 22px; font-weight: 700; color: #0a1628; margin-bottom: 4px; }
.company-sub { font-size: 13px; color: #8892a4; margin-bottom: 28px; }
.divider { height: 1px; background: #e8ecf2; margin: 20px 0; }
.label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: #8892a4; font-weight: 600; margin-bottom: 4px; }
.value { font-size: 15px; color: #1a2540; font-weight: 500; margin-bottom: 16px; }
.amount-box { background: #0a1628; border-radius: 14px; padding: 24px; text-align: center; margin: 24px 0; }
.amount-label { font-size: 12px; color: #8892a4; margin-bottom: 6px; letter-spacing: 0.5px; }
.amount-value { font-size: 38px; font-weight: 700; color: #c9a84c; }
.btn-pay { display: block; width: 100%; background: linear-gradient(135deg, #6366f1, #4f46e5); color: white; border: none; border-radius: 12px; padding: 16px; font-size: 16px; font-weight: 600; cursor: pointer; text-align: center; text-decoration: none; transition: opacity 0.2s; }
.btn-pay:hover { opacity: 0.9; }
.secure { text-align: center; font-size: 11px; color: #8892a4; margin-top: 12px; }
.invoice-num { display: inline-block; background: rgba(201,168,76,0.1); color: #b8962a; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="card">
{% if company and company.logo_path %}
<img src="{{ request.host_url }}static/{{ company.logo_path }}" style="height:50px;margin-bottom:16px;display:block;">
{% endif %}
<div class="logo">{{ company.name if company else 'Invoice Payment' }}</div>
<div class="company-sub">{{ company.email if company else '' }}</div>
<span class="invoice-num">Invoice {{ doc.number }}</span>
<div class="amount-box">
<div class="amount-label">INVOICE TOTAL</div>
<div class="amount-value">${{ "%.2f"|format(doc.total) }}</div>
</div>
{% set fee = (doc.total * 0.029 + 0.30) %}
{% set total_with_fee = doc.total + fee %}
<div style="background:#f8fafc;border-radius:12px;padding:16px;margin-bottom:20px;font-size:14px;">
<div style="display:flex;justify-content:space-between;padding:5px 0;color:#555;">
<span>Invoice subtotal</span><strong>${{ "%.2f"|format(doc.total) }}</strong>
</div>
<div style="display:flex;justify-content:space-between;padding:5px 0;color:#555;">
<span>Credit card fee (2.9% + $0.30)</span><strong>${{ "%.2f"|format(fee) }}</strong>
</div>
<div style="display:flex;justify-content:space-between;padding:8px 0;margin-top:6px;border-top:1px solid #e2e8f0;font-size:16px;font-weight:700;color:#0a1628;">
<span>Total charged</span><span>${{ "%.2f"|format(total_with_fee) }}</span>
</div>
</div>
<form action="/pay/{{ token }}/checkout" method="POST">
<button type="submit" class="btn-pay">💳 Pay Now with Card</button>
</form>
<div class="secure">🔒 Secured by Stripe · Your payment info is never stored on our servers</div>
</div>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment {{ 'Already Received' if already_paid else 'Successful' }}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'DM Sans', sans-serif; background: #f0f4f8; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.card { background: white; border-radius: 20px; padding: 48px 40px; max-width: 440px; width: 100%; box-shadow: 0 8px 40px rgba(0,0,0,0.12); text-align: center; }
.icon { font-size: 64px; margin-bottom: 20px; }
.title { font-size: 26px; font-weight: 700; color: #0a1628; margin-bottom: 8px; }
.sub { font-size: 15px; color: #8892a4; margin-bottom: 28px; line-height: 1.5; }
.detail { background: #f8fafc; border-radius: 12px; padding: 20px; margin-bottom: 24px; text-align: left; }
.detail-row { display: flex; justify-content: space-between; font-size: 14px; padding: 6px 0; color: #555; }
.detail-row strong { color: #0a1628; }
.amount { font-size: 32px; font-weight: 700; color: #2ecc71; margin: 16px 0; }
</style>
</head>
<body>
<div class="card">
{% if already_paid %}
<div class="icon"></div>
<div class="title">Already Paid</div>
<div class="sub">This invoice has already been paid. Thank you!</div>
{% else %}
<div class="icon">🎉</div>
<div class="title">Payment Successful!</div>
<div class="sub">Thank you for your payment. A receipt has been sent to your email.</div>
{% endif %}
<div class="detail">
<div class="detail-row"><span>Invoice</span><strong>{{ doc.number }}</strong></div>
<div class="detail-row"><span>Company</span><strong>{{ company.name if company else '—' }}</strong></div>
<div class="detail-row"><span>Amount</span><strong style="color:#2ecc71;">${{ "%.2f"|format(doc.total) }}</strong></div>
</div>
<div style="font-size:12px;color:#aaa;">You may close this window.</div>
</div>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block title %}Productos — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div><h1 class="page-title">Productos & Servicios</h1><p class="page-subtitle">Catálogo de servicios</p></div>
<button class="btn btn-primary" onclick="openNewProduct()">+ Nuevo Item</button>
</div>
{% if products %}
{% set type_labels = {'service':'Servicio', 'product':'Producto', 'labor':'Mano de Obra', 'material':'Material'} %}
{% for p in products %}
<div class="list-item" id="prod-{{ p.id }}">
<div class="list-item-info">
<h4>{{ p.name }}
<span class="badge badge-gold">{{ type_labels.get(p.item_type, p.item_type) }}</span>
{% if p.item_type in ['product','material'] %}
<span class="badge" style="background:rgba(255,165,0,0.15);color:#ffa500;">📦 Taxable</span>
{% else %}
<span class="badge badge-gray">Tax-exempt</span>
{% endif %}
</h4>
<p>${{ "%.2f"|format(p.price) }} / {{ p.unit }}{% if p.company %} · {{ p.company.name }}{% endif %}</p>
{% if p.description %}<p style="font-size:11px;margin-top:2px;">{{ p.description }}</p>{% endif %}
</div>
<div class="list-item-actions">
<button class="btn btn-secondary btn-sm" data-id="{{ p.id }}" onclick="editProduct(this)">✏️ Editar</button>
<button class="btn btn-danger btn-sm" onclick="delProduct({{ p.id }})">🗑️</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">🔧</div><h3>No hay productos/servicios</h3><p>Agrega tu catálogo</p></div>
{% endif %}
<!-- Embedded JSON data store -->
<script type="application/json" id="products-data">
[{% for p in products %}{
"id": {{ p.id }},
"company_id": {{ p.company_id }},
"name": {{ p.name|tojson }},
"price": {{ p.price }},
"item_type": {{ p.item_type|tojson }},
"unit": {{ p.unit|tojson }},
"description": {{ (p.description or '')|tojson }}
}{% if not loop.last %},{% endif %}{% endfor %}]
</script>
<div class="modal-overlay" id="productModal">
<div class="modal">
<h2 class="modal-title" id="prodModalTitle">🔧 Nuevo Producto / Servicio</h2>
<input type="hidden" id="prod-id">
{% if current_user.is_superadmin() %}
<div class="form-group"><label>Compañía *</label>
<select id="prod-company">
{% for c in companies %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</div>
{% else %}
<input type="hidden" id="prod-company" value="{{ current_user.company_id }}">
{% endif %}
<div class="grid-2">
<div class="form-group"><label>Nombre *</label><input type="text" id="prod-name" placeholder="Ej: Electrical Inspection"></div>
<div class="form-group"><label>Tipo</label>
<select id="prod-type" onchange="updateTaxNote()">
<option value="service">Servicio</option>
<option value="labor">Mano de Obra</option>
<option value="product">Producto</option>
<option value="material">Material</option>
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group"><label>Precio ($) *</label><input type="number" id="prod-price" placeholder="0.00" min="0" step="0.01"></div>
<div class="form-group"><label>Unidad</label>
<select id="prod-unit">
<option value="hr">hr</option>
<option value="ea">ea</option>
<option value="ft">ft</option>
<option value="job">job</option>
<option value="day">day</option>
</select>
</div>
</div>
<div id="tax-note" style="padding:8px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;background:rgba(46,204,113,0.1);color:#2ecc71;border:1px solid rgba(46,204,113,0.2);">
✅ Servicios y mano de obra son <strong>tax-exempt</strong> en Florida
</div>
<div class="form-group"><label>Descripción</label><textarea id="prod-desc" placeholder="Descripción del servicio..."></textarea></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('productModal')">Cancelar</button>
<button class="btn btn-primary" onclick="saveProduct()">💾 Guardar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const ALL_PRODUCTS = JSON.parse(document.getElementById('products-data').textContent);
function updateTaxNote() {
const type = document.getElementById('prod-type').value;
const note = document.getElementById('tax-note');
if (type === 'product' || type === 'material') {
note.style.background='rgba(255,165,0,0.1)'; note.style.color='#ffa500'; note.style.borderColor='rgba(255,165,0,0.3)';
note.innerHTML='📦 Productos y materiales aplican <strong>Sales Tax</strong> en Florida';
} else {
note.style.background='rgba(46,204,113,0.1)'; note.style.color='#2ecc71'; note.style.borderColor='rgba(46,204,113,0.2)';
note.innerHTML='✅ Servicios y mano de obra son <strong>tax-exempt</strong> en Florida';
}
}
function openNewProduct() {
document.getElementById('prodModalTitle').textContent = '🔧 Nuevo Producto / Servicio';
document.getElementById('prod-id').value = '';
['prod-name','prod-price','prod-desc'].forEach(id => document.getElementById(id).value = '');
document.getElementById('prod-type').value = 'service';
document.getElementById('prod-unit').value = 'hr';
updateTaxNote();
openModal('productModal');
}
function editProduct(btn) {
const id = parseInt(btn.dataset.id);
const p = ALL_PRODUCTS.find(x => x.id === id);
if (!p) { showToast('Producto no encontrado', 'error'); return; }
document.getElementById('prodModalTitle').textContent = '🔧 Editar Producto / Servicio';
document.getElementById('prod-id').value = p.id;
document.getElementById('prod-name').value = p.name;
document.getElementById('prod-price').value = p.price;
document.getElementById('prod-type').value = p.item_type;
document.getElementById('prod-unit').value = p.unit;
document.getElementById('prod-desc').value = p.description;
const comp = document.getElementById('prod-company');
if (comp && comp.tagName === 'SELECT') comp.value = p.company_id;
updateTaxNote();
openModal('productModal');
}
async function saveProduct() {
const name = document.getElementById('prod-name').value.trim();
const price = document.getElementById('prod-price').value;
if (!name || !price) { showToast('Nombre y precio son requeridos', 'error'); return; }
const id = document.getElementById('prod-id').value;
const data = {
company_id: document.getElementById('prod-company').value,
name, price: parseFloat(price),
item_type: document.getElementById('prod-type').value,
unit: document.getElementById('prod-unit').value,
description: document.getElementById('prod-desc').value
};
const url = id ? `/products/${id}` : '/products/new';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, {method, headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)});
const res = await r.json();
if (res.success) { showToast(id ? '✅ Actualizado' : '✅ Creado'); setTimeout(()=>location.reload(), 900); }
else showToast(res.error || 'Error', 'error');
}
async function delProduct(id) {
if (!confirm('¿Eliminar este item?')) return;
const r = await fetch(`/products/${id}`, {method:'DELETE'});
const res = await r.json();
if (res.success) { showToast('🗑️ Eliminado'); document.getElementById(`prod-${id}`).remove(); }
}
</script>
{% endblock %}
+177
View File
@@ -0,0 +1,177 @@
{% extends "base.html" %}
{% block title %}Mi Perfil — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex items-center gap-3 mb-4">
<h1 class="page-title">👤 Mi Perfil</h1>
</div>
<div class="card" style="max-width:600px;">
<p class="section-label">Información Personal</p>
<div class="form-group">
<label>Nombre completo</label>
<input type="text" id="p-name" value="{{ current_user.full_name or '' }}" placeholder="Alvaro Romero D.">
</div>
<div class="form-group">
<label>Usuario <span style="font-size:11px;color:var(--gray);">(no se puede cambiar)</span></label>
<input type="text" value="{{ current_user.username }}" disabled style="opacity:0.5;">
</div>
<hr style="border-color:var(--border);margin:16px 0 12px;">
<p class="section-label">📧 Email para envío de documentos</p>
<p style="font-size:11px;color:var(--gray);margin-bottom:12px;">
Con este email envías cotizaciones e invoices a los clientes.<br>
El servidor SMTP está configurado en la compañía — aquí solo va tu email y contraseña.
</p>
<div class="form-group">
<label>Email corporativo</label>
<input type="email" id="p-smtp-user" value="{{ current_user.smtp_user or '' }}" placeholder="alvaro@prisayachts.com">
</div>
<div class="form-group">
<label>Título / Cargo <span style="font-size:11px;color:var(--gray);">(aparece en el "De:" del email)</span></label>
<input type="text" id="p-email-title" value="{{ current_user.email_title or '' }}" placeholder="Marine Electrician · Prisa Yachts LLC">
</div>
<div class="form-group">
<label>Contraseña del email <span style="font-size:11px;color:var(--gray);">(dejar vacío para no cambiar)</span></label>
<input type="password" id="p-smtp-password" placeholder="Contraseña del email corporativo">
</div>
<hr style="border-color:var(--border);margin:16px 0 12px;">
<p class="section-label">✍️ Mi Firma</p>
<p style="font-size:11px;color:var(--gray);margin-bottom:12px;">Aparece en los PDFs de cotizaciones e invoices que elabores.</p>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px;">
<button type="button" class="btn btn-secondary btn-sm" onclick="openSigPad()">✏️ Dibujar firma</button>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0;">
📁 Subir imagen
<input type="file" id="sig-upload" accept="image/*" style="display:none;" onchange="loadSigImage(this)">
</label>
<button type="button" class="btn btn-secondary btn-sm" onclick="clearSig()" style="color:var(--danger);">🗑️ Limpiar</button>
</div>
<canvas id="sig-preview" width="400" height="100"
style="border:1px dashed var(--border);border-radius:8px;background:#fff;display:block;max-width:100%;"></canvas>
<input type="hidden" id="p-signature" value="{{ current_user.signature or '' }}">
<button class="btn btn-secondary btn-sm mt-2" onclick="saveSignature()" id="btn-save-sig" style="display:none;">💾 Guardar firma</button>
<!-- Signature pad modal -->
<div id="sigModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:1100;align-items:center;justify-content:center;">
<div style="background:var(--navy-mid);border:1px solid rgba(255,255,255,0.12);border-radius:16px;padding:24px;width:520px;max-width:95vw;">
<h3 style="margin-bottom:12px;">✍️ Dibuja tu firma</h3>
<canvas id="sig-pad" width="460" height="160"
style="border:2px solid var(--border);border-radius:8px;background:#fff;cursor:crosshair;touch-action:none;display:block;width:100%;"></canvas>
<div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
<button class="btn btn-secondary btn-sm" onclick="clearPad()">🗑️ Borrar</button>
<button class="btn btn-secondary" onclick="document.getElementById('sigModal').style.display='none'">Cancelar</button>
<button class="btn btn-primary" onclick="usePad()">✅ Usar esta firma</button>
</div>
</div>
</div>
<hr style="border-color:var(--border);margin:16px 0 12px;">
<p class="section-label">🔐 Contraseña del sistema</p>
<div class="form-group">
<label>Nueva contraseña <span style="font-size:11px;color:var(--gray);">(dejar vacío para no cambiar)</span></label>
<input type="password" id="p-password" placeholder="Nueva contraseña de acceso">
</div>
<div class="flex gap-2 justify-end mt-3">
<button class="btn btn-primary" onclick="saveProfile()">💾 Guardar cambios</button>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Load existing signature into preview on page load
window.addEventListener('load', () => {
const sig = document.getElementById('p-signature').value;
if (sig) drawPreview(sig);
});
function drawPreview(dataUrl) {
const prev = document.getElementById('sig-preview');
const ctx = prev.getContext('2d');
ctx.clearRect(0, 0, prev.width, prev.height);
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0, prev.width, prev.height);
img.src = dataUrl;
}
function clearSig() {
document.getElementById('p-signature').value = '';
const prev = document.getElementById('sig-preview');
prev.getContext('2d').clearRect(0, 0, prev.width, prev.height);
document.getElementById('btn-save-sig').style.display = 'none';
}
function loadSigImage(input) {
const file = input.files[0]; if (!file) return;
const fr = new FileReader();
fr.onload = (e) => {
document.getElementById('p-signature').value = e.target.result;
drawPreview(e.target.result);
document.getElementById('btn-save-sig').style.display = 'inline-flex';
};
fr.readAsDataURL(file);
}
// Signature pad
let padCtx = null, padDrawing = false;
function openSigPad() {
const modal = document.getElementById('sigModal');
modal.style.display = 'flex';
requestAnimationFrame(() => {
const canvas = document.getElementById('sig-pad');
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width || 460;
canvas.height = 160;
padCtx = canvas.getContext('2d');
padCtx.clearRect(0, 0, canvas.width, canvas.height);
padCtx.strokeStyle = '#1a2744';
padCtx.lineWidth = 2.5;
padCtx.lineCap = 'round';
padCtx.lineJoin = 'round';
const getPos = (e) => {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return { x: (src.clientX - r.left) * (canvas.width / r.width), y: (src.clientY - r.top) * (canvas.height / r.height) };
};
canvas.onmousedown = canvas.ontouchstart = (e) => { e.preventDefault(); padDrawing = true; const p = getPos(e); padCtx.beginPath(); padCtx.moveTo(p.x, p.y); };
canvas.onmousemove = canvas.ontouchmove = (e) => { e.preventDefault(); if (!padDrawing) return; const p = getPos(e); padCtx.lineTo(p.x, p.y); padCtx.stroke(); };
canvas.onmouseup = canvas.onmouseleave = canvas.ontouchend = () => { padDrawing = false; };
});
}
function clearPad() { padCtx && padCtx.clearRect(0, 0, document.getElementById('sig-pad').width, 160); }
function usePad() {
const dataUrl = document.getElementById('sig-pad').toDataURL('image/png');
document.getElementById('p-signature').value = dataUrl;
drawPreview(dataUrl);
document.getElementById('btn-save-sig').style.display = 'inline-flex';
document.getElementById('sigModal').style.display = 'none';
}
async function saveSignature() {
const sig = document.getElementById('p-signature').value;
if (!sig) return;
const r = await fetch('/api/me/signature', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({signature: sig}) });
const res = await r.json();
if (res.success) { showToast('✅ Firma guardada'); document.getElementById('btn-save-sig').style.display = 'none'; }
else showToast('Error al guardar firma', 'error');
}
async function saveProfile() {
const data = {
full_name: document.getElementById('p-name').value,
smtp_user: document.getElementById('p-smtp-user').value,
email_title: document.getElementById('p-email-title').value,
smtp_password: document.getElementById('p-smtp-password').value,
password: document.getElementById('p-password').value
};
const r = await fetch('/profile/save', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
const res = await r.json();
if (res.success) showToast('✅ Perfil actualizado');
else showToast(res.error || 'Error', 'error');
}
</script>
<style>
.section-label { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600;display:block; }
</style>
{% endblock %}
+211
View File
@@ -0,0 +1,211 @@
{% extends "base.html" %}
{% block title %}Usuarios — MarineInvoice Pro{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div><h1 class="page-title">Usuarios</h1><p class="page-subtitle">Gestión de accesos por compañía</p></div>
<button class="btn btn-primary" onclick="openNewUser()">+ Nuevo Usuario</button>
</div>
<div class="card">
{% if users %}
{% for u in users %}
<div class="list-item">
<div class="list-item-info">
<h4>{{ u.full_name or u.username }}
<span class="badge-role badge-{{ u.role }}">{{ u.role }}</span>
</h4>
<p>@{{ u.username }}
{% if u.smtp_user %} · 📧 {{ u.smtp_user }}{% elif u.email %} · {{ u.email }}{% endif %}
{% if u.email_title %} · <em>{{ u.email_title }}</em>{% endif %}
{% if u.company %} · 🏢 {{ u.company.name }}{% endif %}
</p>
</div>
<div class="list-item-actions">
<button class="btn btn-secondary btn-sm" data-id="{{ u.id }}"
data-name="{{ u.full_name or '' }}"
data-username="{{ u.username }}"
data-email="{{ u.email or '' }}"
data-role="{{ u.role }}"
data-company="{{ u.company_id or '' }}"
data-smtp="{{ u.smtp_user or '' }}"
data-title="{{ u.email_title or '' }}"
onclick="editUserFromBtn(this)">✏️ Editar</button>
<button class="btn btn-secondary btn-sm" onclick="resetPwd({{ u.id }}, '{{ u.username }}')">🔑 Contraseña</button>
{% if u.id != current_user.id %}
<button class="btn btn-danger btn-sm" onclick="delUser({{ u.id }})">🗑️</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="emoji">👤</div><h3>No hay usuarios</h3></div>
{% endif %}
</div>
<!-- USER MODAL -->
<div class="modal-overlay" id="userModal">
<div class="modal">
<h2 class="modal-title" id="userModalTitle">👤 Nuevo Usuario</h2>
<input type="hidden" id="u-id">
<p class="section-label">🔐 Acceso al sistema</p>
<div class="grid-2">
<div class="form-group"><label>Nombre completo</label><input type="text" id="u-name" placeholder="Alvaro Romero D."></div>
<div class="form-group"><label>Usuario * <span class="hint">(para login)</span></label><input type="text" id="u-username" placeholder="alvaro"></div>
</div>
<div class="grid-2">
<div class="form-group">
<label>Contraseña <span class="hint" id="pwd-hint">(mínimo 6 caracteres)</span></label>
<input type="password" id="u-password" placeholder="Contraseña de acceso al sistema">
</div>
<div class="form-group">
<label>Rol</label>
<select id="u-role" onchange="toggleCompanyField()">
<option value="user">Usuario</option>
<option value="admin">Admin de Compañía</option>
<option value="superadmin">Super Admin</option>
</select>
</div>
</div>
<div class="form-group" id="company-field">
<label>Compañía</label>
<select id="u-company">
<option value="">Sin compañía</option>
{% for c in companies %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</div>
<hr style="border-color:var(--border);margin:14px 0 10px;">
<p class="section-label">📧 Email para envío de documentos</p>
<p class="hint" style="margin-bottom:12px;">
Con este email se envían cotizaciones e invoices a los clientes, y se recibe copia automática de cada envío.<br>
El servidor SMTP (host/puerto) se configura en la Compañía — aquí solo va el email y contraseña de esta persona.
</p>
<div class="grid-2">
<div class="form-group">
<label>Email corporativo</label>
<input type="email" id="u-smtp-user" placeholder="alvaro@prisayachts.com">
<small class="hint">Ej: technical@prisayachts.com</small>
</div>
<div class="form-group">
<label>Título / Cargo</label>
<input type="text" id="u-email-title" placeholder="Marine Electrician · Prisa Yachts LLC">
<small class="hint">Aparece en el campo "De:" del email enviado al cliente</small>
</div>
</div>
<div class="form-group">
<label>Contraseña del email <span class="hint">(dejar vacío para no cambiar)</span></label>
<input type="password" id="u-smtp-password" placeholder="Contraseña del email corporativo">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('userModal')">Cancelar</button>
<button class="btn btn-primary" onclick="saveUser()">💾 Guardar</button>
</div>
</div>
</div>
<!-- PASSWORD MODAL -->
<div class="modal-overlay" id="pwdModal">
<div class="modal" style="max-width:400px;">
<h2 class="modal-title">🔑 Cambiar Contraseña de Sistema</h2>
<p id="pwd-user-name" style="color:var(--gray);margin-bottom:16px;font-size:13px;"></p>
<input type="hidden" id="pwd-user-id">
<div class="form-group"><label>Nueva Contraseña</label><input type="password" id="new-pwd" placeholder="Nueva contraseña"></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('pwdModal')">Cancelar</button>
<button class="btn btn-primary" onclick="savePwd()">💾 Guardar</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleCompanyField() {
const role = document.getElementById('u-role').value;
document.getElementById('company-field').style.display = role === 'superadmin' ? 'none' : 'block';
}
function openNewUser() {
document.getElementById('userModalTitle').textContent = '👤 Nuevo Usuario';
document.getElementById('u-id').value = '';
document.getElementById('u-name').value = '';
document.getElementById('u-username').value = '';
document.getElementById('u-username').disabled = false;
document.getElementById('u-password').value = '';
document.getElementById('pwd-hint').textContent = '(mínimo 6 caracteres)';
document.getElementById('u-role').value = 'user';
document.getElementById('u-company').value = '';
document.getElementById('u-smtp-user').value = '';
document.getElementById('u-smtp-password').value = '';
document.getElementById('u-email-title').value = '';
toggleCompanyField();
openModal('userModal');
}
function editUserFromBtn(btn) {
document.getElementById('userModalTitle').textContent = '✏️ Editar Usuario';
document.getElementById('u-id').value = btn.dataset.id;
document.getElementById('u-name').value = btn.dataset.name;
document.getElementById('u-username').value = btn.dataset.username;
document.getElementById('u-username').disabled = true;
document.getElementById('u-password').value = '';
document.getElementById('pwd-hint').textContent = '(dejar vacío para no cambiar)';
document.getElementById('u-role').value = btn.dataset.role;
document.getElementById('u-company').value = btn.dataset.company;
document.getElementById('u-smtp-user').value = btn.dataset.smtp;
document.getElementById('u-smtp-password').value = '';
document.getElementById('u-email-title').value = btn.dataset.title;
toggleCompanyField();
openModal('userModal');
}
async function saveUser() {
const id = document.getElementById('u-id').value;
const data = {
full_name: document.getElementById('u-name').value,
username: document.getElementById('u-username').value,
password: document.getElementById('u-password').value,
role: document.getElementById('u-role').value,
company_id: document.getElementById('u-company').value || null,
smtp_user: document.getElementById('u-smtp-user').value,
smtp_password: document.getElementById('u-smtp-password').value,
email_title: document.getElementById('u-email-title').value
};
if (!id && (!data.username || !data.password)) {
showToast('Usuario y contraseña son requeridos', 'error'); return;
}
const url = id ? `/users/${id}/edit` : '/users/new';
const r = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
const res = await r.json();
if (res.success) { showToast(id ? '✅ Usuario actualizado' : '✅ Usuario creado'); setTimeout(()=>location.reload(), 1000); }
else showToast(res.error || 'Error', 'error');
}
function resetPwd(id, username) {
document.getElementById('pwd-user-id').value = id;
document.getElementById('pwd-user-name').textContent = 'Usuario: @' + username;
document.getElementById('new-pwd').value = '';
openModal('pwdModal');
}
async function savePwd() {
const id = document.getElementById('pwd-user-id').value;
const pwd = document.getElementById('new-pwd').value;
if (!pwd) { showToast('Ingresa una contraseña', 'error'); return; }
const r = await fetch(`/users/${id}/reset-password`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({password: pwd}) });
const res = await r.json();
if (res.success) { showToast('✅ Contraseña actualizada'); closeModal('pwdModal'); }
else showToast(res.error || 'Error', 'error');
}
async function delUser(id) {
if (!confirm('¿Eliminar este usuario?')) return;
const r = await fetch(`/users/${id}/delete`, { method:'POST' });
const res = await r.json();
if (res.success) { showToast('🗑️ Usuario eliminado'); setTimeout(()=>location.reload(), 800); }
}
</script>
<style>
.section-label { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600;display:block; }
.hint { font-size:10px; color:var(--gray); }
</style>
{% endblock %}