This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions

View File

@@ -1,32 +1,34 @@
<!-- src/views/pages/saas/SubscriptionHealthPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import InputText from 'primevue/inputtext'
import Divider from 'primevue/divider'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Message from 'primevue/message'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const fixing = ref(false)
const fixingOwner = ref(null)
const isFetching = ref(false)
const activeTab = ref(0) // 0 = terapeutas, 1 = clínicas
const rows = ref([])
const q = ref('')
const total = computed(() => rows.value.length)
const totalMissing = computed(() => rows.value.filter(r => r.mismatch_type === 'missing_entitlement').length)
const totalUnexpected = computed(() => rows.value.filter(r => r.mismatch_type === 'unexpected_entitlement').length)
const personalRows = ref([]) // v_subscription_feature_mismatch
const clinicRows = ref([]) // v_tenant_feature_exceptions
// -----------------------------
// Labels / Severities
// -----------------------------
function severityForMismatch (t) {
if (t === 'missing_entitlement') return 'danger'
if (t === 'unexpected_entitlement') return 'warning'
@@ -34,40 +36,200 @@ function severityForMismatch (t) {
}
function labelForMismatch (t) {
if (t === 'missing_entitlement') return 'Missing'
if (t === 'unexpected_entitlement') return 'Unexpected'
return t || '-'
if (t === 'missing_entitlement') return 'Faltando'
if (t === 'unexpected_entitlement') return 'Inesperado'
return t || ''
}
async function fetchAll () {
loading.value = true
try {
const { data, error } = await supabase
.from('v_subscription_feature_mismatch')
.select('*')
if (error) throw error
rows.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
function helpForMismatch (t) {
if (t === 'missing_entitlement') return 'O plano exige, mas não está ativo'
if (t === 'unexpected_entitlement') return 'Está ativo, mas não consta no plano'
return ''
}
function filteredRows () {
function severityForException () {
return 'info'
}
function labelForException (t) {
if (t === 'commercial_exception') return 'Exceção comercial'
return t || 'Exceção'
}
function helpForException () {
return 'Feature liberada manualmente fora do plano (exceção controlada)'
}
// -----------------------------
// Computeds (Terapeutas)
// -----------------------------
const totalPersonal = computed(() => personalRows.value.length)
const totalPersonalMissing = computed(() => personalRows.value.filter(r => r.mismatch_type === 'missing_entitlement').length)
const totalPersonalUnexpected = computed(() => personalRows.value.filter(r => r.mismatch_type === 'unexpected_entitlement').length)
const totalPersonalWithoutOwner = computed(() => personalRows.value.filter(r => !r.owner_id).length)
// -----------------------------
// Computeds (Clínicas)
// -----------------------------
const totalClinic = computed(() => clinicRows.value.length)
// -----------------------------
// Search filtering
// -----------------------------
const filteredPersonal = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return rows.value
return rows.value.filter(r =>
if (!term) return personalRows.value
return personalRows.value.filter(r =>
String(r.owner_id || '').toLowerCase().includes(term) ||
String(r.feature_key || '').toLowerCase().includes(term) ||
String(r.mismatch_type || '').toLowerCase().includes(term)
)
})
const filteredClinic = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return clinicRows.value
return clinicRows.value.filter(r =>
String(r.tenant_id || '').toLowerCase().includes(term) ||
String(r.tenant_name || '').toLowerCase().includes(term) ||
String(r.plan_key || '').toLowerCase().includes(term) ||
String(r.feature_key || '').toLowerCase().includes(term) ||
String(r.exception_type || '').toLowerCase().includes(term)
)
})
// -----------------------------
// Fetch
// -----------------------------
async function fetchPersonal () {
const { data, error } = await supabase
.from('v_subscription_feature_mismatch')
.select('*')
if (error) throw error
personalRows.value = (data || []).map(r => ({
...r,
__rowKey: `${r.owner_id || 'no_owner'}|${r.feature_key || 'no_feature'}|${r.mismatch_type || 'no_type'}`
}))
}
function openOwnerSubscriptions (ownerId) {
if (!ownerId) return
router.push({ path: '/saas/subscriptions', query: { q: ownerId } })
async function fetchClinic () {
const { data, error } = await supabase
.from('v_tenant_feature_exceptions')
.select('*')
if (error) throw error
clinicRows.value = (data || []).map(r => ({
...r,
__rowKey: `${r.tenant_id || 'no_tenant'}|${r.feature_key || 'no_feature'}|${r.exception_type || 'no_type'}`
}))
}
async function fetchAll () {
if (isFetching.value) return
isFetching.value = true
loading.value = true
try {
await Promise.all([fetchPersonal(), fetchClinic()])
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
isFetching.value = false
}
}
async function reloadActiveTab () {
if (isFetching.value) return
isFetching.value = true
loading.value = true
try {
if (activeTab.value === 0) await fetchPersonal()
else await fetchClinic()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
isFetching.value = false
}
}
// -----------------------------
// Navigation helpers
// -----------------------------
async function inferOwnerKey (ownerId) {
if (!ownerId) return null
try {
const { data, error } = await supabase
.from('tenants')
.select('id')
.eq('id', ownerId)
.maybeSingle()
if (!error && data?.id) return `clinic:${ownerId}`
} catch (_) {}
try {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('tenant_id', ownerId)
.limit(1)
if (!error && Array.isArray(data) && data.length) return `clinic:${ownerId}`
} catch (_) {}
return `therapist:${ownerId}`
}
async function openOwnerSubscriptions (ownerId) {
if (!ownerId) {
toast.add({
severity: 'warn',
summary: 'Owner ausente',
detail: 'Esta linha está sem owner_id. Verifique dados inconsistentes.',
life: 4200
})
return
}
const key = await inferOwnerKey(ownerId)
router.push({ path: '/saas/subscriptions', query: { q: key || ownerId } })
}
function openClinicSubscriptions (tenantId) {
if (!tenantId) return
router.push({ path: '/saas/subscriptions', query: { q: `clinic:${tenantId}` } })
}
// -----------------------------
// Fix (Terapeutas)
// -----------------------------
function askFixOwner (ownerId) {
if (!ownerId) {
toast.add({
severity: 'warn',
summary: 'Owner ausente',
detail: 'Não é possível corrigir um owner vazio.',
life: 4500
})
return
}
confirm.require({
header: 'Confirmar correção',
message: `Reconstruir entitlements para este owner?\n\n${ownerId}\n\nIsso recalcula os recursos ativos com base no plano atual.`,
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => fixOwner(ownerId)
})
}
async function fixOwner (ownerId) {
@@ -89,15 +251,32 @@ async function fixOwner (ownerId) {
life: 2500
})
await fetchAll()
await fetchPersonal()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
toast.add({
severity: 'error',
summary: 'Erro ao corrigir owner',
detail: e?.message || String(e),
life: 5200
})
} finally {
fixing.value = false
fixingOwner.value = null
}
}
function askFixAll () {
if (!totalPersonal.value) return
confirm.require({
header: 'Confirmar correção geral',
message: `Reconstruir entitlements para TODOS os owners com divergência?\n\nTotal: ${totalPersonal.value}\n\nObs.: linhas sem owner_id indicam dado inconsistente.`,
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => fixAll()
})
}
async function fixAll () {
fixing.value = true
try {
@@ -106,126 +285,482 @@ async function fixAll () {
toast.add({
severity: 'success',
summary: 'Sistema corrigido',
summary: 'Correção aplicada',
detail: 'Entitlements reconstruídos para todos os owners com divergência.',
life: 3000
})
await fetchAll()
await fetchPersonal()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
toast.add({
severity: 'error',
summary: 'Erro ao corrigir tudo',
detail: e?.message || String(e),
life: 5200
})
} finally {
fixing.value = false
}
}
onMounted(fetchAll)
// -----------------------------
// Exceptions (Clínicas)
// -----------------------------
function askRemoveException (tenantId, featureKey) {
if (!tenantId || !featureKey) return
confirm.require({
header: 'Remover exceção',
message: `Desativar a exceção desta clínica?\n\nTenant: ${tenantId}\nFeature: ${featureKey}\n\nIsso desliga a liberação manual fora do plano.`,
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => removeException(tenantId, featureKey)
})
}
async function removeException (tenantId, featureKey) {
fixing.value = true
try {
const { error } = await supabase.rpc('set_tenant_feature_exception', {
p_tenant_id: tenantId,
p_feature_key: featureKey,
p_enabled: false,
p_reason: 'Remoção via Saúde das Assinaturas'
})
if (error) throw error
toast.add({
severity: 'success',
summary: 'Exceção removida',
detail: 'A liberação manual foi desativada para esta feature.',
life: 2800
})
await fetchClinic()
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro ao remover exceção',
detail: e?.message || String(e),
life: 5200
})
} finally {
fixing.value = false
}
}
// -------------------------
// Hero sticky
// -------------------------
const heroRef = ref(null)
const sentinelRef = ref(null)
const heroStuck = ref(false)
let heroObserver = null
const mobileMenuRef = ref(null)
const heroMenuItems = computed(() => [
{
label: 'Recarregar aba',
icon: 'pi pi-refresh',
command: reloadActiveTab,
disabled: loading.value || fixing.value
},
{
label: 'Recarregar tudo',
icon: 'pi pi-sync',
command: fetchAll,
disabled: loading.value || fixing.value
},
...(activeTab.value === 0 && totalPersonal.value > 0 ? [{
label: 'Corrigir tudo (terapeutas)',
icon: 'pi pi-wrench',
command: askFixAll,
disabled: loading.value || fixing.value
}] : [])
])
onMounted(() => {
fetchAll()
if (sentinelRef.value) {
heroObserver = new IntersectionObserver(
([entry]) => { heroStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`, threshold: 0 }
)
heroObserver.observe(sentinelRef.value)
}
})
onBeforeUnmount(() => {
heroObserver?.disconnect()
})
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Subscription Health</div>
<small class="text-color-secondary mt-1">
Divergências entre Plano (esperado) e Entitlements (atual). Use Fix para reconstruir.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2 flex-wrap justify-end">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar owner_id, feature_key..." />
</span>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="fetchAll"
/>
<Button
v-if="total > 0"
label="Fix All"
icon="pi pi-refresh"
severity="danger"
:loading="fixing"
@click="fixAll"
/>
</div>
</template>
</Toolbar>
<!-- resumo conceitual -->
<div class="surface-100 border-round p-3 mb-4">
<div class="flex flex-wrap gap-2 items-center justify-content-between">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Total: ${total}`" severity="secondary" />
<Tag :value="`Missing: ${totalMissing}`" severity="danger" />
<Tag :value="`Unexpected: ${totalUnexpected}`" severity="warning" />
</div>
<div class="text-color-secondary text-sm">
Missing = plano exige, mas não está ativo · Unexpected = ativo sem constar no plano
</div>
</div>
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="health-hero__icon-wrap">
<i class="pi pi-shield health-hero__icon" />
</div>
<DataTable
:value="filteredRows()"
dataKey="owner_id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
sortField="owner_id"
:sortOrder="1"
>
<Column field="owner_id" header="Owner" style="min-width: 22rem" />
<Column field="feature_key" header="Feature" style="min-width: 18rem" />
<Column header="Tipo" style="width: 12rem">
<template #body="{ data }">
<Tag :severity="severityForMismatch(data.mismatch_type)" :value="labelForMismatch(data.mismatch_type)" />
</template>
</Column>
<Column header="Ações" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex gap-2 flex-wrap">
<Button
icon="pi pi-external-link"
severity="secondary"
outlined
v-tooltip.top="'Abrir subscriptions deste owner (filtro)'"
@click="openOwnerSubscriptions(data.owner_id)"
/>
<Button
label="Fix owner"
icon="pi pi-wrench"
severity="danger"
outlined
:loading="fixing && fixingOwner === data.owner_id"
@click="fixOwner(data.owner_id)"
/>
</div>
</template>
</Column>
</DataTable>
<Divider class="my-5" />
<div class="text-color-secondary text-sm">
Dica: se você trocar plano e o cliente não refletir de imediato, essa página te mostra exatamente o que ficou divergente.
<div class="health-hero__sub">
Terapeutas: divergências entre plano (esperado) e entitlements (atual).
Clínicas: exceções comerciais (features liberadas manualmente fora do plano).
</div>
</div>
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<div
ref="heroRef"
class="health-hero"
:class="{ 'health-hero--stuck': heroStuck }"
>
<div class="health-hero__blobs" aria-hidden="true">
<div class="health-hero__blob health-hero__blob--1" />
<div class="health-hero__blob health-hero__blob--2" />
</div>
<div class="health-hero__inner">
<!-- Título -->
<div class="health-hero__info min-w-0">
<div class="health-hero__title">Saúde das Assinaturas</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="health-hero__actions health-hero__actions--desktop">
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loading"
:disabled="fixing"
@click="reloadActiveTab"
/>
<Button
label="Recarregar tudo"
icon="pi pi-sync"
severity="secondary"
outlined
size="small"
:loading="loading"
:disabled="fixing"
@click="fetchAll"
/>
<Button
v-if="activeTab === 0 && totalPersonal > 0"
label="Corrigir tudo (terapeutas)"
icon="pi pi-wrench"
severity="danger"
size="small"
:loading="fixing"
:disabled="loading"
@click="askFixAll"
/>
</div>
<!-- Ações mobile (< 1200px) -->
<div class="health-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-4 pb-4">
<!-- busca -->
<div class="mb-4">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="health_search"
class="w-full pr-10"
variant="filled"
:disabled="loading || fixing"
autocomplete="off"
/>
</IconField>
<label for="health_search">Buscar...</label>
</FloatLabel>
</div>
<TabView v-model:activeIndex="activeTab">
<!-- ===================================================== -->
<!-- Terapeutas (Personal) -->
<!-- ===================================================== -->
<TabPanel header="Terapeutas (Pessoal)">
<div class="surface-100 border-round p-3 mb-4">
<div class="flex flex-wrap gap-2 items-center justify-content-between">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Divergências: ${totalPersonal}`" severity="secondary" />
<Tag :value="`Faltando: ${totalPersonalMissing}`" severity="danger" />
<Tag :value="`Inesperado: ${totalPersonalUnexpected}`" severity="warning" />
<Tag v-if="totalPersonalWithoutOwner > 0" :value="`Sem owner: ${totalPersonalWithoutOwner}`" severity="warn" />
</div>
<div class="text-color-secondary text-sm">
<span class="font-medium">Faltando</span>: o plano exige, mas não está ativo ·
<span class="font-medium">Inesperado</span>: está ativo sem constar no plano
</div>
</div>
</div>
<DataTable
:value="filteredPersonal"
dataKey="__rowKey"
:loading="loading"
stripedRows
responsiveLayout="scroll"
sortField="owner_id"
:sortOrder="1"
>
<Column header="Owner (User)" style="min-width: 22rem">
<template #body="{ data }">
<div class="flex items-center justify-between gap-2">
<span class="font-medium">
{{ data.owner_id || '—' }}
</span>
<Tag v-if="!data.owner_id" value="Sem owner" severity="warn" rounded />
</div>
</template>
</Column>
<Column header="Recurso" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<small class="text-color-secondary">
{{ helpForMismatch(data.mismatch_type) || '—' }}
</small>
</div>
</template>
</Column>
<Column header="Tipo" style="width: 12rem">
<template #body="{ data }">
<Tag :severity="severityForMismatch(data.mismatch_type)" :value="labelForMismatch(data.mismatch_type)" />
</template>
</Column>
<Column header="Ações" style="min-width: 20rem">
<template #body="{ data }">
<div class="flex gap-2 flex-wrap">
<Button
icon="pi pi-external-link"
severity="secondary"
outlined
v-tooltip.top="data.owner_id ? 'Abrir assinaturas deste owner (com filtro)' : 'Owner ausente'"
:disabled="loading || fixing || !data.owner_id"
@click="openOwnerSubscriptions(data.owner_id)"
/>
<Button
label="Corrigir owner"
icon="pi pi-wrench"
severity="danger"
outlined
v-tooltip.top="data.owner_id ? 'Reconstruir entitlements deste owner' : 'Owner ausente'"
:loading="fixing && fixingOwner === data.owner_id"
:disabled="loading || !data.owner_id || (fixing && fixingOwner !== data.owner_id)"
@click="askFixOwner(data.owner_id)"
/>
</div>
</template>
</Column>
</DataTable>
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-sm line-height-3">
<p class="mb-0">
<span class="font-semibold">Dica:</span>
Se você alterar o plano e o acesso não refletir imediatamente, esta aba exibirá as divergências entre o plano ativo e os entitlements atuais.
A ação <span class="font-medium">Corrigir</span> reconstrói os entitlements do owner com base no plano vigente e elimina inconsistências.
</p>
</div>
</Message>
</TabPanel>
<!-- ===================================================== -->
<!-- Clínicas (Tenant) -->
<!-- ===================================================== -->
<TabPanel header="Clínicas (Exceções)">
<div class="surface-100 border-round p-3 mb-4">
<div class="flex flex-wrap gap-2 items-center justify-content-between">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Exceções ativas: ${totalClinic}`" severity="info" />
</div>
<div class="text-color-secondary text-sm">
Exceções comerciais: features liberadas manualmente fora do plano. Útil para testes, suporte e acordos.
</div>
</div>
</div>
<DataTable
:value="filteredClinic"
dataKey="__rowKey"
:loading="loading"
stripedRows
responsiveLayout="scroll"
sortField="tenant_id"
:sortOrder="1"
>
<Column header="Clínica (Tenant)" style="min-width: 22rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.tenant_name || data.tenant_id }}</span>
<small class="text-color-secondary">{{ data.tenant_name ? data.tenant_id : '—' }}</small>
</div>
</template>
</Column>
<Column header="Plano" style="width: 12rem">
<template #body="{ data }">
<Tag :value="data.plan_key || '—'" severity="secondary" />
</template>
</Column>
<Column header="Feature" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<small class="text-color-secondary">
{{ helpForException() }}
</small>
</div>
</template>
</Column>
<Column header="Tipo" style="width: 14rem">
<template #body="{ data }">
<Tag :severity="severityForException()" :value="labelForException(data.exception_type)" />
</template>
</Column>
<Column header="Ações" style="min-width: 22rem">
<template #body="{ data }">
<div class="flex gap-2 flex-wrap">
<Button
icon="pi pi-external-link"
severity="secondary"
outlined
v-tooltip.top="'Abrir assinaturas desta clínica (com filtro)'"
:disabled="loading || fixing || !data.tenant_id"
@click="openClinicSubscriptions(data.tenant_id)"
/>
<Button
label="Remover exceção"
icon="pi pi-ban"
severity="danger"
outlined
v-tooltip.top="'Desativar a liberação manual desta feature'"
:disabled="loading || fixing || !data.tenant_id || !data.feature_key"
@click="askRemoveException(data.tenant_id, data.feature_key)"
/>
</div>
</template>
</Column>
</DataTable>
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-sm line-height-3">
<p class="mb-2">
<span class="font-semibold">Observação:</span>
Exceção é uma escolha de negócio. Quando ativa, pode liberar acesso mesmo que o plano não permita.
Utilize <span class="font-medium">Remover exceção</span> quando a liberação deixar de fazer sentido.
</p>
<p class="mb-0">
<span class="font-semibold">Dica:</span>
Exceções comerciais liberam recursos fora do plano.
Se o acesso não refletir como esperado, verifique se existe uma exceção ativa para esta clínica.
A ação <span class="font-medium">Remover exceção</span> restaura o comportamento estritamente definido pelo plano.
</p>
</div>
</Message>
</TabPanel>
</TabView>
</div>
</template>
<style scoped>
/* Hero */
.health-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
margin: 1rem;
}
.health-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.health-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.health-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.health-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(248,113,113,0.12); }
.health-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(251,113,133,0.09); }
.health-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.health-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.health-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.health-hero__info { flex: 1; min-width: 0; }
.health-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.health-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.health-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.health-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.health-hero__actions--desktop { display: none; }
.health-hero__actions--mobile { display: flex; }
}
</style>