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:
Ligbox Spec Hub 2026-07-01 15:06:31 +00:00
parent 2d2b74fad8
commit f344931925
7 changed files with 610 additions and 33 deletions

View file

@ -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

View 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 25."
),
}

View file

@ -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,

View 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()

View file

@ -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');
}

View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 25 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;

View file

@ -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;