diff --git a/projects/ops-desk/VERSION b/projects/ops-desk/VERSION index c8b34bc..a4ddb48 100644 --- a/projects/ops-desk/VERSION +++ b/projects/ops-desk/VERSION @@ -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 diff --git a/projects/ops-desk/api/app/activation_mapper.py b/projects/ops-desk/api/app/activation_mapper.py new file mode 100644 index 0000000..9ab05b1 --- /dev/null +++ b/projects/ops-desk/api/app/activation_mapper.py @@ -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." + ), + } diff --git a/projects/ops-desk/api/app/billing_routes.py b/projects/ops-desk/api/app/billing_routes.py index 8c4d86f..43d2395 100644 --- a/projects/ops-desk/api/app/billing_routes.py +++ b/projects/ops-desk/api/app/billing_routes.py @@ -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, diff --git a/projects/ops-desk/api/tests/test_activation_mapper_043.py b/projects/ops-desk/api/tests/test_activation_mapper_043.py new file mode 100644 index 0000000..beeec57 --- /dev/null +++ b/projects/ops-desk/api/tests/test_activation_mapper_043.py @@ -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() diff --git a/projects/ops-desk/frontend/assets/auth.js b/projects/ops-desk/frontend/assets/auth.js index c81c42f..a8475aa 100644 --- a/projects/ops-desk/frontend/assets/auth.js +++ b/projects/ops-desk/frontend/assets/auth.js @@ -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'); } diff --git a/projects/ops-desk/frontend/assets/billing-ui.js b/projects/ops-desk/frontend/assets/billing-ui.js index e7cce03..2f51c2d 100644 --- a/projects/ops-desk/frontend/assets/billing-ui.js +++ b/projects/ops-desk/frontend/assets/billing-ui.js @@ -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, '>'); } + 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]) => `
${esc(k)}
${esc(v)}
`) + .join(''); + return ` +
+

${esc(title)}

+
${rows || '
sem dados
'}
+
`; + } + + 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 = ` + `; + 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) => `` + ).join(''); const backdrop = document.createElement('div'); backdrop.className = 'billing-modal-backdrop'; backdrop.innerHTML = ` @@ -36,29 +94,46 @@ const DeskBilling = (() => {
CNPJ/CPF
${esc(acc.tax_id || '—')}
Recorrência
${acc.recurrence_active ? '✅ ativa' : '—'}
+ ${canActivate() ? ` + ` : ''}

FOSSBilling 💳 · Odoo

- ${canManageVm112Domains?.() ? `` : ''} + ${canActivate() && !acc.recurrence_active ? `` : ''} + ${canManageVm112Domains?.() ? `` : ''}
`; 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; diff --git a/projects/ops-desk/frontend/assets/styles.css b/projects/ops-desk/frontend/assets/styles.css index 07fa9fe..1c32844 100644 --- a/projects/ops-desk/frontend/assets/styles.css +++ b/projects/ops-desk/frontend/assets/styles.css @@ -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;