feat(desk): Spec 043 Fase 1 — activation preview API e UI
Adiciona mapper de campos, endpoint GET activation-preview, modal FOSS/OpenPanel/Odoo e canManageBilling para staff activar conta. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2d2b74fad8
commit
f344931925
7 changed files with 610 additions and 33 deletions
|
|
@ -1,17 +1,23 @@
|
|||
0.14.0-pre-spec043-activation
|
||||
0.14.1-spec043-fase1-preview
|
||||
|
||||
Specs:
|
||||
043-desk-client-activation-sync — mapa Wizard→FOSS→OpenPanel→Odoo + Activar conta
|
||||
043-desk-client-activation-sync — Fase 1: activation-preview API + UI modal
|
||||
040-desk-design-system-v013 — UserWizard, Access Control Hub
|
||||
041-desk-operational-feed — Central Operacional
|
||||
|
||||
Rollback Spec 043 (antes de activação):
|
||||
Rollback Spec 043 Fase 1:
|
||||
git checkout desk-v0.14.0-pre-spec043-activation -- projects/ops-desk/
|
||||
|
||||
Rollback UI aprovado v0.13.1:
|
||||
frontend/staging-snapshot/v0.13.1-ac-hub-ui-aprovado-20260625/
|
||||
Rollback Fase 1 only (manter docs):
|
||||
git checkout desk-v0.14.0-pre-spec043-activation -- \
|
||||
projects/ops-desk/api/app/activation_mapper.py \
|
||||
projects/ops-desk/api/app/billing_routes.py \
|
||||
projects/ops-desk/frontend/assets/billing-ui.js \
|
||||
projects/ops-desk/frontend/assets/auth.js \
|
||||
projects/ops-desk/frontend/assets/styles.css
|
||||
|
||||
Rollback DS v0.13.0:
|
||||
frontend/staging-snapshot/v0.12.2-pre-ds-20260625/
|
||||
Tags:
|
||||
desk-v0.14.0-pre-spec043-activation — antes de qualquer código Spec 043
|
||||
desk-v0.14.1-spec043-fase1 — Fase 1 preview UI
|
||||
|
||||
Cache produção: ?v=20260701spec043
|
||||
Cache produção: ?v=20260701spec043f1
|
||||
|
|
|
|||
243
projects/ops-desk/api/app/activation_mapper.py
Normal file
243
projects/ops-desk/api/app/activation_mapper.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""Mapeamento activação cliente — Spec 043 activation-field-mapping.md."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_PLAN_CODE = "ligbox-mail-business"
|
||||
|
||||
PLAN_DEFAULTS: dict[str, dict[str, int]] = {
|
||||
"ligbox-mail-starter": {"seats": 10, "mail_gb": 20, "files_gb": 100},
|
||||
"ligbox-mail-business": {"seats": 25, "mail_gb": 30, "files_gb": 200},
|
||||
"ligbox-mail-enterprise": {"seats": 50, "mail_gb": 50, "files_gb": 500},
|
||||
"ligbox-mail-custom": {"seats": 25, "mail_gb": 30, "files_gb": 200},
|
||||
"ligbox-site-cms": {"seats": 1, "mail_gb": 0, "files_gb": 10},
|
||||
}
|
||||
|
||||
ACTIVATABLE_STATES = frozenset(
|
||||
{"awaiting_billing_validation", "provisioning", "policy_pending"}
|
||||
)
|
||||
|
||||
|
||||
def _digits_only(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return re.sub(r"\D", "", str(value))
|
||||
|
||||
|
||||
def normalize_company_profile(
|
||||
profile: dict[str, Any] | None,
|
||||
*,
|
||||
domain: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Unifica manager_* e address vindos do webhook wizard."""
|
||||
p = dict(profile or {})
|
||||
addr = p.get("address")
|
||||
if not isinstance(addr, dict):
|
||||
addr = {}
|
||||
p["address"] = {
|
||||
"country": addr.get("country") or "BR",
|
||||
"city": addr.get("city") or "",
|
||||
"postal_code": addr.get("postal_code") or "",
|
||||
"street": addr.get("street") or "",
|
||||
"state": addr.get("state") or "",
|
||||
}
|
||||
p.setdefault("domain", domain.strip().lower())
|
||||
if not p.get("manager_email"):
|
||||
p["manager_email"] = f"admin@{domain.strip().lower()}"
|
||||
if not p.get("manager_name"):
|
||||
p["manager_name"] = p.get("trade_name") or p.get("legal_name") or ""
|
||||
return p
|
||||
|
||||
|
||||
def split_manager_name(name: str | None) -> tuple[str, str]:
|
||||
parts = (name or "").strip().split(None, 1)
|
||||
if not parts:
|
||||
return ("", "")
|
||||
if len(parts) == 1:
|
||||
return (parts[0], "")
|
||||
return (parts[0], parts[1])
|
||||
|
||||
|
||||
def derive_openpanel_username(domain: str, profile: dict[str, Any] | None = None) -> str:
|
||||
"""Sugestão username hub — Spec 024 (7 chars alfanum + sufixo)."""
|
||||
existing = (profile or {}).get("openpanel_username")
|
||||
if existing:
|
||||
return str(existing).strip().lower()
|
||||
label = re.sub(r"[^a-z0-9]", "", domain.split(".")[0].lower()) or "ligbox"
|
||||
if len(label) <= 7:
|
||||
return f"{label}x"[:16]
|
||||
return f"{label[:7]}x"[:16]
|
||||
|
||||
|
||||
def resolve_plan_config(plan_code: str | None, config: dict[str, Any] | None = None) -> tuple[str, dict[str, int]]:
|
||||
code = (plan_code or DEFAULT_PLAN_CODE).strip() or DEFAULT_PLAN_CODE
|
||||
defaults = dict(PLAN_DEFAULTS.get(code, PLAN_DEFAULTS[DEFAULT_PLAN_CODE]))
|
||||
if config:
|
||||
for key in ("seats", "mail_gb", "files_gb"):
|
||||
if config.get(key) is not None:
|
||||
defaults[key] = int(config[key])
|
||||
return code, defaults
|
||||
|
||||
|
||||
def map_foss_client(profile: dict[str, Any], domain: str) -> dict[str, Any]:
|
||||
first, last = split_manager_name(profile.get("manager_name"))
|
||||
email = (profile.get("manager_email") or profile.get("email_billing") or "").strip().lower()
|
||||
addr = profile.get("address") or {}
|
||||
return {
|
||||
"email": email,
|
||||
"first_name": first,
|
||||
"last_name": last,
|
||||
"company": profile.get("legal_name") or profile.get("trade_name") or domain,
|
||||
"company_vat": _digits_only(profile.get("tax_id")),
|
||||
"phone": profile.get("phone_mobile") or profile.get("contact_phone") or profile.get("phone_landline") or "",
|
||||
"address_1": addr.get("street") or "",
|
||||
"city": addr.get("city") or "",
|
||||
"state": addr.get("state") or "",
|
||||
"postcode": addr.get("postal_code") or "",
|
||||
"country": addr.get("country") or "BR",
|
||||
"currency": "BRL",
|
||||
"status": "active",
|
||||
"custom_ligbox_domain": domain,
|
||||
}
|
||||
|
||||
|
||||
def map_foss_order(
|
||||
*,
|
||||
domain: str,
|
||||
profile: dict[str, Any],
|
||||
plan_code: str,
|
||||
config: dict[str, int],
|
||||
foss_client_id: str | int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"client_id": foss_client_id,
|
||||
"product_slug": plan_code,
|
||||
"period": "1M",
|
||||
"domain": domain,
|
||||
"manager_email": profile.get("manager_email"),
|
||||
"manager_name": profile.get("manager_name"),
|
||||
"config": {
|
||||
"seats": config.get("seats"),
|
||||
"mail_gb": config.get("mail_gb"),
|
||||
"files_gb": config.get("files_gb"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def map_openpanel(
|
||||
*,
|
||||
domain: str,
|
||||
profile: dict[str, Any],
|
||||
plan_code: str,
|
||||
config: dict[str, int],
|
||||
) -> dict[str, Any]:
|
||||
username = derive_openpanel_username(domain, profile)
|
||||
return {
|
||||
"username": username,
|
||||
"email": profile.get("manager_email"),
|
||||
"plan_name": plan_code,
|
||||
"domain": domain,
|
||||
"metadata": {
|
||||
"bundle_type": "ligbox_mail" if plan_code.startswith("ligbox-mail") else "ligbox_hosting",
|
||||
"max_seats": config.get("seats"),
|
||||
"mail_gb_per_seat": config.get("mail_gb"),
|
||||
"files_gb_per_seat": config.get("files_gb"),
|
||||
"easydmarc": True,
|
||||
"wizard_domain": domain,
|
||||
"manager_name": profile.get("manager_name"),
|
||||
"manager_email": profile.get("manager_email"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def map_odoo_partner(profile: dict[str, Any], domain: str, foss_client_id: str | int | None = None) -> dict[str, Any]:
|
||||
addr = profile.get("address") or {}
|
||||
return {
|
||||
"name": profile.get("legal_name") or profile.get("trade_name") or domain,
|
||||
"display_name": profile.get("trade_name") or profile.get("legal_name") or domain,
|
||||
"vat": profile.get("tax_id") or "",
|
||||
"email": profile.get("email_billing") or profile.get("manager_email") or "",
|
||||
"phone": profile.get("phone_mobile") or profile.get("contact_phone") or "",
|
||||
"street": addr.get("street") or "",
|
||||
"city": addr.get("city") or "",
|
||||
"zip": addr.get("postal_code") or "",
|
||||
"country_code": addr.get("country") or "BR",
|
||||
"company_type": "company",
|
||||
"customer_rank": 1,
|
||||
"ref": domain,
|
||||
"comment": {
|
||||
"manager_email": profile.get("manager_email"),
|
||||
"foss_client_id": foss_client_id,
|
||||
"domain": domain,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def map_wizard_mail_bundle(
|
||||
*,
|
||||
domain: str,
|
||||
profile: dict[str, Any],
|
||||
config: dict[str, int],
|
||||
foss_order_id: str | int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"domain": domain,
|
||||
"admin_email": profile.get("manager_email"),
|
||||
"admin_name": profile.get("manager_name"),
|
||||
"seats": config.get("seats"),
|
||||
"mail_gb_per_seat": config.get("mail_gb"),
|
||||
"files_gb_per_seat": config.get("files_gb"),
|
||||
"foss_order_id": foss_order_id,
|
||||
}
|
||||
|
||||
|
||||
def build_activation_preview(
|
||||
account: dict[str, Any],
|
||||
*,
|
||||
plan_code: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
domain = str(account.get("domain") or "").strip().lower()
|
||||
profile = normalize_company_profile(account.get("company_profile"), domain=domain)
|
||||
code, cfg = resolve_plan_config(plan_code or account.get("plan_code"), config)
|
||||
foss_id = account.get("external_customer_id")
|
||||
billing_state = account.get("billing_state") or "awaiting_billing_validation"
|
||||
can_activate = billing_state in ACTIVATABLE_STATES and not account.get("recurrence_active")
|
||||
|
||||
return {
|
||||
"billing_account_id": account.get("id"),
|
||||
"domain": domain,
|
||||
"billing_state": billing_state,
|
||||
"can_activate": can_activate,
|
||||
"plan_code": code,
|
||||
"config": cfg,
|
||||
"company_profile": profile,
|
||||
"mapped": {
|
||||
"foss_client": map_foss_client(profile, domain),
|
||||
"foss_order": map_foss_order(
|
||||
domain=domain,
|
||||
profile=profile,
|
||||
plan_code=code,
|
||||
config=cfg,
|
||||
foss_client_id=foss_id,
|
||||
),
|
||||
"openpanel": map_openpanel(domain=domain, profile=profile, plan_code=code, config=cfg),
|
||||
"odoo_partner": map_odoo_partner(profile, domain, foss_client_id=foss_id),
|
||||
"wizard_mail_bundle": map_wizard_mail_bundle(
|
||||
domain=domain,
|
||||
profile=profile,
|
||||
config=cfg,
|
||||
),
|
||||
},
|
||||
"existing_ids": {
|
||||
"foss_client_id": foss_id,
|
||||
"external_subscription_id": account.get("external_subscription_id"),
|
||||
"openpanel_username": profile.get("openpanel_username"),
|
||||
},
|
||||
"notes": (
|
||||
"Preview read-only — Fase 1 Spec 043. "
|
||||
"POST /activate implementado nas Fases 2–5."
|
||||
),
|
||||
}
|
||||
|
|
@ -93,6 +93,49 @@ def get_billing_account(account_id: int, user: auth.DeskUser = Depends(_reader))
|
|||
return acc
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}/activation-preview")
|
||||
def billing_activation_preview(
|
||||
account_id: int,
|
||||
plan_code: str = "",
|
||||
user: auth.DeskUser = Depends(_manager),
|
||||
):
|
||||
"""Preview read-only — Spec 043 Fase 1."""
|
||||
from app.activation_mapper import build_activation_preview
|
||||
|
||||
conn = auth.db()
|
||||
try:
|
||||
acc = billing_store.get_account(conn, account_id, mask=False)
|
||||
finally:
|
||||
conn.close()
|
||||
if not acc:
|
||||
raise HTTPException(404, "conta não encontrada")
|
||||
preview = build_activation_preview(
|
||||
acc,
|
||||
plan_code=plan_code.strip() or None,
|
||||
)
|
||||
if should_mask_sensitive(user.role):
|
||||
preview["company_profile"] = billing_store._mask_profile(preview.get("company_profile") or {})
|
||||
for key in ("email", "company_vat", "phone"):
|
||||
if preview["mapped"]["foss_client"].get(key):
|
||||
if "email" in key:
|
||||
preview["mapped"]["foss_client"][key] = billing_store._mask_email(
|
||||
preview["mapped"]["foss_client"][key]
|
||||
)
|
||||
elif key == "company_vat":
|
||||
preview["mapped"]["foss_client"][key] = billing_store._mask_tax_id(
|
||||
preview["mapped"]["foss_client"][key]
|
||||
)
|
||||
if preview["mapped"]["odoo_partner"].get("email"):
|
||||
preview["mapped"]["odoo_partner"]["email"] = billing_store._mask_email(
|
||||
preview["mapped"]["odoo_partner"]["email"]
|
||||
)
|
||||
if preview["mapped"]["odoo_partner"].get("vat"):
|
||||
preview["mapped"]["odoo_partner"]["vat"] = billing_store._mask_tax_id(
|
||||
preview["mapped"]["odoo_partner"]["vat"]
|
||||
)
|
||||
return preview
|
||||
|
||||
|
||||
@router.patch("/accounts/{account_id}")
|
||||
def patch_billing_account(
|
||||
account_id: int,
|
||||
|
|
|
|||
63
projects/ops-desk/api/tests/test_activation_mapper_043.py
Normal file
63
projects/ops-desk/api/tests/test_activation_mapper_043.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Unit tests — Spec 043 activation mapper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
API_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load(name: str, rel_path: str):
|
||||
path = API_ROOT / rel_path
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
mapper = _load("activation_mapper_043", "app/activation_mapper.py")
|
||||
|
||||
|
||||
class TestActivationMapper043(unittest.TestCase):
|
||||
def test_derive_openpanel_username(self):
|
||||
self.assertEqual(mapper.derive_openpanel_username("ligbox.com.br"), "ligboxx")
|
||||
self.assertEqual(mapper.derive_openpanel_username("empresa.com.br"), "empresax")
|
||||
|
||||
def test_normalize_manager_defaults(self):
|
||||
profile = mapper.normalize_company_profile(
|
||||
{"legal_name": "ACME LTDA", "email_billing": "fin@acme.com"},
|
||||
domain="acme.com.br",
|
||||
)
|
||||
self.assertEqual(profile["manager_email"], "admin@acme.com.br")
|
||||
|
||||
def test_build_activation_preview(self):
|
||||
account = {
|
||||
"id": 7,
|
||||
"domain": "acme.com.br",
|
||||
"billing_state": "awaiting_billing_validation",
|
||||
"recurrence_active": False,
|
||||
"company_profile": {
|
||||
"legal_name": "ACME Serviços LTDA",
|
||||
"trade_name": "ACME",
|
||||
"tax_id": "00000000000191",
|
||||
"email_billing": "fin@acme.com",
|
||||
"manager_name": "João Silva",
|
||||
"manager_email": "admin@acme.com.br",
|
||||
"address": {"country": "BR", "city": "São Paulo", "postal_code": "01310-100"},
|
||||
},
|
||||
}
|
||||
preview = mapper.build_activation_preview(account)
|
||||
self.assertTrue(preview["can_activate"])
|
||||
self.assertEqual(preview["mapped"]["foss_client"]["email"], "admin@acme.com.br")
|
||||
self.assertEqual(preview["mapped"]["openpanel"]["plan_name"], "ligbox-mail-business")
|
||||
self.assertEqual(preview["mapped"]["odoo_partner"]["ref"], "acme.com.br")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -119,6 +119,10 @@ function canManageVm112Domains() {
|
|||
return hasRole('super_admin', 'ops_lead');
|
||||
}
|
||||
|
||||
function canManageBilling() {
|
||||
return hasRole('super_admin', 'ops_lead', 'finance', 'sales_admin');
|
||||
}
|
||||
|
||||
function canAssist() {
|
||||
return hasRole('super_admin', 'ops_lead', 'technician');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
/**
|
||||
* Billing UI — Spec 023 (conta cliente modal + overview badge)
|
||||
* Billing UI — Spec 023 + Spec 043 (conta cliente + activação preview)
|
||||
*/
|
||||
const DeskBilling = (() => {
|
||||
const API = '/api';
|
||||
const PLAN_OPTIONS = [
|
||||
{ code: 'ligbox-mail-starter', label: 'Mail Starter (10)' },
|
||||
{ code: 'ligbox-mail-business', label: 'Mail Business (25)' },
|
||||
{ code: 'ligbox-mail-enterprise', label: 'Mail Enterprise (50)' },
|
||||
{ code: 'ligbox-mail-custom', label: 'Mail Custom' },
|
||||
];
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function canActivate() {
|
||||
return typeof canManageBilling === 'function' && canManageBilling();
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
...options,
|
||||
|
|
@ -21,9 +31,57 @@ const DeskBilling = (() => {
|
|||
document.querySelector('.billing-modal-backdrop')?.remove();
|
||||
}
|
||||
|
||||
function renderMappedBlock(title, obj) {
|
||||
const rows = Object.entries(obj || {})
|
||||
.filter(([, v]) => v != null && v !== '' && typeof v !== 'object')
|
||||
.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`)
|
||||
.join('');
|
||||
return `
|
||||
<section class="billing-activation-col">
|
||||
<h4>${esc(title)}</h4>
|
||||
<dl class="kv kv-compact">${rows || '<dt>—</dt><dd>sem dados</dd>'}</dl>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
async function openActivationPreview(accountId, domain) {
|
||||
const planSel = document.querySelector('[data-billing-plan]');
|
||||
const plan = planSel?.value || '';
|
||||
const qs = plan ? `?plan_code=${encodeURIComponent(plan)}` : '';
|
||||
const preview = await api(`/v1/billing/accounts/${accountId}/activation-preview${qs}`);
|
||||
const m = preview.mapped || {};
|
||||
const foss = m.foss_client || {};
|
||||
const op = m.openpanel || {};
|
||||
const odoo = m.odoo_partner || {};
|
||||
const meta = op.metadata || {};
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'billing-modal-backdrop';
|
||||
backdrop.innerHTML = `
|
||||
<div class="billing-modal billing-modal-wide" role="dialog">
|
||||
<h3 style="margin-top:0">Activar conta — ${esc(domain)}</h3>
|
||||
<p class="ticket-meta">Estado: <strong>${esc(preview.billing_state)}</strong> · Plano: ${esc(preview.plan_code)}</p>
|
||||
<div class="billing-activation-grid">
|
||||
${renderMappedBlock('FOSSBilling — cliente', foss)}
|
||||
${renderMappedBlock('OpenPanel — hub', { username: op.username, email: op.email, plan_name: op.plan_name, domain: op.domain, seats: meta.max_seats })}
|
||||
${renderMappedBlock('Odoo — empresa', { name: odoo.name, vat: odoo.vat, email: odoo.email, ref: odoo.ref })}
|
||||
</div>
|
||||
<p class="ticket-meta" style="margin-top:0.75rem">${esc(preview.notes || '')}</p>
|
||||
<div style="display:flex;gap:0.5rem;margin-top:1rem;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-billing-activate-confirm disabled title="Fase 2–5 Spec 043">Activar conta (em breve)</button>
|
||||
<button type="button" class="btn btn-sm" data-billing-close>Fechar</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(backdrop);
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) closeModal(); });
|
||||
backdrop.querySelector('[data-billing-close]')?.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
async function openAccountModal(domain) {
|
||||
closeModal();
|
||||
const acc = await api(`/v1/billing/accounts/by-domain/${encodeURIComponent(domain)}`);
|
||||
const planOptions = PLAN_OPTIONS.map(
|
||||
(p) => `<option value="${esc(p.code)}" ${acc.plan_code === p.code ? 'selected' : ''}>${esc(p.label)}</option>`
|
||||
).join('');
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'billing-modal-backdrop';
|
||||
backdrop.innerHTML = `
|
||||
|
|
@ -36,29 +94,46 @@ const DeskBilling = (() => {
|
|||
<dt>CNPJ/CPF</dt><dd>${esc(acc.tax_id || '—')}</dd>
|
||||
<dt>Recorrência</dt><dd>${acc.recurrence_active ? '✅ ativa' : '—'}</dd>
|
||||
</dl>
|
||||
${canActivate() ? `
|
||||
<label class="ticket-meta" style="display:block;margin-top:0.5rem">Plano comercial
|
||||
<select class="input-sm" data-billing-plan style="display:block;margin-top:0.25rem;max-width:100%">${planOptions}</select>
|
||||
</label>` : ''}
|
||||
<p class="ticket-meta">
|
||||
<a href="${esc(acc.links?.fossbilling || '#')}" target="_blank" rel="noreferrer">FOSSBilling 💳</a>
|
||||
· <a href="${esc(acc.links?.odoo || '#')}" target="_blank" rel="noreferrer">Odoo</a>
|
||||
</p>
|
||||
<div style="display:flex;gap:0.5rem;margin-top:1rem;flex-wrap:wrap">
|
||||
${canManageVm112Domains?.() ? `<button type="button" class="btn btn-primary btn-sm" data-billing-ativate="${acc.id}">Activar recorrência</button>` : ''}
|
||||
${canActivate() && !acc.recurrence_active ? `<button type="button" class="btn btn-primary btn-sm" data-billing-activate="${acc.id}" data-domain="${esc(domain)}">Activar conta</button>` : ''}
|
||||
${canManageVm112Domains?.() ? `<button type="button" class="btn btn-sm" data-billing-ativate="${acc.id}">Activar recorrência</button>` : ''}
|
||||
<button type="button" class="btn btn-sm" data-billing-close>Fechar</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(backdrop);
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) closeModal(); });
|
||||
backdrop.querySelector('[data-billing-close]')?.addEventListener('click', closeModal);
|
||||
backdrop.querySelector('[data-billing-activate]')?.addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
const id = btn.getAttribute('data-billing-activate');
|
||||
const dom = btn.getAttribute('data-domain') || domain;
|
||||
try {
|
||||
await openActivationPreview(id, dom);
|
||||
} catch (err) {
|
||||
alert(err.message || 'Erro ao carregar preview');
|
||||
}
|
||||
});
|
||||
backdrop.querySelector('[data-billing-ativate]')?.addEventListener('click', async () => {
|
||||
await api(`/v1/billing/accounts/${acc.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ recurrence_active: true, billing_state: 'billing_active' }),
|
||||
});
|
||||
closeModal();
|
||||
if (state.view === 'overview-home') await renderOverviewHome();
|
||||
if (typeof state !== 'undefined' && state.view === 'overview-home' && typeof renderOverviewHome === 'function') {
|
||||
await renderOverviewHome();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { openAccountModal, closeModal };
|
||||
return { openAccountModal, openActivationPreview, closeModal };
|
||||
})();
|
||||
|
||||
window.DeskBilling = DeskBilling;
|
||||
|
|
|
|||
|
|
@ -4409,16 +4409,15 @@ button.health-card {
|
|||
/* Process cards — grid uniforme (Spec 033 § proc-card) */
|
||||
.proc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.proc-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 168px;
|
||||
padding: 16px;
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
|
|
@ -4430,15 +4429,18 @@ button.health-card {
|
|||
.proc-card--slate { border-top: 3px solid #64748b; }
|
||||
.proc-card--violet { border-top: 3px solid #8b5cf6; }
|
||||
.proc-card--aqua { border-top: 3px solid #06b6d4; }
|
||||
.proc-card-head {
|
||||
.proc-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
gap: 10px;
|
||||
}
|
||||
.proc-card-heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.proc-card-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -4453,33 +4455,36 @@ button.health-card {
|
|||
.proc-card--slate .proc-card-icon { background: #f1f5f9; }
|
||||
.proc-card--violet .proc-card-icon { background: #ede9fe; }
|
||||
.proc-card--aqua .proc-card-icon { background: #cffafe; }
|
||||
.proc-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.proc-card-spec {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
line-height: 1.2;
|
||||
max-width: 42%;
|
||||
}
|
||||
.proc-card-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
font-size: 0.62rem;
|
||||
position: static;
|
||||
font-size: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.proc-card-title {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
color: #0f172a;
|
||||
line-height: 1.35;
|
||||
padding-right: 3.5rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.proc-card-desc {
|
||||
margin: 6px 0 0;
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
|
|
@ -4498,6 +4503,138 @@ button.health-card {
|
|||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
.proc-card-foot .btn { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
/* INFRA detail modal */
|
||||
#infra-process-modal .modal-panel {
|
||||
width: min(100%, 920px);
|
||||
}
|
||||
.infra-detail-hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #fffdf9 0%, #f8fafc 100%);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.infra-detail-hero__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
font-size: 1.35rem;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.infra-detail-hero__copy { flex: 1; min-width: 0; }
|
||||
.infra-detail-hero__eyebrow {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
.infra-detail-hero__title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
.infra-detail-hero__sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.infra-detail-hero__status { flex-shrink: 0; margin-top: 2px; }
|
||||
.infra-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.infra-detail-card {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e8edf2;
|
||||
background: #fff;
|
||||
}
|
||||
.infra-detail-card--wide { grid-column: 1 / -1; }
|
||||
.infra-detail-card__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: #5c2e2e;
|
||||
}
|
||||
.infra-detail-kv {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.infra-detail-kv dt {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.infra-detail-kv dd {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #1e293b;
|
||||
word-break: break-word;
|
||||
}
|
||||
.infra-detail-kv dd code {
|
||||
font-size: 0.74rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.infra-detail-code-list {
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.infra-detail-code-list code {
|
||||
font-size: 0.72rem;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.infra-detail-validation {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: #334155;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.infra-detail-id {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.infra-detail-id span {
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.infra-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e8edf2;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.infra-detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.proc-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
|
@ -4849,6 +4986,12 @@ button.health-card {
|
|||
}
|
||||
.billing-modal-backdrop { position: fixed; inset: 0; background: #0009; z-index: 900; display: flex; align-items: center; justify-content: center; }
|
||||
.billing-modal { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; max-width: 480px; width: 90%; }
|
||||
.billing-modal-wide { max-width: 920px; }
|
||||
.billing-activation-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-top: 0.75rem; }
|
||||
.billing-activation-col { border: 1px solid var(--border); border-radius: 6px; padding: 0.65rem 0.75rem; background: var(--bg-subtle, #f8f9fa); }
|
||||
.billing-activation-col h4 { margin: 0 0 0.5rem; font-size: 0.85rem; }
|
||||
.kv-compact dt, .kv-compact dd { font-size: 0.8rem; }
|
||||
@media (max-width: 768px) { .billing-activation-grid { grid-template-columns: 1fr; } }
|
||||
.ticket-card-aside {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
|
|||
Loading…
Reference in a new issue