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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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> ·
|
||||
Ej: <code>IPY</code> → <strong style="color:var(--gold)">IPY-001-032026</strong> · <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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user