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,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MarineInvoice Pro{% endblock %}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--navy: #0a1628; --navy-mid: #112240; --navy-light: #1a3a6b;
|
||||
--gold: #c9a84c; --gold-light: #e8c97a; --white: #f8f9fc;
|
||||
--gray: #8892a4; --gray-light: #e2e8f0; --success: #2ecc71;
|
||||
--danger: #e74c3c; --shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'DM Sans', sans-serif; background: var(--navy); color: var(--white); min-height: 100vh; }
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, var(--navy-mid), var(--navy));
|
||||
border-bottom: 1px solid rgba(201,168,76,0.3);
|
||||
padding: 0 24px; display: flex; align-items: center;
|
||||
justify-content: space-between; height: 64px;
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.app-logo { display: flex; align-items: center; gap: 12px; text-decoration: none; }
|
||||
.logo-icon {
|
||||
width: 40px; height: 40px;
|
||||
background: linear-gradient(135deg, var(--gold), var(--gold-light));
|
||||
border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px;
|
||||
}
|
||||
.logo-text { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 700; color: var(--white); }
|
||||
.logo-text span { color: var(--gold); }
|
||||
nav { display: flex; gap: 4px; align-items: center; }
|
||||
.nav-btn {
|
||||
background: none; border: none; color: var(--gray);
|
||||
padding: 8px 14px; border-radius: 8px; cursor: pointer;
|
||||
font-family: 'DM Sans', sans-serif; font-size: 13px; font-weight: 500;
|
||||
transition: all 0.2s; text-decoration: none; display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.nav-btn:hover { background: rgba(255,255,255,0.08); color: var(--white); }
|
||||
.nav-btn.active { background: rgba(201,168,76,0.15); color: var(--gold); }
|
||||
.nav-user {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 12px; background: rgba(255,255,255,0.06);
|
||||
border-radius: 20px; font-size: 13px; color: var(--gray);
|
||||
}
|
||||
.nav-user strong { color: var(--white); }
|
||||
.badge-role {
|
||||
padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
}
|
||||
.badge-superadmin { background: rgba(201,168,76,0.2); color: var(--gold); }
|
||||
.badge-admin { background: rgba(100,181,246,0.2); color: #64b5f6; }
|
||||
.badge-user { background: rgba(46,204,113,0.2); color: var(--success); }
|
||||
.main { max-width: 1200px; margin: 0 auto; padding: 32px 24px; }
|
||||
.card { background: var(--navy-mid); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; margin-bottom: 20px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.card-title { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
@media(max-width:900px) { .grid-4 { grid-template-columns: 1fr 1fr; } }
|
||||
@media(max-width:768px) { .grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; } nav { display: none; } .mobile-menu { display: flex !important; } }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
label { display: block; font-size: 11px; font-weight: 600; color: var(--gray); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||
input,select,textarea {
|
||||
width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 10px; padding: 10px 14px; color: var(--white);
|
||||
font-family: 'DM Sans', sans-serif; font-size: 14px; transition: border-color 0.2s; outline: none;
|
||||
}
|
||||
input:focus,select:focus,textarea:focus { border-color: var(--gold); background: rgba(201,168,76,0.08); }
|
||||
select option { background: var(--navy-mid); color: var(--white); }
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
.btn { padding: 9px 18px; border-radius: 10px; border: none; font-family: 'DM Sans', sans-serif; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.btn-primary { background: linear-gradient(135deg, var(--gold), var(--gold-light)); color: var(--navy); }
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(201,168,76,0.4); }
|
||||
.btn-secondary { background: rgba(255,255,255,0.08); color: var(--white); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.btn-secondary:hover { background: rgba(255,255,255,0.14); }
|
||||
.btn-danger { background: rgba(231,76,60,0.15); color: var(--danger); border: 1px solid rgba(231,76,60,0.3); }
|
||||
.btn-success { background: linear-gradient(135deg, #27ae60, #2ecc71); color: white; }
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
.list-item { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; transition: all 0.2s; }
|
||||
.list-item:hover { background: rgba(255,255,255,0.07); border-color: rgba(201,168,76,0.2); }
|
||||
.list-item-info h4 { font-size: 14px; font-weight: 600; margin-bottom: 3px; }
|
||||
.list-item-info p { font-size: 12px; color: var(--gray); }
|
||||
.list-item-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.badge { padding: 3px 9px; border-radius: 20px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.badge-gold { background: rgba(201,168,76,0.2); color: var(--gold); }
|
||||
.badge-blue { background: rgba(100,181,246,0.15); color: #64b5f6; }
|
||||
.badge-green { background: rgba(46,204,113,0.15); color: var(--success); }
|
||||
.badge-gray { background: rgba(136,146,164,0.15); color: var(--gray); }
|
||||
.stat-card { background: var(--navy-mid); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 20px; }
|
||||
.stat-icon { font-size: 24px; margin-bottom: 10px; }
|
||||
.stat-label { font-size: 11px; color: var(--gray); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||
.stat-value { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; }
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--gray); }
|
||||
.empty-state .emoji { font-size: 48px; margin-bottom: 16px; }
|
||||
.empty-state h3 { font-size: 18px; margin-bottom: 8px; color: var(--white); }
|
||||
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center; padding: 20px; overflow-y: auto; }
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal { background: var(--navy-mid); border: 1px solid rgba(255,255,255,0.12); border-radius: 20px; padding: 32px; width: 100%; max-width: 650px; box-shadow: var(--shadow); }
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay { align-items: flex-start; padding: 12px; }
|
||||
.modal { padding: 20px; border-radius: 14px; margin: auto; }
|
||||
}
|
||||
.modal-lg { max-width: 900px; }
|
||||
.modal-title { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 700; margin-bottom: 24px; color: var(--gold); }
|
||||
.modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 20px; }
|
||||
.divider { height: 1px; background: rgba(255,255,255,0.08); margin: 20px 0; }
|
||||
.page-title { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; margin-bottom: 4px; }
|
||||
.page-subtitle { color: var(--gray); font-size: 13px; margin-bottom: 24px; }
|
||||
.flex { display: flex; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.mt-3 { margin-top: 12px; }
|
||||
.mb-4 { margin-bottom: 20px; }
|
||||
.totals-box { background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.2); border-radius: 12px; padding: 16px; width: 280px; margin-left: auto; }
|
||||
.totals-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 13px; color: var(--gray); }
|
||||
.totals-row.total { font-size: 17px; font-weight: 700; color: var(--gold); border-top: 1px solid rgba(201,168,76,0.3); margin-top: 8px; padding-top: 10px; }
|
||||
.line-items-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
.line-items-table th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--gray); padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.line-items-table td { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.line-items-table td input, .line-items-table td select { padding: 5px 8px; font-size: 12px; }
|
||||
.toast { position: fixed; top: 76px; right: 20px; background: var(--navy-mid); border: 1px solid rgba(201,168,76,0.4); border-radius: 12px; padding: 12px 18px; font-size: 13px; z-index: 2000; transform: translateX(300%); transition: transform 0.3s; box-shadow: var(--shadow); }
|
||||
.toast.show { transform: translateX(0); }
|
||||
.toast.success { border-color: rgba(46,204,113,0.4); color: var(--success); }
|
||||
.toast.error { border-color: rgba(231,76,60,0.4); color: var(--danger); }
|
||||
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: var(--navy-mid); border-top: 1px solid rgba(255,255,255,0.1); padding: 6px; z-index: 100; justify-content: space-around; }
|
||||
@media(max-width:768px) { .mobile-nav { display: flex; } .main { padding-bottom: 80px; } }
|
||||
.mobile-nav-btn { background: none; border: none; color: var(--gray); display: flex; flex-direction: column; align-items: center; gap: 3px; font-size: 9px; font-family: 'DM Sans', sans-serif; cursor: pointer; padding: 5px 10px; border-radius: 8px; text-decoration: none; }
|
||||
.mobile-nav-btn.active { color: var(--gold); background: rgba(201,168,76,0.1); }
|
||||
.mobile-nav-btn .ico { font-size: 18px; }
|
||||
.flash-messages { margin-bottom: 20px; }
|
||||
.flash { padding: 12px 16px; border-radius: 10px; margin-bottom: 10px; font-size: 13px; }
|
||||
.flash-success { background: rgba(46,204,113,0.15); border: 1px solid rgba(46,204,113,0.3); color: var(--success); }
|
||||
.flash-error { background: rgba(231,76,60,0.15); border: 1px solid rgba(231,76,60,0.3); color: var(--danger); }
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="app-logo">
|
||||
<div class="logo-icon">⚓</div>
|
||||
<span class="logo-text">Marine<span>Invoice</span> Pro</span>
|
||||
</a>
|
||||
<nav>
|
||||
<a href="/" class="nav-btn {% if request.endpoint == 'dashboard' %}active{% endif %}">📊 Dashboard</a>
|
||||
{% if current_user.is_superadmin() %}
|
||||
<a href="/companies" class="nav-btn {% if request.endpoint == 'companies' %}active{% endif %}">🏢 Compañías</a>
|
||||
<a href="/users" class="nav-btn {% if request.endpoint == 'users' %}active{% endif %}">👤 Usuarios</a>
|
||||
{% endif %}
|
||||
<a href="/clients" class="nav-btn {% if request.endpoint == 'clients' %}active{% endif %}">👥 Clientes</a>
|
||||
<a href="/products" class="nav-btn {% if request.endpoint == 'products' %}active{% endif %}">🔧 Productos</a>
|
||||
<a href="/invoices" class="nav-btn {% if request.endpoint == 'invoices' %}active{% endif %}">📄 Invoices</a>
|
||||
<a href="/quotes" class="nav-btn {% if request.endpoint == 'quotes' %}active{% endif %}">📋 Cotizaciones</a>
|
||||
<div class="nav-user">
|
||||
<strong>{{ current_user.full_name or current_user.username }}</strong>
|
||||
<span class="badge-role badge-{{ current_user.role }}">{{ current_user.role }}</span>
|
||||
</div>
|
||||
<a href="/profile" class="btn btn-secondary btn-sm">👤 Mi Perfil</a>
|
||||
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
|
||||
</nav>
|
||||
</header>
|
||||
<nav class="mobile-nav">
|
||||
<a href="/" class="mobile-nav-btn {% if request.endpoint == 'dashboard' %}active{% endif %}"><span class="ico">📊</span>Home</a>
|
||||
{% if current_user.is_superadmin() %}<a href="/companies" class="mobile-nav-btn {% if request.endpoint == 'companies' %}active{% endif %}"><span class="ico">🏢</span>Cias</a>{% endif %}
|
||||
<a href="/clients" class="mobile-nav-btn {% if request.endpoint == 'clients' %}active{% endif %}"><span class="ico">👥</span>Clientes</a>
|
||||
<a href="/products" class="mobile-nav-btn {% if request.endpoint == 'products' %}active{% endif %}"><span class="ico">🔧</span>Prods</a>
|
||||
<a href="/invoices" class="mobile-nav-btn {% if request.endpoint == 'invoices' %}active{% endif %}"><span class="ico">📄</span>Invoices</a>
|
||||
<a href="/quotes" class="mobile-nav-btn {% if request.endpoint == 'quotes' %}active{% endif %}"><span class="ico">📋</span>Cotiz.</a>
|
||||
<a href="/profile" class="mobile-nav-btn {% if request.endpoint == 'profile' %}active{% endif %}"><span class="ico">👤</span>Perfil</a>
|
||||
<a href="/logout" class="mobile-nav-btn"><span class="ico">🚪</span>Salir</a>
|
||||
</nav>
|
||||
<div class="main">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
function showToast(msg, type='success', duration=3000) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `toast ${type} show`;
|
||||
// info style
|
||||
if (type === 'info') {
|
||||
t.style.background = 'linear-gradient(135deg,#1a6fa8,#0d5c94)';
|
||||
t.style.borderColor = 'rgba(26,111,168,0.4)';
|
||||
} else {
|
||||
t.style.background = ''; t.style.borderColor = '';
|
||||
}
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(() => t.classList.remove('show'), duration);
|
||||
}
|
||||
function openModal(id) { document.getElementById(id).classList.add('active'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
||||
// Modal stays open on outside click — only closes via Cancel/X buttons
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,124 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Editar' if company else 'Nueva' }} Compañía{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<a href="/companies" class="btn btn-secondary btn-sm">← Volver</a>
|
||||
<h1 class="page-title">{{ 'Editar' if company else 'Nueva' }} Compañía</h1>
|
||||
</div>
|
||||
<div class="card">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<p class="inv-section-title">Información General</p>
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>Nombre *</label><input type="text" name="name" value="{{ company.name if company else '' }}" required placeholder="Ej: Prisa Yachts"></div>
|
||||
<div class="form-group"><label>EIN / Tax ID</label><input type="text" name="ein" value="{{ company.ein if company else '' }}" placeholder="XX-XXXXXXX"></div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>Licencia</label><input type="text" name="license" value="{{ company.license_num if company else '' }}" placeholder="Número de licencia"></div>
|
||||
<div class="form-group"><label>Teléfono</label><input type="text" name="phone" value="{{ company.phone if company else '' }}" placeholder="(305) XXX-XXXX"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Dirección</label><input type="text" name="address" value="{{ company.address if company else '' }}" placeholder="Dirección"></div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>Ciudad</label><input type="text" name="city" value="{{ company.city if company else '' }}" placeholder="Miami"></div>
|
||||
<div class="form-group"><label>Estado / ZIP</label><input type="text" name="state" value="{{ company.state if company else '' }}" placeholder="FL 33010"></div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>Email</label><input type="email" name="email" value="{{ company.email if company else '' }}" placeholder="info@empresa.com"></div>
|
||||
<div class="form-group"><label>Website</label><input type="text" name="website" value="{{ company.website if company else '' }}" placeholder="www.empresa.com"></div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>Gerente / Owner</label><input type="text" name="manager" value="{{ company.manager if company else '' }}" placeholder="Nombre completo"></div>
|
||||
<div class="form-group"><label>Persona Autorizada</label><input type="text" name="authorized" value="{{ company.authorized if company else '' }}" placeholder="Nombre completo"></div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>Sales Tax %</label><input type="number" name="tax_rate" value="{{ company.tax_rate if company else '7' }}" min="0" max="20" step="0.1"></div>
|
||||
<div class="form-group">
|
||||
<label>Logo</label>
|
||||
{% if company and company.logo_path %}<img src="/static/{{ company.logo_path }}" style="height:44px;margin-bottom:6px;display:block;background:white;padding:3px;border-radius:5px;">{% endif %}
|
||||
<input type="file" name="logo" accept="image/*" style="padding:7px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<p class="inv-section-title">📋 Formato de Numeración Automática</p>
|
||||
<div style="background:rgba(201,168,76,0.06);border:1px solid rgba(201,168,76,0.15);border-radius:10px;padding:12px 16px;font-size:12px;color:var(--gray);margin-bottom:16px;">
|
||||
El número se genera como: <strong style="color:var(--gold)">PREFIJO-001-MMAAAA</strong> ·
|
||||
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>
|
||||
<p class="inv-section-title">💳 Stripe — Pagos con Tarjeta</p>
|
||||
<div style="background:rgba(99,102,241,0.06);border:1px solid rgba(99,102,241,0.2);border-radius:10px;padding:12px 16px;font-size:12px;color:var(--gray);margin-bottom:14px;">
|
||||
Cada compañía tiene sus propias claves. Obtén las claves en
|
||||
<a href="https://dashboard.stripe.com/apikeys" target="_blank" style="color:#818cf8;">dashboard.stripe.com → Developers → API Keys</a>.<br>
|
||||
<span style="margin-top:4px;display:block;">La Secret Key no se muestra después de guardada — déjala vacía si no quieres cambiarla.</span>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label>Publishable Key <span style="font-size:10px;color:var(--gray);">(pk_live_... o pk_test_...)</span></label>
|
||||
<input type="text" name="stripe_publishable_key"
|
||||
value="{{ company.stripe_publishable_key if company else '' }}"
|
||||
placeholder="pk_live_...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Secret Key <span style="font-size:10px;color:var(--gray);">(sk_live_... o sk_test_...)</span></label>
|
||||
<input type="password" name="stripe_secret_key"
|
||||
placeholder="{{ '••••••• (ya configurada — dejar vacío para no cambiar)' if company and company.stripe_secret_key else 'sk_live_...' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="form-group">
|
||||
<label>Notas para Invoices</label>
|
||||
<textarea name="invoice_notes" rows="6" placeholder="Payment terms, methods of payment...">{{ company.invoice_notes if company else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notas para Cotizaciones</label>
|
||||
<textarea name="quote_notes" rows="6" placeholder="Quotation validity, payment terms...">{{ company.quote_notes if company else '' }}</textarea>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-between mt-3">
|
||||
<a href="/companies" class="btn btn-secondary">Cancelar</a>
|
||||
<button type="submit" class="btn btn-primary">💾 Guardar Compañía</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<style>
|
||||
.inv-section-title { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:14px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600; }
|
||||
code { background:rgba(201,168,76,0.12);padding:2px 6px;border-radius:4px;font-size:11px; }
|
||||
</style>
|
||||
<script>
|
||||
function updatePreview() {
|
||||
const now = new Date();
|
||||
const monthStr = String(now.getMonth()+1).padStart(2,'0')+String(now.getFullYear());
|
||||
const ip = document.querySelector('[name="invoice_prefix"]').value||'INV';
|
||||
const qp = document.querySelector('[name="quote_prefix"]').value||'QUO';
|
||||
document.getElementById('inv-preview').textContent = ip+'-001-'+monthStr;
|
||||
document.getElementById('quo-preview').textContent = qp+'-001-'+monthStr;
|
||||
}
|
||||
updatePreview();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — MarineInvoice Pro{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
<p class="page-subtitle">Bienvenido, {{ current_user.full_name or current_user.username }}</p>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="grid-4 mb-4">
|
||||
<div class="stat-card"><div class="stat-icon">🏢</div><div class="stat-label">Compañías</div><div class="stat-value">{{ companies|length }}</div></div>
|
||||
<div class="stat-card"><div class="stat-icon">👥</div><div class="stat-label">Clientes</div><div class="stat-value">{{ total_clients }}</div></div>
|
||||
<div class="stat-card"><div class="stat-icon">📄</div><div class="stat-label">Invoices</div><div class="stat-value">{{ total_invoices }}</div></div>
|
||||
<div class="stat-card"><div class="stat-icon">📋</div><div class="stat-label">Cotizaciones</div><div class="stat-value">{{ total_quotes }}</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Total facturado banner -->
|
||||
<div style="background:linear-gradient(135deg,#1a2744 0%,#243560 100%);border-radius:12px;padding:20px 28px;margin-bottom:24px;display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<div style="color:#c9a84c;font-size:12px;font-weight:600;letter-spacing:1px;text-transform:uppercase;">Total Facturado (Invoices)</div>
|
||||
<div style="color:white;font-size:32px;font-weight:700;margin-top:4px;">${{ "%.2f"|format(total_billed) }}</div>
|
||||
</div>
|
||||
<div style="font-size:40px;opacity:0.4;">💰</div>
|
||||
</div>
|
||||
|
||||
<!-- Two columns: Invoices | Cotizaciones -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
|
||||
|
||||
<!-- INVOICES -->
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="card-header" style="border-left:4px solid #c9a84c;padding-left:12px;">
|
||||
<span class="card-title" style="color:#c9a84c;">📄 Últimos Invoices</span>
|
||||
<a href="/invoices" class="btn btn-primary btn-sm">Ver todos</a>
|
||||
</div>
|
||||
{% if recent_invoices %}
|
||||
{% for inv in recent_invoices %}
|
||||
{% set client = inv.client %}
|
||||
<div class="list-item" style="padding:10px 0;">
|
||||
<div class="list-item-info">
|
||||
<h4 style="font-size:13px;margin-bottom:2px;">
|
||||
{{ inv.number }}
|
||||
{% if inv.status == 'draft' %}<span class="badge badge-gold">Borrador</span>
|
||||
{% elif inv.status == 'sent' %}<span class="badge badge-blue">Enviado</span>
|
||||
{% elif inv.status == 'paid' %}<span class="badge badge-green">Pagado</span>
|
||||
{% else %}<span class="badge" style="background:#eee;color:#666;">Cancelado</span>{% endif %}
|
||||
</h4>
|
||||
<p style="font-size:11px;color:var(--gray);">{{ client.name if client else '—' }} · {{ inv.date }}</p>
|
||||
</div>
|
||||
<span style="font-weight:700;color:#c9a84c;font-size:13px;">${{ "%.2f"|format(inv.total) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state" style="padding:30px 0;">
|
||||
<div class="emoji">📄</div>
|
||||
<h3>No hay invoices aún</h3>
|
||||
<p>Ve a <a href="/invoices">Invoices</a> para crear uno</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- COTIZACIONES -->
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="card-header" style="border-left:4px solid #4a90d9;padding-left:12px;">
|
||||
<span class="card-title" style="color:#4a90d9;">📋 Últimas Cotizaciones</span>
|
||||
<a href="/quotes" class="btn btn-secondary btn-sm">Ver todas</a>
|
||||
</div>
|
||||
{% if recent_quotes %}
|
||||
{% for qt in recent_quotes %}
|
||||
{% set client = qt.client %}
|
||||
<div class="list-item" style="padding:10px 0;">
|
||||
<div class="list-item-info">
|
||||
<h4 style="font-size:13px;margin-bottom:2px;">
|
||||
{{ qt.number }}
|
||||
{% if qt.status == 'draft' %}<span class="badge badge-gold">Borrador</span>
|
||||
{% elif qt.status == 'sent' %}<span class="badge badge-blue">Enviado</span>
|
||||
{% elif qt.status == 'accepted' %}<span class="badge badge-green">Aceptado</span>
|
||||
{% else %}<span class="badge" style="background:#fde8e8;color:#c0392b;">Rechazado</span>{% endif %}
|
||||
</h4>
|
||||
<p style="font-size:11px;color:var(--gray);">{{ client.name if client else '—' }} · {{ qt.date }}</p>
|
||||
</div>
|
||||
<span style="font-weight:700;color:#4a90d9;font-size:13px;">${{ "%.2f"|format(qt.total) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state" style="padding:30px 0;">
|
||||
<div class="emoji">📋</div>
|
||||
<h3>No hay cotizaciones aún</h3>
|
||||
<p>Ve a <a href="/quotes">Cotizaciones</a> para crear una</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pay Invoice {{ doc.number }}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'DM Sans', sans-serif; background: #f0f4f8; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.card { background: white; border-radius: 20px; padding: 40px; max-width: 480px; width: 100%; box-shadow: 0 8px 40px rgba(0,0,0,0.12); }
|
||||
.logo { font-size: 22px; font-weight: 700; color: #0a1628; margin-bottom: 4px; }
|
||||
.company-sub { font-size: 13px; color: #8892a4; margin-bottom: 28px; }
|
||||
.divider { height: 1px; background: #e8ecf2; margin: 20px 0; }
|
||||
.label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: #8892a4; font-weight: 600; margin-bottom: 4px; }
|
||||
.value { font-size: 15px; color: #1a2540; font-weight: 500; margin-bottom: 16px; }
|
||||
.amount-box { background: #0a1628; border-radius: 14px; padding: 24px; text-align: center; margin: 24px 0; }
|
||||
.amount-label { font-size: 12px; color: #8892a4; margin-bottom: 6px; letter-spacing: 0.5px; }
|
||||
.amount-value { font-size: 38px; font-weight: 700; color: #c9a84c; }
|
||||
.btn-pay { display: block; width: 100%; background: linear-gradient(135deg, #6366f1, #4f46e5); color: white; border: none; border-radius: 12px; padding: 16px; font-size: 16px; font-weight: 600; cursor: pointer; text-align: center; text-decoration: none; transition: opacity 0.2s; }
|
||||
.btn-pay:hover { opacity: 0.9; }
|
||||
.secure { text-align: center; font-size: 11px; color: #8892a4; margin-top: 12px; }
|
||||
.invoice-num { display: inline-block; background: rgba(201,168,76,0.1); color: #b8962a; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{% if company and company.logo_path %}
|
||||
<img src="{{ request.host_url }}static/{{ company.logo_path }}" style="height:50px;margin-bottom:16px;display:block;">
|
||||
{% endif %}
|
||||
<div class="logo">{{ company.name if company else 'Invoice Payment' }}</div>
|
||||
<div class="company-sub">{{ company.email if company else '' }}</div>
|
||||
|
||||
<span class="invoice-num">Invoice {{ doc.number }}</span>
|
||||
|
||||
<div class="amount-box">
|
||||
<div class="amount-label">INVOICE TOTAL</div>
|
||||
<div class="amount-value">${{ "%.2f"|format(doc.total) }}</div>
|
||||
</div>
|
||||
|
||||
{% set fee = (doc.total * 0.029 + 0.30) %}
|
||||
{% set total_with_fee = doc.total + fee %}
|
||||
<div style="background:#f8fafc;border-radius:12px;padding:16px;margin-bottom:20px;font-size:14px;">
|
||||
<div style="display:flex;justify-content:space-between;padding:5px 0;color:#555;">
|
||||
<span>Invoice subtotal</span><strong>${{ "%.2f"|format(doc.total) }}</strong>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;padding:5px 0;color:#555;">
|
||||
<span>Credit card fee (2.9% + $0.30)</span><strong>${{ "%.2f"|format(fee) }}</strong>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;padding:8px 0;margin-top:6px;border-top:1px solid #e2e8f0;font-size:16px;font-weight:700;color:#0a1628;">
|
||||
<span>Total charged</span><span>${{ "%.2f"|format(total_with_fee) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="/pay/{{ token }}/checkout" method="POST">
|
||||
<button type="submit" class="btn-pay">💳 Pay Now with Card</button>
|
||||
</form>
|
||||
<div class="secure">🔒 Secured by Stripe · Your payment info is never stored on our servers</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment {{ 'Already Received' if already_paid else 'Successful' }}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'DM Sans', sans-serif; background: #f0f4f8; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.card { background: white; border-radius: 20px; padding: 48px 40px; max-width: 440px; width: 100%; box-shadow: 0 8px 40px rgba(0,0,0,0.12); text-align: center; }
|
||||
.icon { font-size: 64px; margin-bottom: 20px; }
|
||||
.title { font-size: 26px; font-weight: 700; color: #0a1628; margin-bottom: 8px; }
|
||||
.sub { font-size: 15px; color: #8892a4; margin-bottom: 28px; line-height: 1.5; }
|
||||
.detail { background: #f8fafc; border-radius: 12px; padding: 20px; margin-bottom: 24px; text-align: left; }
|
||||
.detail-row { display: flex; justify-content: space-between; font-size: 14px; padding: 6px 0; color: #555; }
|
||||
.detail-row strong { color: #0a1628; }
|
||||
.amount { font-size: 32px; font-weight: 700; color: #2ecc71; margin: 16px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{% if already_paid %}
|
||||
<div class="icon">✅</div>
|
||||
<div class="title">Already Paid</div>
|
||||
<div class="sub">This invoice has already been paid. Thank you!</div>
|
||||
{% else %}
|
||||
<div class="icon">🎉</div>
|
||||
<div class="title">Payment Successful!</div>
|
||||
<div class="sub">Thank you for your payment. A receipt has been sent to your email.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="detail">
|
||||
<div class="detail-row"><span>Invoice</span><strong>{{ doc.number }}</strong></div>
|
||||
<div class="detail-row"><span>Company</span><strong>{{ company.name if company else '—' }}</strong></div>
|
||||
<div class="detail-row"><span>Amount</span><strong style="color:#2ecc71;">${{ "%.2f"|format(doc.total) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px;color:#aaa;">You may close this window.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,211 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Usuarios — MarineInvoice Pro{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div><h1 class="page-title">Usuarios</h1><p class="page-subtitle">Gestión de accesos por compañía</p></div>
|
||||
<button class="btn btn-primary" onclick="openNewUser()">+ Nuevo Usuario</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
{% if users %}
|
||||
{% for u in users %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<h4>{{ u.full_name or u.username }}
|
||||
<span class="badge-role badge-{{ u.role }}">{{ u.role }}</span>
|
||||
</h4>
|
||||
<p>@{{ u.username }}
|
||||
{% if u.smtp_user %} · 📧 {{ u.smtp_user }}{% elif u.email %} · {{ u.email }}{% endif %}
|
||||
{% if u.email_title %} · <em>{{ u.email_title }}</em>{% endif %}
|
||||
{% if u.company %} · 🏢 {{ u.company.name }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-id="{{ u.id }}"
|
||||
data-name="{{ u.full_name or '' }}"
|
||||
data-username="{{ u.username }}"
|
||||
data-email="{{ u.email or '' }}"
|
||||
data-role="{{ u.role }}"
|
||||
data-company="{{ u.company_id or '' }}"
|
||||
data-smtp="{{ u.smtp_user or '' }}"
|
||||
data-title="{{ u.email_title or '' }}"
|
||||
onclick="editUserFromBtn(this)">✏️ Editar</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="resetPwd({{ u.id }}, '{{ u.username }}')">🔑 Contraseña</button>
|
||||
{% if u.id != current_user.id %}
|
||||
<button class="btn btn-danger btn-sm" onclick="delUser({{ u.id }})">🗑️</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="emoji">👤</div><h3>No hay usuarios</h3></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- USER MODAL -->
|
||||
<div class="modal-overlay" id="userModal">
|
||||
<div class="modal">
|
||||
<h2 class="modal-title" id="userModalTitle">👤 Nuevo Usuario</h2>
|
||||
<input type="hidden" id="u-id">
|
||||
|
||||
<p class="section-label">🔐 Acceso al sistema</p>
|
||||
<div class="grid-2">
|
||||
<div class="form-group"><label>Nombre completo</label><input type="text" id="u-name" placeholder="Alvaro Romero D."></div>
|
||||
<div class="form-group"><label>Usuario * <span class="hint">(para login)</span></label><input type="text" id="u-username" placeholder="alvaro"></div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label>Contraseña <span class="hint" id="pwd-hint">(mínimo 6 caracteres)</span></label>
|
||||
<input type="password" id="u-password" placeholder="Contraseña de acceso al sistema">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Rol</label>
|
||||
<select id="u-role" onchange="toggleCompanyField()">
|
||||
<option value="user">Usuario</option>
|
||||
<option value="admin">Admin de Compañía</option>
|
||||
<option value="superadmin">Super Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="company-field">
|
||||
<label>Compañía</label>
|
||||
<select id="u-company">
|
||||
<option value="">Sin compañía</option>
|
||||
{% for c in companies %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:var(--border);margin:14px 0 10px;">
|
||||
<p class="section-label">📧 Email para envío de documentos</p>
|
||||
<p class="hint" style="margin-bottom:12px;">
|
||||
Con este email se envían cotizaciones e invoices a los clientes, y se recibe copia automática de cada envío.<br>
|
||||
El servidor SMTP (host/puerto) se configura en la Compañía — aquí solo va el email y contraseña de esta persona.
|
||||
</p>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label>Email corporativo</label>
|
||||
<input type="email" id="u-smtp-user" placeholder="alvaro@prisayachts.com">
|
||||
<small class="hint">Ej: technical@prisayachts.com</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Título / Cargo</label>
|
||||
<input type="text" id="u-email-title" placeholder="Marine Electrician · Prisa Yachts LLC">
|
||||
<small class="hint">Aparece en el campo "De:" del email enviado al cliente</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Contraseña del email <span class="hint">(dejar vacío para no cambiar)</span></label>
|
||||
<input type="password" id="u-smtp-password" placeholder="Contraseña del email corporativo">
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('userModal')">Cancelar</button>
|
||||
<button class="btn btn-primary" onclick="saveUser()">💾 Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PASSWORD MODAL -->
|
||||
<div class="modal-overlay" id="pwdModal">
|
||||
<div class="modal" style="max-width:400px;">
|
||||
<h2 class="modal-title">🔑 Cambiar Contraseña de Sistema</h2>
|
||||
<p id="pwd-user-name" style="color:var(--gray);margin-bottom:16px;font-size:13px;"></p>
|
||||
<input type="hidden" id="pwd-user-id">
|
||||
<div class="form-group"><label>Nueva Contraseña</label><input type="password" id="new-pwd" placeholder="Nueva contraseña"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('pwdModal')">Cancelar</button>
|
||||
<button class="btn btn-primary" onclick="savePwd()">💾 Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function toggleCompanyField() {
|
||||
const role = document.getElementById('u-role').value;
|
||||
document.getElementById('company-field').style.display = role === 'superadmin' ? 'none' : 'block';
|
||||
}
|
||||
|
||||
function openNewUser() {
|
||||
document.getElementById('userModalTitle').textContent = '👤 Nuevo Usuario';
|
||||
document.getElementById('u-id').value = '';
|
||||
document.getElementById('u-name').value = '';
|
||||
document.getElementById('u-username').value = '';
|
||||
document.getElementById('u-username').disabled = false;
|
||||
document.getElementById('u-password').value = '';
|
||||
document.getElementById('pwd-hint').textContent = '(mínimo 6 caracteres)';
|
||||
document.getElementById('u-role').value = 'user';
|
||||
document.getElementById('u-company').value = '';
|
||||
document.getElementById('u-smtp-user').value = '';
|
||||
document.getElementById('u-smtp-password').value = '';
|
||||
document.getElementById('u-email-title').value = '';
|
||||
toggleCompanyField();
|
||||
openModal('userModal');
|
||||
}
|
||||
|
||||
function editUserFromBtn(btn) {
|
||||
document.getElementById('userModalTitle').textContent = '✏️ Editar Usuario';
|
||||
document.getElementById('u-id').value = btn.dataset.id;
|
||||
document.getElementById('u-name').value = btn.dataset.name;
|
||||
document.getElementById('u-username').value = btn.dataset.username;
|
||||
document.getElementById('u-username').disabled = true;
|
||||
document.getElementById('u-password').value = '';
|
||||
document.getElementById('pwd-hint').textContent = '(dejar vacío para no cambiar)';
|
||||
document.getElementById('u-role').value = btn.dataset.role;
|
||||
document.getElementById('u-company').value = btn.dataset.company;
|
||||
document.getElementById('u-smtp-user').value = btn.dataset.smtp;
|
||||
document.getElementById('u-smtp-password').value = '';
|
||||
document.getElementById('u-email-title').value = btn.dataset.title;
|
||||
toggleCompanyField();
|
||||
openModal('userModal');
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const id = document.getElementById('u-id').value;
|
||||
const data = {
|
||||
full_name: document.getElementById('u-name').value,
|
||||
username: document.getElementById('u-username').value,
|
||||
password: document.getElementById('u-password').value,
|
||||
role: document.getElementById('u-role').value,
|
||||
company_id: document.getElementById('u-company').value || null,
|
||||
smtp_user: document.getElementById('u-smtp-user').value,
|
||||
smtp_password: document.getElementById('u-smtp-password').value,
|
||||
email_title: document.getElementById('u-email-title').value
|
||||
};
|
||||
if (!id && (!data.username || !data.password)) {
|
||||
showToast('Usuario y contraseña son requeridos', 'error'); return;
|
||||
}
|
||||
const url = id ? `/users/${id}/edit` : '/users/new';
|
||||
const r = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
||||
const res = await r.json();
|
||||
if (res.success) { showToast(id ? '✅ Usuario actualizado' : '✅ Usuario creado'); setTimeout(()=>location.reload(), 1000); }
|
||||
else showToast(res.error || 'Error', 'error');
|
||||
}
|
||||
|
||||
function resetPwd(id, username) {
|
||||
document.getElementById('pwd-user-id').value = id;
|
||||
document.getElementById('pwd-user-name').textContent = 'Usuario: @' + username;
|
||||
document.getElementById('new-pwd').value = '';
|
||||
openModal('pwdModal');
|
||||
}
|
||||
async function savePwd() {
|
||||
const id = document.getElementById('pwd-user-id').value;
|
||||
const pwd = document.getElementById('new-pwd').value;
|
||||
if (!pwd) { showToast('Ingresa una contraseña', 'error'); return; }
|
||||
const r = await fetch(`/users/${id}/reset-password`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({password: pwd}) });
|
||||
const res = await r.json();
|
||||
if (res.success) { showToast('✅ Contraseña actualizada'); closeModal('pwdModal'); }
|
||||
else showToast(res.error || 'Error', 'error');
|
||||
}
|
||||
async function delUser(id) {
|
||||
if (!confirm('¿Eliminar este usuario?')) return;
|
||||
const r = await fetch(`/users/${id}/delete`, { method:'POST' });
|
||||
const res = await r.json();
|
||||
if (res.success) { showToast('🗑️ Usuario eliminado'); setTimeout(()=>location.reload(), 800); }
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.section-label { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.08);font-weight:600;display:block; }
|
||||
.hint { font-size:10px; color:var(--gray); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user