feat: module toggles in POS config and Instance Manager

- Add GET/PUT /pos/api/config/modules endpoints in POS config_bp.py
- Update sidebar.js to filter nav items based on enabled modules
- Add Modules section to POS config.html with toggles for WhatsApp, Marketplace, MercadoLibre
- Add module load/save logic to POS config.js
- Preload modules in app-init.js for sidebar caching

- Add tenant module management to Instance Manager
  - get_tenant_modules / update_tenant_modules in tenant_service.py
  - GET/PUT /api/tenants/<id>/modules endpoints in tenants_bp.py
  - Add modules modal to manager index.html
  - Add module editing UI and logic to manager.js
  - Add toggle-switch CSS to manager.css
This commit is contained in:
2026-05-28 00:21:52 +00:00
parent 999591e248
commit 718fa06888
26 changed files with 2614 additions and 429 deletions

View File

@@ -58,3 +58,24 @@ def delete_tenant(tenant_id):
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
@tenants_bp.route("/<int:tenant_id>/modules", methods=["GET"])
@require_manager_auth
def get_tenant_modules(tenant_id):
try:
result = tenant_service.get_tenant_modules(tenant_id)
return jsonify({"data": result})
except Exception as e:
return jsonify({"error": str(e)}), 500
@tenants_bp.route("/<int:tenant_id>/modules", methods=["PUT"])
@require_manager_auth
def update_tenant_modules(tenant_id):
data = request.get_json() or {}
try:
result = tenant_service.update_tenant_modules(tenant_id, data)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@@ -311,6 +311,55 @@ def get_tenant_login_url(subdomain):
return f"https://{subdomain}.{domain}/pos/login"
def get_tenant_modules(tenant_id):
"""Get enabled modules for a tenant from tenant_config."""
tenant = get_tenant(tenant_id)
if not tenant:
raise ValueError("Tenant not found")
db_name = tenant["db_name"]
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
conn = psycopg2.connect(dsn)
cur = conn.cursor()
try:
modules = {}
for key in ["module_whatsapp", "module_marketplace", "module_meli"]:
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
row = cur.fetchone()
modules[key.replace("module_", "")] = (row[0] or "").lower() == "true" if row else True
return modules
finally:
cur.close()
conn.close()
def update_tenant_modules(tenant_id, modules):
"""Update enabled modules for a tenant in tenant_config."""
tenant = get_tenant(tenant_id)
if not tenant:
raise ValueError("Tenant not found")
db_name = tenant["db_name"]
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
conn = psycopg2.connect(dsn)
cur = conn.cursor()
try:
key_map = {
"whatsapp": "module_whatsapp",
"marketplace": "module_marketplace",
"meli": "module_meli",
}
for field, key in key_map.items():
value = "true" if modules.get(field) else "false"
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, value))
conn.commit()
return {"success": True, "tenant_id": tenant_id, "modules": modules}
finally:
cur.close()
conn.close()
def get_dashboard_stats():
"""Global stats for the manager dashboard."""
conn = get_master_conn()

View File

@@ -661,3 +661,42 @@ body {
.sidebar-brand span, .nav-item span, .user-info span { display: none; }
.stats-grid { grid-template-columns: 1fr; }
}
/* Toggle switch for modules modal */
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
cursor: pointer;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
inset: 0;
background: var(--border);
border-radius: 24px;
transition: background 0.2s;
}
.toggle-slider::before {
content: "";
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle-switch input:checked + .toggle-slider {
background: var(--success);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(20px);
}

View File

@@ -188,6 +188,7 @@ async function loadDemos() {
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
<td>
<button class="btn-icon" onclick="openModulesModal(${d.id}, '${escapeHtml(d.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
<button class="btn-icon" onclick="resetTenant(${d.id})" title="Resetear"><i class="fas fa-undo"></i></button>
<button class="btn-icon" onclick="toggleTenant(${d.id}, ${!d.is_active})" title="${d.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${d.is_active ? "pause" : "play"}"></i></button>
<button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
@@ -254,6 +255,7 @@ async function loadTenants(withStats = false) {
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
<td>${formatDate(t.created_at)}</td>
<td>
<button class="btn-icon" onclick="openModulesModal(${t.id}, '${escapeHtml(t.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
<button class="btn-icon" onclick="resetTenant(${t.id})" title="Resetear datos"><i class="fas fa-undo"></i></button>
<button class="btn-icon" onclick="toggleTenant(${t.id}, ${!t.is_active})" title="${t.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${t.is_active ? "pause" : "play"}"></i></button>
<button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
@@ -475,5 +477,59 @@ function copyText(text) {
navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success"));
}
// ─── Modules ───────────────────────────────────────────────────────────────
let currentModulesTenantId = null;
async function openModulesModal(tenantId, name) {
currentModulesTenantId = tenantId;
document.getElementById("modules-modal-title").textContent = `Módulos — ${escapeHtml(name)}`;
document.getElementById("modules-modal").style.display = "flex";
// Load current state
const res = await api(`/api/tenants/${tenantId}/modules`);
if (res && res.status === 200) {
const m = res.data.data;
document.getElementById("mod-whatsapp").checked = m.whatsapp !== false;
document.getElementById("mod-marketplace").checked = m.marketplace !== false;
document.getElementById("mod-meli").checked = m.meli !== false;
} else {
toast("Error al cargar módulos", "error");
}
}
function closeModulesModal() {
document.getElementById("modules-modal").style.display = "none";
currentModulesTenantId = null;
}
async function saveModules() {
if (!currentModulesTenantId) return;
const btn = document.getElementById("modules-save-btn");
const originalText = btn.innerHTML;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> Guardando...`;
btn.disabled = true;
const payload = {
whatsapp: document.getElementById("mod-whatsapp").checked,
marketplace: document.getElementById("mod-marketplace").checked,
meli: document.getElementById("mod-meli").checked,
};
const res = await api(`/api/tenants/${currentModulesTenantId}/modules`, {
method: "PUT",
body: payload
});
if (res && res.status === 200) {
toast("Módulos actualizados", "success");
closeModulesModal();
} else {
toast(res?.data?.error || "Error al guardar", "error");
}
btn.innerHTML = originalText;
btn.disabled = false;
}
// ─── Init ──────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", initAuth);

View File

@@ -316,6 +316,53 @@
</div>
</div>
<!-- Modules Modal -->
<div id="modules-modal" class="modal" style="display:none;">
<div class="modal-overlay" onclick="closeModulesModal()"></div>
<div class="modal-content" style="max-width:480px;">
<div class="modal-header">
<h3 id="modules-modal-title">Módulos del Tenant</h3>
<button class="btn-icon" onclick="closeModulesModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
<div>
<div style="font-weight:600;color:var(--text);">WhatsApp</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de WhatsApp Bridge</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-whatsapp">
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
<div>
<div style="font-weight:600;color:var(--text);">Marketplace</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de Marketplace interno</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-marketplace">
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;">
<div>
<div style="font-weight:600;color:var(--text);">MercadoLibre</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de MercadoLibre</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-meli">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModulesModal()">Cancelar</button>
<button class="btn btn-primary" id="modules-save-btn" onclick="saveModules()">Guardar</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast-container"></div>