Layout 100%, Notificações, SetupWizard

This commit is contained in:
Leonardo
2026-03-17 21:08:14 -03:00
parent 84d65e49c0
commit 66f67cd40f
77 changed files with 35823 additions and 15023 deletions
+272 -355
View File
@@ -407,424 +407,341 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-center gap-3 px-4 pb-3">
<div class="dash-hero__icon shrink-0">
<i class="pi pi-chart-bar text-2xl" />
</div>
<small class="text-color-secondary">
Visão estratégica (receita e distribuição) + saúde de consistência (entitlements).
</small>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="dash-hero"
:class="{ 'dash-hero--stuck': heroStuck }"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="dash-hero__blob dash-hero__blob--1" />
<div class="dash-hero__blob dash-hero__blob--2" />
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Central de Controle do SaaS</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Visão estratégica (receita e distribuição) + saúde de consistência (entitlements).</div>
</div>
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xl font-bold leading-none">Central de Controle do SaaS</span>
<!-- desktop actions -->
<div class="hidden xl:flex items-center gap-2">
<SelectButton
v-model="intervalView"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading"
/>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="loadStats"
/>
<Button
label="Assinaturas"
icon="pi pi-credit-card"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscriptions')"
/>
<Button
label="Eventos"
icon="pi pi-history"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-events')"
/>
</div>
<!-- mobile -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
outlined
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
<!-- desktop actions -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton
v-model="intervalView"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading"
/>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="loadStats"
/>
<Button
label="Assinaturas"
icon="pi pi-credit-card"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscriptions')"
/>
<Button
label="Eventos"
icon="pi pi-history"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-events')"
/>
</div>
<!-- mobile -->
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
outlined
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- KPIs -->
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Ativas</span>
<Tag value="active" severity="success" rounded />
</div>
</template>
<template #content>
<div class="text-4xl font-semibold">{{ totalActive }}</div>
<small class="text-color-secondary">assinaturas em status <b>active</b></small>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Ativas</div>
<Tag value="active" severity="success" rounded />
</div>
<div class="text-4xl font-semibold">{{ totalActive }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">assinaturas em status <b>active</b></div>
</div>
</div>
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Canceladas</span>
<Tag value="canceled" severity="danger" rounded />
</div>
</template>
<template #content>
<div class="text-4xl font-semibold">{{ totalCanceled }}</div>
<small class="text-color-secondary">assinaturas em status <b>canceled</b></small>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Canceladas</div>
<Tag value="canceled" severity="danger" rounded />
</div>
<div class="text-4xl font-semibold">{{ totalCanceled }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">assinaturas em status <b>canceled</b></div>
</div>
</div>
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>{{ intervalView === 'year' ? 'ARR' : 'MRR' }}</span>
<Tag :value="intervalView === 'year' ? 'anual' : 'mensal'" severity="secondary" rounded />
</div>
</template>
<template #content>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(revenueCents) }}</div>
<small class="text-color-secondary">normalizado (mensal anual)</small>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">{{ intervalView === 'year' ? 'ARR' : 'MRR' }}</div>
<Tag :value="intervalView === 'year' ? 'anual' : 'mensal'" severity="secondary" rounded />
</div>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(revenueCents) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">normalizado (mensal anual)</div>
</div>
</div>
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>ARPA</span>
<Tag value="média" severity="secondary" rounded />
</div>
</template>
<template #content>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(arpaCents) }}</div>
<small class="text-color-secondary">média por assinatura ativa</small>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">ARPA</div>
<Tag value="média" severity="secondary" rounded />
</div>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(arpaCents) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">média por assinatura ativa</div>
</div>
</div>
</div>
<!-- Intenções + Health + Chart -->
<div class="grid grid-cols-12 gap-4 mt-4">
<div class="grid grid-cols-12 gap-4">
<!-- Intenções -->
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Intenções de assinatura</span>
<Tag :value="intentsLoading ? 'carregando' : 'últimas'" severity="secondary" rounded />
</div>
</template>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Intenções de assinatura</div>
<Tag :value="intentsLoading ? 'carregando' : 'últimas'" severity="secondary" rounded />
</div>
<template #content>
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-xs text-color-secondary">Total</div>
<div class="text-2xl font-semibold">{{ totalIntents }}</div>
</div>
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-xs text-color-secondary">New</div>
<div class="text-2xl font-semibold">{{ totalIntentsNew }}</div>
</div>
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-xs text-color-secondary">Paid</div>
<div class="text-2xl font-semibold">{{ totalIntentsPaid }}</div>
</div>
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
<div class="text-[1rem] text-[var(--text-color-secondary)]">Total</div>
<div class="text-2xl font-semibold">{{ totalIntents }}</div>
</div>
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
<div class="text-[1rem] text-[var(--text-color-secondary)]">New</div>
<div class="text-2xl font-semibold">{{ totalIntentsNew }}</div>
</div>
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
<div class="text-[1rem] text-[var(--text-color-secondary)]">Paid</div>
<div class="text-2xl font-semibold">{{ totalIntentsPaid }}</div>
</div>
</div>
<Divider class="my-3" />
<div v-if="intentsLoading" class="text-[1rem] text-[var(--text-color-secondary)]">
Carregando intenções
</div>
<div v-else>
<div v-if="!intents.length" class="text-[1rem] text-[var(--text-color-secondary)]">
Nenhuma intenção encontrada.
</div>
<Divider class="my-3" />
<div v-if="intentsLoading" class="text-color-secondary text-sm">
Carregando intenções
</div>
<div v-else>
<div v-if="!intents.length" class="text-color-secondary text-sm">
Nenhuma intenção encontrada.
</div>
<div v-else class="space-y-2">
<div
v-for="(it, idx) in intents"
:key="idx"
class="flex items-start justify-between gap-3 rounded-xl border border-[var(--surface-border)] p-3"
>
<div class="min-w-0">
<div class="font-medium truncate">
{{ maskEmail(it.email) }}
</div>
<div class="text-xs text-color-secondary mt-1">
{{ it.plan_key || '—' }} {{ intervalLabel(it.interval) }}
<span class="font-mono">{{ it.tenant_id ? String(it.tenant_id).slice(0, 8) + '…' : '—' }}</span>
</div>
<div class="text-xs text-color-secondary mt-1">
{{ fmtDate(it.created_at) }}
</div>
<div v-else class="space-y-2">
<div
v-for="(it, idx) in intents"
:key="idx"
class="flex items-start justify-between gap-3 rounded-md border border-[var(--surface-border)] p-3"
>
<div class="min-w-0">
<div class="font-medium truncate">
{{ maskEmail(it.email) }}
</div>
<div class="shrink-0">
<Tag :value="it.status || '—'" :severity="statusSeverity(it.status)" rounded />
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
{{ it.plan_key || '—' }} {{ intervalLabel(it.interval) }}
<span class="font-mono">{{ it.tenant_id ? String(it.tenant_id).slice(0, 8) + '…' : '—' }}</span>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
{{ fmtDate(it.created_at) }}
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap mt-3">
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="intentsLoading || loading"
@click="loadIntents"
/>
<Button
label="Ver eventos"
icon="pi pi-history"
severity="secondary"
outlined
size="small"
:disabled="loading"
@click="openIntentEvents"
/>
</div>
<div class="text-color-secondary text-xs mt-3">
Mostrando {{ intentsLimit }} itens mais recentes de <span class="font-mono">subscription_intents</span>.
<div class="shrink-0">
<Tag :value="it.status || '—'" :severity="statusSeverity(it.status)" rounded />
</div>
</div>
</div>
</template>
</Card>
<div class="flex gap-2 flex-wrap mt-3">
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="intentsLoading || loading"
@click="loadIntents"
/>
<Button
label="Ver eventos"
icon="pi pi-history"
severity="secondary"
outlined
size="small"
:disabled="loading"
@click="openIntentEvents"
/>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3">
Mostrando {{ intentsLimit }} itens mais recentes de <span class="font-mono">subscription_intents</span>.
</div>
</div>
</div>
</div>
<!-- Health -->
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Saúde do sistema</span>
<Tag :severity="healthSeverity" :value="healthLabel" rounded />
</div>
</template>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Saúde do sistema</div>
<Tag :severity="healthSeverity" :value="healthLabel" rounded />
</div>
<template #content>
<div class="flex items-center justify-between gap-2">
<div class="text-4xl font-semibold">{{ totalMismatches }}</div>
<small class="text-color-secondary text-right">
divergências entre plano (esperado) e entitlements (atual)
</small>
<div class="flex items-center justify-between gap-2">
<div class="text-4xl font-semibold">{{ totalMismatches }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] text-right">
divergências entre plano (esperado) e entitlements (atual)
</div>
</div>
<div class="text-color-secondary text-sm mt-2">
{{ healthHint }}
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-2">
{{ healthHint }}
</div>
<Divider class="my-3" />
<Divider class="my-3" />
<div class="flex gap-2 flex-wrap">
<Button
v-if="totalMismatches > 0"
label="Corrigir tudo"
icon="pi pi-refresh"
severity="danger"
:loading="loading"
@click="askFixAll"
/>
<Button
label="Ver divergências"
icon="pi pi-search"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-health')"
/>
</div>
<div class="flex gap-2 flex-wrap">
<Button
v-if="totalMismatches > 0"
label="Corrigir tudo"
icon="pi pi-refresh"
severity="danger"
:loading="loading"
@click="askFixAll"
/>
<Button
label="Ver divergências"
icon="pi pi-search"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-health')"
/>
</div>
<div class="text-color-secondary text-xs mt-3" v-if="lastUpdatedAt">
Atualizado em {{ fmtDate(lastUpdatedAt) }}
</div>
</template>
</Card>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3" v-if="lastUpdatedAt">
Atualizado em {{ fmtDate(lastUpdatedAt) }}
</div>
</div>
</div>
<!-- Chart -->
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #title>{{ intervalView === 'year' ? 'ARR por plano' : 'MRR por plano' }}</template>
<template #content>
<div style="height: 260px;">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
</template>
</Card>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-3">{{ intervalView === 'year' ? 'ARR por plano' : 'MRR por plano' }}</div>
<div style="height: 260px;">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
</div>
</div>
</div>
<!-- Breakdown table (com ações) -->
<div class="mt-4">
<Card>
<template #title>Distribuição por plano</template>
<template #content>
<DataTable :value="breakdown" stripedRows responsiveLayout="scroll" emptyMessage="Sem dados para exibir.">
<Column field="plan_key" header="Plano" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.plan_key }}</span>
<small class="text-color-secondary">
{{ data.plan_active ? 'ativo no catálogo' : 'inativo no catálogo' }}
</small>
</div>
</template>
</Column>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-3">Distribuição por plano</div>
<DataTable :value="breakdown" stripedRows responsiveLayout="scroll" emptyMessage="Sem dados para exibir.">
<Column field="plan_key" header="Plano" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.plan_key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ data.plan_active ? 'ativo no catálogo' : 'inativo no catálogo' }}
</div>
</div>
</template>
</Column>
<Column header="Público" style="width: 12rem">
<template #body="{ data }">
<Tag
:value="planTargetLabel(data.plan_target)"
:severity="planTargetSeverity(data.plan_target)"
rounded
/>
</template>
</Column>
<Column header="Público" style="width: 12rem">
<template #body="{ data }">
<Tag
:value="planTargetLabel(data.plan_target)"
:severity="planTargetSeverity(data.plan_target)"
rounded
/>
</template>
</Column>
<Column header="Ativas" style="width: 8rem">
<template #body="{ data }">{{ data.active_count }}</template>
</Column>
<Column header="Ativas" style="width: 8rem">
<template #body="{ data }">{{ data.active_count }}</template>
</Column>
<Column header="Canceladas" style="width: 10rem">
<template #body="{ data }">{{ data.canceled_count }}</template>
</Column>
<Column header="Canceladas" style="width: 10rem">
<template #body="{ data }">{{ data.canceled_count }}</template>
</Column>
<Column header="Preço (ref.)" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
</Column>
<Column header="Preço (ref.)" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
</Column>
<Column :header="intervalView === 'year' ? 'ARR' : 'MRR'" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.revenue_cents) }}</template>
</Column>
<Column :header="intervalView === 'year' ? 'ARR' : 'MRR'" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.revenue_cents) }}</template>
</Column>
<Column header="Ações" style="width: 16rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end flex-wrap">
<Button
label="Abrir vitrine"
icon="pi pi-external-link"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
@click="openPlanPublic(data.plan_key)"
/>
<Button
icon="pi pi-pencil"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
v-tooltip.top="'Abrir catálogo interno do plano'"
@click="openPlanCatalog(data.plan_key)"
/>
</div>
</template>
</Column>
</DataTable>
<Column header="Ações" style="width: 16rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end flex-wrap">
<Button
label="Abrir vitrine"
icon="pi pi-external-link"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
@click="openPlanPublic(data.plan_key)"
/>
<Button
icon="pi pi-pencil"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
v-tooltip.top="'Abrir catálogo interno do plano'"
@click="openPlanCatalog(data.plan_key)"
/>
</div>
</template>
</Column>
</DataTable>
<div class="text-color-secondary text-sm mt-3">
Nota: "Preço (ref.)" e "MRR/ARR" são normalizados usando o preço ativo.
Se existir anual, MRR = anual/12; se existir mensal, ARR = mensal*12.
</div>
</template>
</Card>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3">
Nota: "Preço (ref.)" e "MRR/ARR" são normalizados usando o preço ativo.
Se existir anual, MRR = anual/12; se existir mensal, ARR = mensal*12.
</div>
</div>
</div>
</template>
<style scoped>
/* Hero */
.dash-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1rem;
margin: 1rem;
padding: 1.25rem 1.5rem;
background: linear-gradient(135deg, var(--surface-card) 0%, var(--surface-section) 100%);
border: 1px solid var(--surface-border);
box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
}
.dash-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.dash-hero__blob {
position: absolute;
border-radius: 50%;
opacity: .15;
pointer-events: none;
}
.dash-hero__blob--1 {
width: 220px; height: 220px;
top: -60px; right: 80px;
background: radial-gradient(circle, #2dd4bf, transparent 70%);
}
.dash-hero__blob--2 {
width: 160px; height: 160px;
bottom: -40px; right: 260px;
background: radial-gradient(circle, #60a5fa, transparent 70%);
}
.dash-hero__icon {
width: 2.75rem; height: 2.75rem;
display: flex; align-items: center; justify-content: center;
border-radius: .75rem;
background: var(--primary-100, rgba(99,102,241,.1));
color: var(--primary-color, #6366f1);
}
</style>
File diff suppressed because it is too large Load Diff
+86 -329
View File
@@ -125,84 +125,93 @@ function selecionarCat (cat) {
</script>
<template>
<div class="faq-page">
<!-- Sentinel -->
<div class="h-px" />
<!-- Cabeçalho -->
<div class="faq-header">
<div class="faq-header-inner">
<div class="flex items-center gap-3 mb-3">
<div class="faq-icon-wrap">
<i class="pi pi-comments text-xl" />
</div>
<div>
<h1 class="faq-title">Central de Ajuda</h1>
<p class="faq-subtitle">Encontre respostas para as dúvidas mais comuns</p>
</div>
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div class="relative z-10 flex flex-col gap-3">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-comments text-xl text-[var(--text-color)]" />
</div>
<div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Central de Ajuda</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Encontre respostas para as dúvidas mais comuns</div>
</div>
</div>
<!-- Busca -->
<div class="faq-search-wrap">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="busca"
placeholder="Buscar pergunta…"
class="faq-search-input"
/>
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="faq-search-result">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
<!-- Busca -->
<div>
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="busca"
placeholder="Buscar pergunta…"
class="w-full"
/>
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 mt-1 ml-1">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
</div>
</div>
</div>
<!-- Corpo -->
<div class="faq-body">
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
</div>
<template v-else>
<template v-else>
<div class="flex gap-4 items-start flex-col sm:flex-row">
<!-- Sidebar de categorias -->
<aside v-if="categorias.length" class="faq-sidebar">
<div class="faq-sidebar-title">Categorias</div>
<aside v-if="categorias.length" class="w-full sm:w-48 flex-shrink-0 sticky top-[calc(var(--layout-sticky-top,56px)+8rem)] flex flex-col sm:flex-col flex-row flex-wrap gap-1">
<div class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 px-2 mb-1 hidden sm:block">Categorias</div>
<button
class="faq-cat-btn"
:class="{ 'faq-cat-btn--active': !catAtiva }"
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': !catAtiva }"
@click="selecionarCat(null)"
>
<i class="pi pi-th-large text-xs mr-2" />
<i class="pi pi-th-large mr-2 opacity-60" />
Todas
<span class="faq-cat-count">{{ faqItens.length }}</span>
<span class="ml-auto opacity-50 text-[1rem]">{{ faqItens.length }}</span>
</button>
<button
v-for="cat in categorias"
:key="cat"
class="faq-cat-btn"
:class="{ 'faq-cat-btn--active': catAtiva === cat }"
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': catAtiva === cat }"
@click="selecionarCat(cat)"
>
<i class="pi pi-tag text-xs mr-2 opacity-60" />
<i class="pi pi-tag mr-2 opacity-60" />
{{ cat }}
<span class="faq-cat-count">
<span class="ml-auto opacity-50 text-[1rem]">
{{ faqItens.filter(f => docs.find(d => d.id === f.doc_id && d.categoria === cat)).length }}
</span>
</button>
</aside>
<!-- Conteúdo principal -->
<main class="faq-main">
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- Sem resultados -->
<div v-if="docsComResultado.length === 0" class="faq-empty">
<div v-if="docsComResultado.length === 0" class="flex flex-col items-center py-12 text-center">
<i class="pi pi-search text-3xl opacity-20 mb-3" />
<p class="text-[var(--text-color-secondary)]">Nenhuma pergunta encontrada.</p>
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-sm mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
<div class="text-[var(--text-color-secondary)] text-[1rem]">Nenhuma pergunta encontrada.</div>
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-[1rem] mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
Limpar filtros
</button>
</div>
@@ -211,45 +220,48 @@ function selecionarCat (cat) {
<div
v-for="doc in docsComResultado"
:key="doc.id"
class="faq-group"
class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
>
<!-- Cabeçalho do grupo (doc) -->
<div class="faq-group-header">
<div class="faq-group-icon">
<i class="pi pi-file-edit text-sm" />
<div class="group flex items-center gap-3 px-5 py-3.5 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="w-8 h-8 rounded-md bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-file-edit" />
</div>
<div class="flex-1 min-w-0">
<h2 class="faq-group-title">{{ doc.titulo }}</h2>
<span v-if="doc.categoria" class="faq-group-cat">{{ doc.categoria }}</span>
<div class="text-[1rem] font-semibold text-[var(--text-color)] leading-tight">{{ doc.titulo }}</div>
<div v-if="doc.categoria" class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-px">{{ doc.categoria }}</div>
</div>
<button
class="edit-doc-btn"
class="flex items-center justify-center w-7 h-7 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--surface-hover)] hover:text-[var(--primary-color)]"
v-tooltip.top="'Editar documento'"
@click="editarDoc(doc.id)"
>
<i class="pi pi-pencil text-xs" />
<i class="pi pi-pencil" />
</button>
</div>
<!-- Itens FAQ do grupo -->
<div class="faq-items">
<div class="flex flex-col">
<div
v-for="item in itensDo(doc.id)"
:key="item.id"
class="faq-item"
:class="{ 'faq-item--open': abertos[item.id] }"
class="border-b border-[var(--surface-border)] last:border-b-0 transition-colors"
:class="abertos[item.id] ? 'bg-[color-mix(in_srgb,var(--primary-color)_3%,transparent)]' : ''"
>
<button class="faq-pergunta" @click="toggle(item.id)">
<span class="faq-pergunta-text">{{ item.pergunta }}</span>
<button
class="w-full flex items-center justify-between gap-4 px-5 py-3.5 bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)]"
@click="toggle(item.id)"
>
<span class="text-[1rem] font-medium text-[var(--text-color)] leading-snug">{{ item.pergunta }}</span>
<i
class="pi shrink-0 text-sm opacity-40 transition-transform duration-200"
class="pi shrink-0 opacity-40 transition-transform duration-200"
:class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</button>
<Transition name="faq-expand">
<div
v-if="abertos[item.id] && item.resposta"
class="faq-resposta ql-content"
class="px-5 pb-4 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed break-words ql-content"
v-html="item.resposta"
/>
</Transition>
@@ -257,270 +269,23 @@ function selecionarCat (cat) {
</div>
</div>
</main>
</template>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* ── Layout ──────────────────────────────────────────────────── */
.faq-page {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* ── Header ─────────────────────────────────────────────────── */
.faq-header {
background: var(--surface-card);
border-bottom: 1px solid var(--surface-border);
padding: 2rem 1.5rem 1.5rem;
}
.faq-header-inner {
max-width: 720px;
margin: 0 auto;
}
.faq-icon-wrap {
width: 48px;
height: 48px;
border-radius: 14px;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-title {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-color);
margin: 0;
line-height: 1.2;
}
.faq-subtitle {
font-size: 0.875rem;
color: var(--text-color-secondary);
margin: 2px 0 0;
}
.faq-search-wrap {
position: relative;
}
.faq-search-input {
width: 100%;
border-radius: 0.75rem !important;
font-size: 0.9rem;
}
.faq-search-result {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.7;
margin-top: 0.375rem;
margin-left: 0.25rem;
}
/* ── Corpo ──────────────────────────────────────────────────── */
.faq-body {
display: flex;
gap: 1.5rem;
padding: 1.5rem;
flex: 1;
max-width: 1100px;
margin: 0 auto;
width: 100%;
align-items: flex-start;
}
/* ── Sidebar ─────────────────────────────────────────────────── */
.faq-sidebar {
width: 200px;
flex-shrink: 0;
position: sticky;
top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.faq-sidebar-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-color-secondary);
opacity: 0.6;
padding: 0 0.5rem;
margin-bottom: 0.25rem;
}
.faq-cat-btn {
display: flex;
align-items: center;
width: 100%;
padding: 0.45rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.82rem;
color: var(--text-color-secondary);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
}
.faq-cat-btn:hover {
background: var(--surface-hover);
color: var(--text-color);
}
.faq-cat-btn--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
.faq-cat-count {
margin-left: auto;
font-size: 0.7rem;
opacity: 0.5;
font-weight: 500;
}
/* ── Main ─────────────────────────────────────────────────────── */
.faq-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.faq-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem;
text-align: center;
}
/* ── Grupo (doc) ─────────────────────────────────────────────── */
.faq-group {
border: 1px solid var(--surface-border);
border-radius: 1rem;
overflow: hidden;
background: var(--surface-card);
}
.faq-group-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.faq-group-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-group-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
line-height: 1.3;
}
.faq-group-cat {
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.6;
display: block;
margin-top: 1px;
}
.edit-doc-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color-secondary);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.faq-group-header:hover .edit-doc-btn {
opacity: 1;
}
.edit-doc-btn:hover {
background: var(--surface-hover);
color: var(--primary-color);
border-color: color-mix(in srgb, var(--primary-color) 30%, transparent);
}
/* ── Itens FAQ ───────────────────────────────────────────────── */
.faq-items {
display: flex;
flex-direction: column;
}
.faq-item {
border-bottom: 1px solid var(--surface-border);
transition: background 0.15s;
}
.faq-item:last-child { border-bottom: none; }
.faq-item--open { background: color-mix(in srgb, var(--primary-color) 3%, transparent); }
.faq-pergunta {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1.25rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.faq-pergunta:hover { background: var(--surface-hover); }
.faq-pergunta-text {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-color);
line-height: 1.4;
}
.faq-resposta {
padding: 0 1.25rem 1rem;
font-size: 0.875rem;
color: var(--text-color-secondary);
line-height: 1.65;
word-break: break-word;
}
/* Quill content */
.faq-resposta.ql-content :deep(p) { margin: 0 0 0.5rem; }
.faq-resposta.ql-content :deep(p:last-child) { margin-bottom: 0; }
.faq-resposta.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
.faq-resposta.ql-content :deep(em) { font-style: italic; }
.faq-resposta.ql-content :deep(ul),
.faq-resposta.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
.faq-resposta.ql-content :deep(li) { margin-bottom: 0.2rem; }
.faq-resposta.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
.faq-resposta.ql-content :deep(blockquote) {
.ql-content :deep(p) { margin: 0 0 0.5rem; }
.ql-content :deep(p:last-child) { margin-bottom: 0; }
.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
.ql-content :deep(em) { font-style: italic; }
.ql-content :deep(ul),
.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
.ql-content :deep(li) { margin-bottom: 0.2rem; }
.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
.ql-content :deep(blockquote) {
border-left: 3px solid var(--surface-border);
margin: 0.5rem 0;
padding: 0.25rem 0.75rem;
@@ -539,12 +304,4 @@ function selecionarCat (cat) {
opacity: 0;
max-height: 0;
}
/* ── Responsivo ─────────────────────────────────────────────── */
@media (max-width: 640px) {
.faq-body { flex-direction: column; padding: 1rem; }
.faq-sidebar { width: 100%; position: static; flex-direction: row; flex-wrap: wrap; gap: 0.375rem; }
.faq-sidebar-title { display: none; }
.faq-cat-btn { width: auto; padding: 0.3rem 0.625rem; font-size: 0.75rem; }
}
</style>
+116 -184
View File
@@ -233,56 +233,53 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="features-root">
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="features-hero__icon-wrap">
<i class="pi pi-bolt features-hero__icon" />
</div>
<div class="features-hero__sub">
Cadastre os recursos (features) que os planos podem habilitar.
A <b>key</b> é o identificador técnico; o <b>nome</b> é exibido para o usuário.
</div>
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-fuchsia-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="features-hero-sentinel" />
<div ref="heroEl" class="features-hero mb-4" :class="{ 'features-hero--stuck': heroStuck }">
<div class="features-hero__blobs" aria-hidden="true">
<div class="features-hero__blob features-hero__blob--1" />
<div class="features-hero__blob features-hero__blob--2" />
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Recursos do Sistema</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Cadastre os recursos (features) que os planos podem habilitar.</div>
</div>
<div class="features-hero__inner">
<div class="features-hero__info min-w-0">
<div class="features-hero__title">Recursos do Sistema</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="features-hero__actions features-hero__actions--desktop">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="features-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="features_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="features_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="features_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="features_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-[380px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -292,7 +289,6 @@ onBeforeUnmount(() => {
</FloatLabel>
</div>
<div class="px-4 pb-4">
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Domínio" style="width: 9rem">
<template #body="{ data }">
@@ -303,8 +299,8 @@ onBeforeUnmount(() => {
<Column field="key" header="Key" sortable style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium font-mono text-sm">{{ data.key }}</span>
<small class="text-color-secondary">ID: {{ data.id }}</small>
<span class="font-medium font-mono text-[1rem]">{{ data.key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">ID: {{ data.id }}</div>
</div>
</template>
</Column>
@@ -317,7 +313,7 @@ onBeforeUnmount(() => {
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
<template #body="{ data }">
<div class="max-w-[600px] whitespace-nowrap overflow-hidden text-ellipsis text-color-secondary" :title="data.descricao || ''">
<div class="max-w-[600px] whitespace-nowrap overflow-hidden text-ellipsis text-[var(--text-color-secondary)]" :title="data.descricao || ''">
{{ data.descricao || '—' }}
</div>
</template>
@@ -334,151 +330,87 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div>
</div>
<Dialog
v-model:visible="showDlg"
modal
:header="isEdit ? 'Editar recurso' : 'Novo recurso'"
:style="{ width: '640px' }"
:closable="!saving"
:dismissableMask="!saving"
:draggable="false"
>
<div class="flex flex-col gap-4">
<!-- Key -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="cr-key"
v-model.trim="form.key"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@blur="form.key = slugifyKey(form.key)"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="cr-key">Key *</label>
</FloatLabel>
<small class="text-color-secondary block mt-1">
Ex.: <span class="font-mono">agenda.view</span> ou <span class="font-mono">online_scheduling.manage</span>.
Espaços e acentos são normalizados automaticamente.
</small>
</div>
<!-- Nome -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-bookmark" />
<InputText
id="cr-name"
v-model.trim="form.name"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="cr-name">Nome *</label>
</FloatLabel>
<small class="text-color-secondary block mt-1">
Nome exibido para o usuário na página de upgrade e nas listagens.
</small>
</div>
<!-- Descrição PT-BR -->
<div>
<FloatLabel variant="on">
<Textarea
id="cr-desc-pt"
v-model.trim="form.descricao"
<Dialog
v-model:visible="showDlg"
modal
:header="isEdit ? 'Editar recurso' : 'Novo recurso'"
:style="{ width: '640px' }"
:closable="!saving"
:dismissableMask="!saving"
:draggable="false"
>
<div class="flex flex-col gap-4">
<!-- Key -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="cr-key"
v-model.trim="form.key"
class="w-full"
rows="3"
autoResize
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@blur="form.key = slugifyKey(form.key)"
@keydown.enter.prevent="save"
/>
<label for="cr-desc-pt">Descrição</label>
</FloatLabel>
<small class="text-color-secondary block mt-1">
Explique o que o recurso habilita e para quem se aplica.
</small>
</IconField>
<label for="cr-key">Key *</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Ex.: <span class="font-mono">agenda.view</span> ou <span class="font-mono">online_scheduling.manage</span>.
Espaços e acentos são normalizados automaticamente.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
</template>
<!-- Nome -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-bookmark" />
<InputText
id="cr-name"
v-model.trim="form.name"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="cr-name">Nome *</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Nome exibido para o usuário na página de upgrade e nas listagens.
</div>
</div>
<style scoped>
.features-root { padding: 1rem; }
@media (min-width: 768px) { .features-root { padding: 1.5rem; } }
<!-- Descrição PT-BR -->
<div>
<FloatLabel variant="on">
<Textarea
id="cr-desc-pt"
v-model.trim="form.descricao"
class="w-full"
rows="3"
autoResize
:disabled="saving"
/>
<label for="cr-desc-pt">Descrição</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Explique o que o recurso habilita e para quem se aplica.
</div>
</div>
</div>
.features-hero-sentinel { height: 1px; }
.features-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;
}
.features-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.features-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.features-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.features-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(217,70,239,0.10); }
.features-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.10); }
.features-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.features-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;
}
.features-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.features-hero__info { flex: 1; min-width: 0; }
.features-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;
}
.features-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.features-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.features-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.features-hero__actions--desktop { display: none; }
.features-hero__actions--mobile { display: flex; }
}
</style>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>
+57 -100
View File
@@ -177,46 +177,59 @@ async function excluir (id) {
<template>
<Toast />
<div class="flex flex-col gap-4 p-4">
<!-- Sentinel -->
<div class="h-px" />
<!-- Header -->
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-5 py-4">
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div class="relative z-10 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="font-bold text-lg flex items-center gap-2">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-star text-amber-500" />
Feriados Municipais
</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Feriados cadastrados pelos tenants alimentam o banco central de feriados do SAAS.
</div>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
<span class="font-bold text-[1rem] w-14 text-center">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
<Button icon="pi pi-plus" label="Cadastrar feriado" class="rounded-full" @click="abrirDialog" />
<Button icon="pi pi-plus" label="Cadastrar feriado" @click="abrirDialog" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Stats -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-amber-500">{{ totalFeriados }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Total de feriados</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Total de feriados</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalTenants }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Tenants contribuintes</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Tenants contribuintes</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-green-500">{{ totalMunicipios }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Municípios</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Municípios</div>
</div>
</div>
<!-- Filtros -->
<div class="flex flex-wrap gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3">
<div class="flex flex-wrap gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3">
<div class="flex-1 min-w-[160px]">
<IconField>
<InputIcon class="pi pi-search" />
@@ -247,34 +260,42 @@ async function excluir (id) {
<template v-else>
<div v-if="!agrupados.length" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-8 text-center text-[var(--text-color-secondary)]">
<div v-if="!agrupados.length" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-8 text-center text-[var(--text-color-secondary)]">
Nenhum feriado municipal cadastrado para {{ ano }}.
</div>
<!-- Lista agrupada por data -->
<div v-for="[data, lista] in agrupados" :key="data" class="blk-group">
<div class="blk-group__head">
<span class="font-mono text-sm">{{ fmtDate(data) }}</span>
<span class="blk-group__count">{{ lista.length }}</span>
<div
v-for="[data, lista] in agrupados"
:key="data"
class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
>
<div class="flex items-center gap-2 px-5 py-3 border-b border-[var(--surface-border)] font-semibold bg-[var(--surface-ground)]">
<span class="font-mono text-[1rem]">{{ fmtDate(data) }}</span>
<span class="text-[1rem] bg-[var(--surface-card)] border border-[var(--surface-border)] rounded-full px-2 py-px text-[var(--text-color-secondary)]">{{ lista.length }}</span>
</div>
<div class="blk-list">
<div v-for="f in lista" :key="f.id" class="blk-item">
<div class="blk-item__name">{{ f.nome }}</div>
<div class="flex flex-col">
<div
v-for="f in lista"
:key="f.id"
class="flex items-center gap-3 px-5 py-2.5 border-b border-[var(--surface-border)] last:border-b-0 flex-wrap hover:bg-[var(--surface-hover)]"
>
<div class="font-medium text-[1rem] flex-1 min-w-[180px]">{{ f.nome }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag v-if="f.cidade" :value="f.cidade" severity="secondary" class="text-xs" />
<Tag v-if="f.estado" :value="f.estado" severity="info" class="text-xs" />
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" class="text-xs" />
<Tag v-if="f.cidade" :value="f.cidade" severity="secondary" />
<Tag v-if="f.estado" :value="f.estado" severity="info" />
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" />
</div>
<div v-if="f.tenants?.name" class="blk-item__tenant">
<i class="pi pi-building text-xs" /> {{ f.tenants.name }}
<div v-if="f.tenants?.name" class="text-[1rem] text-[var(--text-color-secondary)] w-full flex items-center gap-1">
<i class="pi pi-building" /> {{ f.tenants.name }}
</div>
<div v-if="f.observacao" class="blk-item__obs">{{ f.observacao }}</div>
<div v-if="f.observacao" class="text-[1rem] text-[var(--text-color-secondary)] w-full italic">{{ f.observacao }}</div>
<div class="blk-item__actions">
<div class="ml-auto">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluir(f.id)" />
</div>
</div>
@@ -295,12 +316,12 @@ async function excluir (id) {
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="dlg-label">Nome do feriado *</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Nome do feriado *</label>
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Padroeiro Municipal, Aniversário da cidade…" />
</div>
<div>
<label class="dlg-label">Data *</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Data *</label>
<DatePicker
v-model="form.data"
showIcon fluid iconDisplay="input"
@@ -314,17 +335,17 @@ async function excluir (id) {
<div class="flex gap-3">
<div class="flex-1">
<label class="dlg-label">Cidade</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Cidade</label>
<InputText v-model="form.cidade" class="w-full mt-1" placeholder="Ex.: São Paulo" />
</div>
<div class="w-24">
<label class="dlg-label">Estado (UF)</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Estado (UF)</label>
<InputText v-model="form.estado" class="w-full mt-1" placeholder="SP" maxlength="2" />
</div>
</div>
<div>
<label class="dlg-label">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
<Select
v-model="form.tenant_id"
:options="tenantOptions"
@@ -336,13 +357,13 @@ async function excluir (id) {
</div>
<div>
<label class="dlg-label">Observação <span class="opacity-60">(opcional)</span></label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.bloqueia_sessoes" :binary="true" inputId="bloqueia" />
<label for="bloqueia" class="text-sm cursor-pointer">Bloqueia sessões neste dia</label>
<label for="bloqueia" class="text-[1rem] cursor-pointer">Bloqueia sessões neste dia</label>
</div>
</div>
@@ -359,67 +380,3 @@ async function excluir (id) {
</template>
</Dialog>
</template>
<style scoped>
.blk-group {
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
.blk-group__head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
font-weight: 600;
background: var(--surface-ground);
}
.blk-group__count {
font-size: 0.75rem;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 999px;
padding: 1px 8px;
color: var(--text-color-secondary);
}
.blk-list { display: flex; flex-direction: column; }
.blk-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
flex-wrap: wrap;
}
.blk-item:last-child { border-bottom: none; }
.blk-item:hover { background: var(--surface-hover); }
.blk-item__name {
font-weight: 500;
font-size: 0.875rem;
flex: 1;
min-width: 180px;
}
.blk-item__tenant {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
display: flex;
align-items: center;
gap: 0.25rem;
}
.blk-item__obs {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
font-style: italic;
}
.blk-item__actions { margin-left: auto; }
.dlg-label {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 500;
}
</style>
+564
View File
@@ -0,0 +1,564 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Editor from 'primevue/editor'
const toast = useToast()
const confirm = useConfirm()
// ─── Estado ───────────────────────────────────────────────────────────────────
const slides = ref([])
const loading = ref(false)
const saving = ref(false)
const previewIdx = ref(0)
const dialogOpen = ref(false)
const editingSlide = ref(null) // null = novo
const form = ref({ title: '', body: '', icon: '', ordem: 0, ativo: true })
// ─── Ícones disponíveis (subset PrimeIcons relevantes) ────────────────────────
const ICONS = [
{ value: 'pi-calendar-clock', label: 'Agenda' },
{ value: 'pi-users', label: 'Equipe' },
{ value: 'pi-globe', label: 'Online' },
{ value: 'pi-shield', label: 'Segurança' },
{ value: 'pi-heart-fill', label: 'Saúde' },
{ value: 'pi-chart-line', label: 'Estatísticas' },
{ value: 'pi-bell', label: 'Notificações' },
{ value: 'pi-lock', label: 'Privacidade' },
{ value: 'pi-mobile', label: 'Mobile' },
{ value: 'pi-sync', label: 'Sincronização' },
{ value: 'pi-star', label: 'Destaque' },
{ value: 'pi-check-circle', label: 'Aprovação' },
{ value: 'pi-comments', label: 'Comunicação' },
{ value: 'pi-file-edit', label: 'Prontuário' },
{ value: 'pi-briefcase', label: 'Profissional' },
{ value: 'pi-bolt', label: 'Performance' },
]
// ─── Computed ─────────────────────────────────────────────────────────────────
const slidesAtivos = computed(() => slides.value.filter(s => s.ativo).sort((a, b) => a.ordem - b.ordem))
const previewSlide = computed(() => slidesAtivos.value[previewIdx.value] ?? slidesAtivos.value[0] ?? null)
// ─── Supabase ─────────────────────────────────────────────────────────────────
async function load () {
loading.value = true
try {
const { data, error } = await supabase
.from('login_carousel_slides')
.select('*')
.order('ordem', { ascending: true })
if (error) throw error
slides.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar slides.', life: 4000 })
} finally {
loading.value = false
}
}
function stripHtml (s) {
return String(s || '').replace(/<[^>]+>/g, '').trim()
}
async function saveSlide () {
if (!stripHtml(form.value.title) || !stripHtml(form.value.body)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Título e conteúdo são obrigatórios.', life: 3000 })
return
}
saving.value = true
try {
const payload = {
title: form.value.title,
body: form.value.body,
icon: form.value.icon || 'pi-star',
ordem: form.value.ordem,
ativo: form.value.ativo,
}
if (editingSlide.value) {
const { error } = await supabase
.from('login_carousel_slides')
.update(payload)
.eq('id', editingSlide.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slide atualizado com sucesso.', life: 3000 })
} else {
const maxOrdem = slides.value.length ? Math.max(...slides.value.map(s => s.ordem)) + 1 : 0
payload.ordem = maxOrdem
const { error } = await supabase
.from('login_carousel_slides')
.insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Criado', detail: 'Slide adicionado com sucesso.', life: 3000 })
}
dialogOpen.value = false
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 })
} finally {
saving.value = false
}
}
async function toggleAtivo (slide) {
try {
const { error } = await supabase
.from('login_carousel_slides')
.update({ ativo: !slide.ativo })
.eq('id', slide.id)
if (error) throw error
slide.ativo = !slide.ativo
toast.add({ severity: 'info', summary: slide.ativo ? 'Ativado' : 'Desativado', detail: `"${slide.title}"`, life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
}
async function deleteSlide (slide) {
confirm.require({
message: `Remover o slide "${slide.title}"? Esta ação não pode ser desfeita.`,
header: 'Confirmar remoção',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const { error } = await supabase.from('login_carousel_slides').delete().eq('id', slide.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Removido', detail: `Slide removido.`, life: 2500 })
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
},
})
}
async function moveSlide (slide, dir) {
const sorted = [...slides.value].sort((a, b) => a.ordem - b.ordem)
const idx = sorted.findIndex(s => s.id === slide.id)
const swapIdx = idx + dir
if (swapIdx < 0 || swapIdx >= sorted.length) return
const a = sorted[idx]
const b = sorted[swapIdx]
const tempOrdem = a.ordem
try {
await supabase.from('login_carousel_slides').update({ ordem: b.ordem }).eq('id', a.id)
await supabase.from('login_carousel_slides').update({ ordem: tempOrdem }).eq('id', b.id)
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
}
// ─── Dialog helpers ───────────────────────────────────────────────────────────
function openNew () {
editingSlide.value = null
form.value = { title: '', body: '', icon: 'pi-calendar-clock', ordem: 0, ativo: true }
dialogOpen.value = true
}
function openEdit (slide) {
editingSlide.value = slide
form.value = { title: slide.title, body: slide.body, icon: slide.icon || 'pi-star', ordem: slide.ordem, ativo: slide.ativo }
dialogOpen.value = true
}
onMounted(load)
</script>
<template>
<Toast />
<ConfirmDialog />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-images text-indigo-500" />
Carrossel do Login
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Gerencie os slides exibidos na tela de login do sistema
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
title="Recarregar"
:loading="loading"
@click="load"
/>
<Button
icon="pi pi-plus"
label="Novo slide"
@click="openNew"
/>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-6 items-start">
<!-- Tabela de slides -->
<div class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border)] overflow-hidden">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col divide-y divide-[var(--surface-border)]">
<div v-for="i in 4" :key="i" class="flex items-center gap-4 px-5 py-4 animate-pulse">
<div class="w-10 h-10 rounded-md bg-[var(--surface-ground)] flex-shrink-0" />
<div class="flex-1 space-y-2">
<div class="h-3.5 w-40 rounded bg-[var(--surface-ground)]" />
<div class="h-3 w-64 rounded bg-[var(--surface-ground)]" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-else-if="!slides.length" class="flex flex-col items-center justify-center py-16 gap-3 text-[var(--text-color-secondary)]">
<i class="pi pi-images text-4xl opacity-30" />
<span class="text-[1rem]">Nenhum slide cadastrado ainda.</span>
<Button label="Criar primeiro slide" size="small" @click="openNew" />
</div>
<!-- Rows -->
<div v-else class="divide-y divide-[var(--surface-border)]">
<!-- Header -->
<div class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-2.5 bg-[var(--surface-ground)] text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
<span class="w-10" />
<span>Slide</span>
<span class="text-center w-[60px]">Status</span>
<span class="w-[96px]" />
</div>
<div
v-for="(slide, i) in [...slides].sort((a,b) => a.ordem - b.ordem)"
:key="slide.id"
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-3.5 transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)] group"
>
<!-- Ícone + ordem -->
<div class="relative flex-shrink-0">
<div
class="w-10 h-10 rounded-md flex items-center justify-center text-lg"
:class="slide.ativo ? 'bg-indigo-500/10 text-indigo-500' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'"
>
<i :class="['pi', slide.icon || 'pi-star']" />
</div>
<span class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--surface-border)] text-[0.58rem] font-bold flex items-center justify-center text-[var(--text-color-secondary)]">
{{ slide.ordem + 1 }}
</span>
</div>
<!-- Conteúdo -->
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] truncate [&_*]:inline" :class="!slide.ativo && 'opacity-40 line-through'" v-html="slide.title" />
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate mt-0.5 [&_*]:inline" :class="!slide.ativo && 'opacity-40'" v-html="slide.body" />
</div>
<!-- Toggle ativo -->
<div class="flex justify-center w-[60px]">
<InputSwitch
:modelValue="slide.ativo"
@update:modelValue="() => toggleAtivo(slide)"
/>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 w-[96px] justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === 0"
title="Mover para cima"
@click="moveSlide(slide, -1)"
>
<i class="pi pi-chevron-up text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === slides.length - 1"
title="Mover para baixo"
@click="moveSlide(slide, 1)"
>
<i class="pi pi-chevron-down text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-indigo-50 hover:text-indigo-600 transition-colors duration-100"
title="Editar"
@click="openEdit(slide)"
>
<i class="pi pi-pencil text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-red-50 hover:text-red-500 transition-colors duration-100"
title="Remover"
@click="deleteSlide(slide)"
>
<i class="pi pi-trash text-xs" />
</button>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div class="sticky top-6 flex flex-col gap-3">
<div class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] flex items-center gap-1.5 px-1">
<i class="pi pi-eye" /> Pré-visualização
</div>
<!-- Mock da tela de login lado esquerdo -->
<div class="relative overflow-hidden rounded-md aspect-[9/16] max-h-[480px] w-full select-none shadow-xl">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div
class="absolute inset-0 opacity-[0.08]"
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 32px 32px;"
/>
<!-- Orbs -->
<div class="absolute -top-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-48 w-48 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="relative z-10 flex flex-col h-full p-6">
<!-- Brand mock -->
<div class="flex items-center gap-2">
<div class="grid h-7 w-7 place-items-center rounded-lg bg-white/20 border border-white/20">
<i class="pi pi-heart-fill text-white text-[0.6rem]" />
</div>
<span class="text-white/90 font-bold text-xs tracking-tight">Agência PSI</span>
</div>
<!-- Slide content -->
<div class="flex-1 flex flex-col justify-center gap-4">
<Transition name="prev-fade" mode="out-in">
<div v-if="previewSlide" :key="previewSlide.id ?? previewIdx" class="space-y-4">
<div class="grid h-11 w-11 place-items-center rounded-md bg-white/15 border border-white/20 shadow-lg">
<i :class="['pi', previewSlide.icon || 'pi-star', 'text-white text-lg']" />
</div>
<div class="space-y-2">
<div class="text-xl font-bold text-white leading-tight prose prose-invert prose-sm max-w-none" v-html="previewSlide.title" />
<div class="text-[1rem] text-white/70 leading-relaxed prose prose-invert prose-sm max-w-none" v-html="previewSlide.body" />
</div>
</div>
<div v-else class="flex flex-col items-center justify-center gap-2 text-white/30 text-xs">
<i class="pi pi-ban text-2xl" />
Nenhum slide ativo
</div>
</Transition>
</div>
<!-- Dots -->
<div class="flex items-center gap-1.5">
<button
v-for="(s, i) in slidesAtivos"
:key="s.id"
class="transition-all duration-300 rounded-full"
:class="i === previewIdx ? 'w-5 h-1.5 bg-white shadow' : 'w-1.5 h-1.5 bg-white/35 hover:bg-white/60'"
@click="previewIdx = i"
/>
<span v-if="slidesAtivos.length" class="ml-2 text-[0.6rem] text-white/40 tabular-nums">
{{ previewIdx + 1 }}/{{ slidesAtivos.length }}
</span>
</div>
</div>
</div>
<!-- Info -->
<div class="rounded-lg border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-xs text-[var(--text-color-secondary)] flex items-start gap-2">
<i class="pi pi-info-circle text-indigo-500 mt-px flex-shrink-0" />
<span>Clique nos pontos para navegar entre os slides ativos. A ordem e visibilidade refletem o que o usuário verá no login.</span>
</div>
</div>
</div>
<!-- SQL Helper -->
<div class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-5 py-4">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-database text-amber-500 text-[1rem]" />
<span class="text-xs font-bold text-[var(--text-color)] uppercase tracking-widest">SQL de referência</span>
<span class="ml-auto text-xs text-[var(--text-color-secondary)]">Execute no Supabase caso a tabela não exista</span>
</div>
<pre class="text-[0.7rem] bg-[var(--surface-ground)] rounded-lg p-3.5 overflow-x-auto text-[var(--text-color-secondary)] leading-relaxed whitespace-pre-wrap"><code>create table if not exists public.login_carousel_slides (
id uuid primary key default gen_random_uuid(),
title text not null,
body text not null,
icon text not null default 'pi-star',
ordem integer not null default 0,
ativo boolean not null default true,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- RLS: apenas saas_admin pode gerenciar
alter table public.login_carousel_slides enable row level security;
create policy "saas_admin_full" on public.login_carousel_slides
for all using (
exists (
select 1 from public.profiles
where id = auth.uid() and role = 'saas_admin'
)
);
-- Leitura pública (login não tem usuário autenticado)
create policy "public_read" on public.login_carousel_slides
for select using (ativo = true);</code></pre>
</div>
</div>
<!-- /px-3 content wrapper -->
<!-- Dialog: Criar / Editar slide -->
<Dialog
v-model:visible="dialogOpen"
modal
:header="editingSlide ? 'Editar slide' : 'Novo slide'"
:draggable="false"
:style="{ width: '46rem', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-4 pt-1">
<!-- Título -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Título <span class="text-red-500">*</span></label>
<Editor
v-model="form.title"
:pt="{ toolbar: { style: 'display:none' } }"
style="height: 72px"
editorStyle="font-size: 1rem; font-weight: 600;"
placeholder="Ex: Gestão clínica simplificada"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
</template>
</Editor>
</div>
<!-- Conteúdo -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Conteúdo <span class="text-red-500">*</span></label>
<Editor
v-model="form.body"
style="height: 160px"
editorStyle="font-size: 1rem;"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
<button class="ql-clean" />
</span>
</template>
</Editor>
</div>
<!-- Ícone -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Ícone</label>
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
<button
v-for="ic in ICONS"
:key="ic.value"
type="button"
class="flex flex-col items-center justify-center gap-1 py-2 rounded-lg border text-xs transition-all duration-100"
:class="form.icon === ic.value
? 'border-indigo-500 bg-indigo-50 text-indigo-600 shadow-sm'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-indigo-500'"
:title="ic.label"
@click="form.icon = ic.value"
>
<i :class="['pi', ic.value, 'text-base']" />
<span class="text-[0.6rem] leading-none">{{ ic.label }}</span>
</button>
</div>
</div>
<!-- Ativo -->
<div class="flex items-center gap-3">
<InputSwitch v-model="form.ativo" inputId="slide-ativo" />
<label for="slide-ativo" class="text-[1rem] text-[var(--text-color)] cursor-pointer select-none">
Slide ativo (visível no carrossel)
</label>
</div>
<!-- Mini preview -->
<div
class="relative overflow-hidden rounded-md p-5 flex items-center gap-4"
style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)"
>
<div class="grid h-12 w-12 flex-shrink-0 place-items-center rounded-md bg-white/15 border border-white/20 shadow">
<i :class="['pi', form.icon || 'pi-star', 'text-white text-xl']" />
</div>
<div class="min-w-0 overflow-hidden">
<div class="text-[1rem] font-bold text-white line-clamp-2 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.title) ? form.title : 'Título do slide'" />
<div class="text-[1rem] text-white/70 mt-0.5 line-clamp-3 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.body) ? form.body : 'Conteúdo descritivo...'" />
</div>
</div>
<!-- Ações -->
<div class="flex justify-end gap-2 pt-1">
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="dialogOpen = false" />
<Button
:label="editingSlide ? 'Salvar alterações' : 'Criar slide'"
icon="pi pi-check"
:loading="saving"
@click="saveSlide"
/>
</div>
</div>
</Dialog>
</template>
<style scoped>
.prev-fade-enter-active,
.prev-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.prev-fade-enter-from {
opacity: 0;
transform: translateY(12px);
}
.prev-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
+21 -4
View File
@@ -1,8 +1,25 @@
<template>
<div class="p-4">
<div class="text-xl font-semibold">Em construção</div>
<div class="text-color-secondary mt-2">
Esta área do Admin SaaS ainda será implementada.
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Em construção</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Esta área do Admin SaaS ainda será implementada.</div>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- conteúdo futuro -->
</div>
</template>
@@ -398,57 +398,55 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="matrix-root">
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="matrix-hero__icon-wrap">
<i class="pi pi-th-large matrix-hero__icon" />
</div>
<div class="matrix-hero__sub">
Defina quais recursos cada plano habilita. As mudanças ficam <b>pendentes</b> até clicar em <b>Salvar alterações</b>.
</div>
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="matrix-hero-sentinel" />
<div ref="heroEl" class="matrix-hero mb-4" :class="{ 'matrix-hero--stuck': heroStuck }">
<div class="matrix-hero__blobs" aria-hidden="true">
<div class="matrix-hero__blob matrix-hero__blob--1" />
<div class="matrix-hero__blob matrix-hero__blob--2" />
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Controle de Recursos</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Defina quais recursos cada plano habilita. Mudanças ficam pendentes até salvar.</div>
</div>
<div class="matrix-hero__inner">
<div class="matrix-hero__info min-w-0">
<div class="matrix-hero__title">Controle de Recursos</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || hasPending" v-tooltip.top="hasPending ? 'Salve ou descarte antes de recarregar.' : ''" @click="fetchAll" />
<Button label="Descartar" icon="pi pi-undo" severity="secondary" outlined size="small" :disabled="loading || saving || !hasPending" @click="confirmReset" />
<Button label="Salvar alterações" icon="pi pi-save" size="small" :loading="saving" :disabled="loading || !hasPending" @click="saveChanges" />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="matrix-hero__actions matrix-hero__actions--desktop">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || hasPending" v-tooltip.top="hasPending ? 'Salve ou descarte antes de recarregar.' : ''" @click="fetchAll" />
<Button label="Descartar" icon="pi pi-undo" severity="secondary" outlined size="small" :disabled="loading || saving || !hasPending" @click="confirmReset" />
<Button label="Salvar alterações" icon="pi pi-save" size="small" :loading="saving" :disabled="loading || !hasPending" @click="saveChanges" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="matrix-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="matrix_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="matrix_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="matrix_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="matrix_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-[340px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -458,8 +456,7 @@ onBeforeUnmount(() => {
</FloatLabel>
</div>
<div class="px-4 pb-4">
<div class="mb-3 surface-100 border-round p-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Planos: ${filteredPlans.length}`" severity="info" icon="pi pi-list" rounded />
@@ -467,13 +464,13 @@ onBeforeUnmount(() => {
<Tag v-if="hasPending" value="Alterações pendentes" severity="warn" icon="pi pi-clock" rounded />
</div>
<div class="text-color-secondary text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Dica: use a busca para reduzir a lista e aplique ações em massa com confirmação.
</div>
</div>
</div>
<Divider class="my-4" />
<Divider class="my-0" />
<DataTable
:value="filteredFeatures"
@@ -488,9 +485,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.key }}</span>
<small class="text-color-secondary leading-snug mt-1">
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-snug mt-1">
{{ data.descricao || data.description || '—' }}
</small>
</div>
</div>
</template>
</Column>
@@ -506,7 +503,7 @@ onBeforeUnmount(() => {
{{ planTitle(p) }}
</div>
<div class="flex items-center justify-center gap-1 flex-wrap">
<small class="text-color-secondary truncate" :title="p.key">{{ p.key }}</small>
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate" :title="p.key">{{ p.key }}</div>
<Tag :value="targetLabel(p.target)" :severity="targetSeverity(p.target)" rounded />
</div>
<div class="flex gap-2 justify-center">
@@ -545,69 +542,5 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div><!-- /px-4 pb-4 -->
</div>
</template>
<style scoped>
.matrix-root { padding: 1rem; }
@media (min-width: 768px) { .matrix-root { padding: 1.5rem; } }
.matrix-hero-sentinel { height: 1px; }
.matrix-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;
}
.matrix-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.matrix-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.matrix-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.matrix-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(52,211,153,0.12); }
.matrix-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.matrix-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.matrix-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;
}
.matrix-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.matrix-hero__info { flex: 1; min-width: 0; }
.matrix-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;
}
.matrix-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.matrix-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.matrix-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.matrix-hero__actions--desktop { display: none; }
.matrix-hero__actions--mobile { display: flex; }
}
</style>
</template>
+210 -279
View File
@@ -329,56 +329,53 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="limits-root">
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="limits-hero__icon-wrap">
<i class="pi pi-sliders-h limits-hero__icon" />
</div>
<div class="limits-hero__sub">
Configure os limites reais de cada feature por plano (ex: max_patients, max_sessions_per_month).
Esses valores são lidos pelo sistema para bloquear ações quando o limite é atingido.
</div>
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="limits-hero-sentinel" />
<div ref="heroEl" class="limits-hero mb-4" :class="{ 'limits-hero--stuck': heroStuck }">
<div class="limits-hero__blobs" aria-hidden="true">
<div class="limits-hero__blob limits-hero__blob--1" />
<div class="limits-hero__blob limits-hero__blob--2" />
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Limites por Plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Configure os limites reais de cada feature por plano.</div>
</div>
<div class="limits-hero__inner">
<div class="limits-hero__info min-w-0">
<div class="limits-hero__title">Limites por Plano</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="limits-hero__actions limits-hero__actions--desktop">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="limits-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="limits_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="limits_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="limits_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="limits_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-80">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -388,19 +385,18 @@ onBeforeUnmount(() => {
</FloatLabel>
</div>
<div class="px-4 pb-4">
<!-- Legenda rápida -->
<div class="surface-100 border-round p-3 mb-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2 text-sm text-color-secondary">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-blue-400" />
<span><strong>Sem limites</strong> = acesso habilitado sem restrição de quantidade</span>
</div>
<div class="flex items-center gap-2 text-sm text-color-secondary">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-orange-400" />
<span><strong>-1</strong> = ilimitado (explícito no JSON, útil para planos PRO)</span>
</div>
<div class="flex items-center gap-2 text-sm text-color-secondary">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-red-400" />
<span><strong>0 ou N</strong> = limite máximo que o sistema vai verificar</span>
</div>
@@ -431,11 +427,11 @@ onBeforeUnmount(() => {
:severity="domainSeverity(featureDomain(data.feature.key))"
rounded
/>
<span class="font-medium font-mono text-sm">{{ data.feature.key }}</span>
<span class="font-medium font-mono text-[1rem]">{{ data.feature.key }}</span>
</div>
<small class="text-color-secondary mt-1 leading-snug">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1 leading-snug">
{{ data.feature.descricao || '—' }}
</small>
</div>
</div>
</template>
</Column>
@@ -452,7 +448,7 @@ onBeforeUnmount(() => {
<span class="font-semibold truncate" :title="plan.name">{{ plan.name || plan.key }}</span>
<Tag :value="targetLabel(plan.target)" :severity="targetSeverity(plan.target)" rounded />
</div>
<small class="text-color-secondary font-mono">{{ plan.key }}</small>
<div class="text-[1rem] text-[var(--text-color-secondary)] font-mono">{{ plan.key }}</div>
</div>
</template>
@@ -472,7 +468,7 @@ onBeforeUnmount(() => {
<!-- Limites atuais -->
<div
v-if="data.planCols[plan.id].limits"
class="text-xs text-color-secondary leading-relaxed bg-surface-100 border-round p-2"
class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2"
>
<div
v-for="(val, key) in data.planCols[plan.id].limits"
@@ -483,7 +479,7 @@ onBeforeUnmount(() => {
<span>{{ limitValueDisplay(val) }}</span>
</div>
</div>
<div v-else-if="data.planCols[plan.id].hasRecord" class="text-xs text-color-secondary">
<div v-else-if="data.planCols[plan.id].hasRecord" class="text-[1rem] text-[var(--text-color-secondary)]">
Sem limites definidos
</div>
@@ -508,7 +504,7 @@ onBeforeUnmount(() => {
@click="askClearLimits(plan, data.feature)"
/>
</div>
<div v-else class="text-xs text-color-secondary italic">
<div v-else class="text-[1rem] text-[var(--text-color-secondary)] italic">
Feature não vinculada a este plano.<br/>
Configure em <strong>Recursos por Plano</strong>.
</div>
@@ -516,260 +512,195 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div>
</div><!-- /px-4 pb-4 -->
<!-- Dialog: editar limites de plan_features -->
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '680px' }"
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-lg font-semibold">Limites {{ dlgFeature?.key }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="dlgPlan?.name || dlgPlan?.key" severity="secondary" />
<Tag :value="targetLabel(dlgPlan?.target)" :severity="targetSeverity(dlgPlan?.target)" rounded />
</div>
<!-- Dialog: editar limites de plan_features -->
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '680px' }"
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-[1rem] font-semibold">Limites {{ dlgFeature?.key }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="dlgPlan?.name || dlgPlan?.key" severity="secondary" />
<Tag :value="targetLabel(dlgPlan?.target)" :severity="targetSeverity(dlgPlan?.target)" rounded />
</div>
</template>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4">
<!-- Campos existentes -->
<div v-if="limitFields.length">
<div class="font-semibold mb-2">Limites configurados</div>
<div class="flex flex-col gap-2">
<div
v-for="(field, idx) in limitFields"
:key="idx"
class="flex items-center gap-3 surface-100 border-round p-3"
>
<!-- Key (não editável) -->
<div class="flex-1 min-w-0">
<div class="font-mono font-medium text-sm">{{ field.key }}</div>
<small class="text-color-secondary">{{ field.type }}</small>
</div>
<!-- Valor -->
<div class="w-40 shrink-0">
<template v-if="field.type === 'number'">
<InputNumber
v-model="field.value"
class="w-full"
inputClass="w-full"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
</template>
<template v-else-if="field.type === 'boolean'">
<SelectButton
v-model="field.value"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</template>
<template v-else>
<InputText v-model="field.value" class="w-full" :disabled="saving" />
</template>
</div>
<!-- Ações rápidas -->
<div class="flex gap-1 shrink-0">
<Button
icon="pi pi-infinity"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Definir como ilimitado (-1)'"
:disabled="saving || field.type !== 'number'"
@click="setUnlimited(idx)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Remover este campo'"
:disabled="saving"
@click="removeLimitField(idx)"
/>
</div>
</div>
</div>
</div>
<div v-else class="text-sm text-color-secondary surface-100 border-round p-3 text-center">
Nenhum limite configurado. Adicione abaixo.
</div>
<Divider />
<!-- Adicionar novo campo -->
<div>
<div class="font-semibold mb-3">Adicionar campo de limite</div>
<div class="flex flex-col gap-3">
<!-- Nome -->
<div>
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">
Nome do campo *
</label>
<InputText
id="new-limit-key"
v-model="newLimitKey"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
placeholder="ex: max_patients"
@keydown.enter.prevent="addLimitField"
/>
<small class="text-color-secondary mt-1 block">
Ex: <span class="font-mono">max_patients</span>, <span class="font-mono">max_sessions_per_month</span>
</small>
<!-- Campos existentes -->
<div v-if="limitFields.length">
<div class="font-semibold mb-2">Limites configurados</div>
<div class="flex flex-col gap-2">
<div
v-for="(field, idx) in limitFields"
:key="idx"
class="flex items-center gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3"
>
<!-- Key (não editável) -->
<div class="flex-1 min-w-0">
<div class="font-mono font-medium text-[1rem]">{{ field.key }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ field.type }}</div>
</div>
<!-- Tipo + Valor + Botão -->
<div class="flex items-end gap-2 flex-wrap">
<div>
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">Tipo</label>
<SelectButton
v-model="newLimitType"
:options="limitTypeOptions"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</div>
<div class="flex-1" style="min-width: 8rem;">
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">Valor inicial</label>
<!-- Valor -->
<div class="w-40 shrink-0">
<template v-if="field.type === 'number'">
<InputNumber
v-if="newLimitType === 'number'"
v-model="newLimitValue"
v-model="field.value"
class="w-full"
inputClass="w-full"
variant="filled"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
</template>
<template v-else-if="field.type === 'boolean'">
<SelectButton
v-else-if="newLimitType === 'boolean'"
v-model="newLimitValue"
v-model="field.value"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
<InputText
v-else
v-model="newLimitValue"
class="w-full"
variant="filled"
:disabled="saving"
/>
</div>
</template>
<template v-else>
<InputText v-model="field.value" class="w-full" :disabled="saving" />
</template>
</div>
<!-- Ações rápidas -->
<div class="flex gap-1 shrink-0">
<Button
icon="pi pi-plus"
label="Adicionar"
:disabled="saving || !newLimitKey?.trim()"
@click="addLimitField"
icon="pi pi-infinity"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Definir como ilimitado (-1)'"
:disabled="saving || field.type !== 'number'"
@click="setUnlimited(idx)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Remover este campo'"
:disabled="saving"
@click="removeLimitField(idx)"
/>
</div>
</div>
</div>
</div>
<!-- Dica de boas práticas -->
<div class="surface-100 border-round p-3 text-xs text-color-secondary leading-relaxed">
<div class="font-semibold mb-1">Convenções recomendadas</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
<div><span class="font-mono">max_patients</span> número máximo de pacientes</div>
<div><span class="font-mono">max_sessions_per_month</span> sessões/mês</div>
<div><span class="font-mono">max_members</span> membros da clínica</div>
<div><span class="font-mono">max_therapists</span> terapeutas vinculados</div>
<div><span class="font-mono">-1</span> sem limite (planos PRO)</div>
<div><span class="font-mono">0</span> bloqueado completamente</div>
<div v-else class="text-[1rem] text-[var(--text-color-secondary)] rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 text-center">
Nenhum limite configurado. Adicione abaixo.
</div>
<Divider />
<!-- Adicionar novo campo -->
<div>
<div class="font-semibold mb-3">Adicionar campo de limite</div>
<div class="flex flex-col gap-3">
<!-- Nome -->
<div>
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">
Nome do campo *
</label>
<InputText
id="new-limit-key"
v-model="newLimitKey"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
placeholder="ex: max_patients"
@keydown.enter.prevent="addLimitField"
/>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Ex: <span class="font-mono">max_patients</span>, <span class="font-mono">max_sessions_per_month</span>
</div>
</div>
<!-- Tipo + Valor + Botão -->
<div class="flex items-end gap-2 flex-wrap">
<div>
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">Tipo</label>
<SelectButton
v-model="newLimitType"
:options="limitTypeOptions"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</div>
<div class="flex-1" style="min-width: 8rem;">
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">Valor inicial</label>
<InputNumber
v-if="newLimitType === 'number'"
v-model="newLimitValue"
class="w-full"
inputClass="w-full"
variant="filled"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
<SelectButton
v-else-if="newLimitType === 'boolean'"
v-model="newLimitValue"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
<InputText
v-else
v-model="newLimitValue"
class="w-full"
variant="filled"
:disabled="saving"
/>
</div>
<Button
icon="pi pi-plus"
label="Adicionar"
:disabled="saving || !newLimitKey?.trim()"
@click="addLimitField"
/>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
</template>
</Dialog>
</div>
</template>
<!-- Dica de boas práticas -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
<div class="font-semibold mb-1">Convenções recomendadas</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
<div><span class="font-mono">max_patients</span> número máximo de pacientes</div>
<div><span class="font-mono">max_sessions_per_month</span> sessões/mês</div>
<div><span class="font-mono">max_members</span> membros da clínica</div>
<div><span class="font-mono">max_therapists</span> terapeutas vinculados</div>
<div><span class="font-mono">-1</span> sem limite (planos PRO)</div>
<div><span class="font-mono">0</span> bloqueado completamente</div>
</div>
</div>
</div>
<style scoped>
.limits-root { padding: 1rem; }
@media (min-width: 768px) { .limits-root { padding: 1.5rem; } }
.limits-hero-sentinel { height: 1px; }
.limits-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;
}
.limits-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.limits-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.limits-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.limits-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(251,146,60,0.12); }
.limits-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.limits-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.limits-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;
}
.limits-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.limits-hero__info { flex: 1; min-width: 0; }
.limits-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;
}
.limits-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.limits-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.limits-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.limits-hero__actions--desktop { display: none; }
.limits-hero__actions--mobile { display: flex; }
}
</style>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
</template>
</Dialog>
</template>
+192 -282
View File
@@ -432,321 +432,231 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="plans-root">
<!-- Sentinel -->
<div ref=heroSentinelRef class=h-px />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="plans-hero__icon-wrap">
<i class="pi pi-list plans-hero__icon" />
</div>
<div class="plans-hero__sub">
Catálogo de planos do SaaS. A <b>key</b> é a referência técnica estável.
O <b>público</b> indica se o plano é para <b>Clínica</b> ou <b>Terapeuta</b>.
</div>
<!-- Hero sticky -->
<div
ref=heroEl
class=sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5
:style={ top: 'var(--layout-sticky-top, 56px)' }
>
<div class=absolute inset-0 pointer-events-none overflow-hidden aria-hidden=true>
<div class=absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10 />
<div class=absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10 />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="plans-hero-sentinel" />
<div ref="heroEl" class="plans-hero mb-5" :class="{ 'plans-hero--stuck': heroStuck }">
<div class="plans-hero__blobs" aria-hidden="true">
<div class="plans-hero__blob plans-hero__blob--1" />
<div class="plans-hero__blob plans-hero__blob--2" />
<div class=relative z-10 flex items-center justify-between gap-3 flex-wrap>
<div class=min-w-0>
<div class=text-[1rem] font-bold tracking-tight text-[var(--text-color)]>Planos e preços</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>Catálogo de planos do SaaS.</div>
</div>
<div class="plans-hero__inner">
<!-- Título -->
<div class="plans-hero__info min-w-0">
<div class="plans-hero__title">Planos e preços</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class=hidden xl:flex items-center gap-2 flex-wrap>
<SelectButton
v-model=targetFilter
:options=targetFilterOptions
optionLabel=label
optionValue=value
size=small
/>
<Button label=Atualizar icon=pi pi-refresh severity=secondary outlined size=small :loading=loading :disabled=saving @click=fetchAll />
<Button label=Adicionar plano icon=pi pi-plus size=small :disabled=saving @click=openCreate />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="plans-hero__actions plans-hero__actions--desktop">
<SelectButton
v-model="targetFilter"
:options="targetFilterOptions"
optionLabel="label"
optionValue="value"
size="small"
/>
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar plano" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="plans-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="plans_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="plans_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class=flex xl:hidden>
<Button
label=Ações
icon=pi pi-ellipsis-v
severity=warn
size=small
aria-haspopup=true
aria-controls=plans_hero_menu
@click=(e) => heroMenuRef.toggle(e)
/>
<Menu ref=heroMenuRef id=plans_hero_menu :model=heroMenuItems :popup=true />
</div>
</div>
</div>
<div class="px-4 pb-4">
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
<Column field="key" header="Key" sortable />
<!-- content -->
<div class=px-3 md:px-4 pb-8 flex flex-col gap-4>
<DataTable :value=filteredRows dataKey=id :loading=loading stripedRows responsiveLayout=scroll>
<Column field=name header=Nome sortable style=min-width: 14rem />
<Column field=key header=Key sortable />
<Column field="target" header="Público" sortable style="width: 10rem">
<template #body="{ data }">
<span class="font-medium">{{ formatTargetLabel(data.target) }}</span>
<Column field=target header=Público sortable style=width: 10rem>
<template #body={ data }>
<span class=font-medium>{{ formatTargetLabel(data.target) }}</span>
</template>
</Column>
<Column header="Mensal" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
<Column header=Mensal sortable style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header="Anual" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
<Column header=Anual sortable style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
<Column v-if=hasCreatedAt field=created_at header=Criado em sortable />
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
<Column header=Ações style=width: 12rem>
<template #body={ data }>
<div class=flex gap-2>
<Button icon=pi pi-pencil severity=secondary outlined @click=openEdit(data) />
<Button
icon="pi pi-trash"
severity="danger"
icon=pi pi-trash
severity=danger
outlined
:disabled="isDeleteLockedRow(data)"
:title="isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'"
@click="askDelete(data)"
:disabled=isDeleteLockedRow(data)
:title=isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'
@click=askDelete(data)
/>
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:header="isEdit ? 'Editar plano' : 'Novo plano'"
:style="{ width: '620px' }"
class="plans-dialog"
>
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Público do plano</label>
<SelectButton
v-model="form.target"
:options="targetOptions"
optionLabel="label"
optionValue="value"
class="w-full"
:disabled="isTargetLocked || saving"
/>
<small class="text-color-secondary">
Planos existentes não mudam de público. Isso evita inconsistência no catálogo.
</small>
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-tag" />
<InputText
v-model="form.key"
id="plan_key"
class="w-full pr-10"
variant="filled"
placeholder="ex.: clinic_pro"
:disabled="(isCorePlanEditing || saving)"
@blur="form.key = slugifyKey(form.key)"
/>
</IconField>
<label for="plan_key">Key</label>
</FloatLabel>
<small class="text-color-secondary -mt-3">
Key é técnica e estável (slug). Planos padrão do sistema têm a key protegida.
</small>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-bookmark" />
<InputText
v-model="form.name"
id="plan_name"
class="w-full pr-10"
variant="filled"
placeholder="ex.: Clínica PRO"
:disabled="saving"
/>
</IconField>
<label for="plan_name">Nome</label>
</FloatLabel>
<small class="text-color-secondary -mt-3">
Nome interno para administração. (Nome público vem de <b>plan_public</b>.)
</small>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-money-bill" />
<InputNumber
v-model="form.price_monthly"
inputId="price_monthly"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 49,90"
:disabled="saving"
/>
</IconField>
<label for="price_monthly">Preço mensal (R$)</label>
</FloatLabel>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-calendar" />
<InputNumber
v-model="form.price_yearly"
inputId="price_yearly"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 490,00"
:disabled="saving"
/>
</IconField>
<label for="price_yearly">Preço anual (R$)</label>
</FloatLabel>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
</div>
<!-- max_supervisees: para planos de supervisor -->
<div v-if="form.target === 'supervisor'">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-users" />
<InputNumber
v-model="form.max_supervisees"
inputId="max_supervisees"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
:useGrouping="false"
:min="1"
placeholder="ex.: 3"
:disabled="saving"
/>
</IconField>
<label for="max_supervisees">Limite de supervisionados</label>
</FloatLabel>
<small class="text-color-secondary">Número máximo de terapeutas que podem ser supervisionados neste plano.</small>
<Dialog
v-model:visible=showDlg
modal
:draggable=false
:header=isEdit ? 'Editar plano' : 'Novo plano'
:style={ width: '620px' }
>
<div class=flex flex-col gap-4>
<div>
<label class=block mb-2>Público do plano</label>
<SelectButton
v-model=form.target
:options=targetOptions
optionLabel=label
optionValue=value
class=w-full
:disabled=isTargetLocked || saving
/>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
Planos existentes não mudam de público. Isso evita inconsistência no catálogo.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
</template>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-tag />
<InputText
v-model=form.key
id=plan_key
class=w-full pr-10
variant=filled
placeholder=ex.: clinic_pro
:disabled=(isCorePlanEditing || saving)
@blur=form.key = slugifyKey(form.key)
/>
</IconField>
<label for=plan_key>Key</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] -mt-3>
Key é técnica e estável (slug). Planos padrão do sistema têm a key protegida.
</div>
<style scoped>
/* ─── Root ──────────────────────────────────────────────── */
.plans-root { padding: 1rem; }
@media (min-width: 768px) { .plans-root { padding: 1.5rem; } }
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-bookmark />
<InputText
v-model=form.name
id=plan_name
class=w-full pr-10
variant=filled
placeholder=ex.: Clínica PRO
:disabled=saving
/>
</IconField>
<label for=plan_name>Nome</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] -mt-3>
Nome interno para administração. (Nome público vem de <b>plan_public</b>.)
</div>
/* ─── Hero ──────────────────────────────────────────────── */
.plans-hero-sentinel { height: 1px; }
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<div>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-money-bill />
<InputNumber
v-model=form.price_monthly
inputId=price_monthly
class=w-full
inputClass=w-full pr-10
variant=filled
mode=decimal
:minFractionDigits=2
:maxFractionDigits=2
placeholder=ex.: 49,90
:disabled=saving
/>
</IconField>
<label for=price_monthly>Preço mensal (R$)</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
</div>
.plans-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;
}
.plans-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.plans-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.plans-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.plans-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(99,102,241,0.12); }
.plans-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(52,211,153,0.09); }
<div>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-calendar />
<InputNumber
v-model=form.price_yearly
inputId=price_yearly
class=w-full
inputClass=w-full pr-10
variant=filled
mode=decimal
:minFractionDigits=2
:maxFractionDigits=2
placeholder=ex.: 490,00
:disabled=saving
/>
</IconField>
<label for=price_yearly>Preço anual (R$)</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
</div>
</div>
.plans-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
<!-- max_supervisees: para planos de supervisor -->
<div v-if=form.target === 'supervisor'>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-users />
<InputNumber
v-model=form.max_supervisees
inputId=max_supervisees
class=w-full
inputClass=w-full pr-10
variant=filled
:useGrouping=false
:min=1
placeholder=ex.: 3
:disabled=saving
/>
</IconField>
<label for=max_supervisees>Limite de supervisionados</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Número máximo de terapeutas que podem ser supervisionados neste plano.</div>
</div>
</div>
/* Ícone */
.plans-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;
}
.plans-hero__icon { font-size: 1.5rem; color: var(--text-color); }
/* Info */
.plans-hero__info { flex: 1; min-width: 0; }
.plans-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;
}
.plans-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
/* Ações */
.plans-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.plans-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.plans-hero__actions--desktop { display: none; }
.plans-hero__actions--mobile { display: flex; }
}
/* ─── Dialog: linhas divisórias no header e footer */
:deep(.plans-dialog .p-dialog-header) {
border-bottom: 1px solid var(--surface-border);
}
:deep(.plans-dialog .p-dialog-footer) {
border-top: 1px solid var(--surface-border);
}
/* Pequena melhoria de leitura */
small.text-color-secondary {
line-height: 1.35rem;
}
</style>
<template #footer>
<Button label=Cancelar severity=secondary outlined @click=showDlg = false :disabled=saving />
<Button :label=isEdit ? 'Salvar' : 'Criar' icon=pi pi-check :loading=saving @click=save />
</template>
</Dialog>
</template>
+387 -459
View File
@@ -449,152 +449,149 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="showcase-root">
<!-- Sentinel -->
<div ref=heroSentinelRef class=h-px />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="showcase-hero__icon-wrap">
<i class="pi pi-megaphone showcase-hero__icon" />
</div>
<div class="showcase-hero__sub">
Configure como os planos aparecem na página pública nome, descrição, badge, ordem e benefícios.
</div>
<!-- Hero sticky -->
<div
ref=heroEl
class=sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5
:style={ top: 'var(--layout-sticky-top, 56px)' }
>
<div class=absolute inset-0 pointer-events-none overflow-hidden aria-hidden=true>
<div class=absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10 />
<div class=absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10 />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="showcase-hero-sentinel" />
<div ref="heroEl" class="showcase-hero mb-4" :class="{ 'showcase-hero--stuck': heroStuck }">
<div class="showcase-hero__blobs" aria-hidden="true">
<div class="showcase-hero__blob showcase-hero__blob--1" />
<div class="showcase-hero__blob showcase-hero__blob--2" />
<div class=relative z-10 flex items-center justify-between gap-3 flex-wrap>
<div class=min-w-0>
<div class=text-[1rem] font-bold tracking-tight text-[var(--text-color)]>Vitrine de Planos</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>Configure como os planos aparecem na página pública.</div>
</div>
<div class="showcase-hero__inner">
<div class="showcase-hero__info min-w-0">
<div class="showcase-hero__title">Vitrine de Planos</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class=hidden xl:flex items-center gap-2 flex-wrap>
<SelectButton v-model=targetFilter :options=targetOptions optionLabel=label optionValue=value size=small :disabled=loading || saving || bulletSaving />
<Button label=Recarregar icon=pi pi-refresh severity=secondary outlined size=small :loading=loading :disabled=saving || bulletSaving @click=fetchAll />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="showcase-hero__actions showcase-hero__actions--desktop">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving || bulletSaving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || bulletSaving" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="showcase-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="showcase_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="showcase_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class=flex xl:hidden>
<Button
label=Ações
icon=pi pi-ellipsis-v
severity=warn
size=small
aria-haspopup=true
aria-controls=showcase_hero_menu
@click=(e) => heroMenuRef.toggle(e)
/>
<Menu ref=heroMenuRef id=showcase_hero_menu :model=heroMenuItems :popup=true />
</div>
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<FloatLabel variant="on" class="w-full md:w-80">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="plans_public_search" class="w-full pr-10" variant="filled" :disabled="loading || saving || bulletSaving" />
<!-- content -->
<div class=px-3 md:px-4 pb-8 flex flex-col gap-4>
<!-- Search -->
<div>
<FloatLabel variant=on class=w-full md:w-80>
<IconField class=w-full>
<InputIcon class=pi pi-search />
<InputText v-model=q id=plans_public_search class=w-full pr-10 variant=filled :disabled=loading || saving || bulletSaving />
</IconField>
<label for="plans_public_search">Buscar plano</label>
<label for=plans_public_search>Buscar plano</label>
</FloatLabel>
</div>
<!-- Popover global (reutilizado) -->
<Popover ref="bulletsPop">
<div class="w-[340px] max-w-[80vw]">
<div class="text-sm font-semibold mb-2">{{ popPlanTitle }}</div>
<Popover ref=bulletsPop>
<div class=w-[340px] max-w-[80vw]>
<div class=text-[1rem] font-semibold mb-2>{{ popPlanTitle }}</div>
<div v-if="!popBullets?.length" class="text-sm text-color-secondary">
<div v-if=!popBullets?.length class=text-[1rem] text-[var(--text-color-secondary)]>
Nenhum benefício configurado.
</div>
<ul v-else class="m-0 pl-4 space-y-2">
<li v-for="b in popBullets" :key="b.id" class="text-sm leading-snug">
<span :class="b.highlight ? 'font-semibold' : ''">
<ul v-else class=m-0 pl-4 space-y-2>
<li v-for=b in popBullets :key=b.id class=text-[1rem] leading-snug>
<span :class=b.highlight ? 'font-semibold' : ''>
{{ b.text }}
</span>
<small v-if="b.highlight" class="ml-2 text-color-secondary">(destaque)</small>
<div v-if=b.highlight class=inline ml-2 text-[1rem] text-[var(--text-color-secondary)]>(destaque)</div>
</li>
</ul>
</div>
</Popover>
<div class="px-4 pb-4">
<DataTable :value="tableRows" dataKey="plan_id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Plano" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ data.public_name || data.plan_name || data.plan_key }}</span>
<small class="text-color-secondary">
<DataTable :value=tableRows dataKey=plan_id :loading=loading stripedRows responsiveLayout=scroll>
<Column header=Plano style=min-width: 18rem>
<template #body={ data }>
<div class=flex flex-col>
<span class=font-semibold>{{ data.public_name || data.plan_name || data.plan_key }}</span>
<div class=text-[1rem] text-[var(--text-color-secondary)]>
{{ data.plan_key }} {{ data.plan_name || '—' }}
</small>
</div>
</div>
</template>
</Column>
<Column header="Público" style="width: 10rem">
<template #body="{ data }">
<Tag :value="targetLabel(normalizeTarget(data))" :severity="targetSeverity(normalizeTarget(data))" rounded />
<Column header=Público style=width: 10rem>
<template #body={ data }>
<Tag :value=targetLabel(normalizeTarget(data)) :severity=targetSeverity(normalizeTarget(data)) rounded />
</template>
</Column>
<Column header="Mensal" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
<Column header=Mensal style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header="Anual" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
<Column header=Anual style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column field="badge" header="Badge" style="min-width: 12rem" />
<Column field=badge header=Badge style=min-width: 12rem />
<Column header="Visível" style="width: 8rem">
<template #body="{ data }">
<Column header=Visível style=width: 8rem>
<template #body={ data }>
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Destaque" style="width: 9rem">
<template #body="{ data }">
<Column header=Destaque style=width: 9rem>
<template #body={ data }>
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column field="sort_order" header="Ordem" style="width: 8rem" />
<Column field=sort_order header=Ordem style=width: 8rem />
<Column header="Ações" style="width: 14rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Column header=Ações style=width: 14rem>
<template #body={ data }>
<div class=flex gap-2 justify-end>
<Button
severity="secondary"
severity=secondary
outlined
size="small"
:disabled="loading || saving || bulletSaving"
@click="(e) => openBulletsPopover(e, data)"
size=small
:disabled=loading || saving || bulletSaving
@click=(e) => openBulletsPopover(e, data)
>
<i class="pi pi-list mr-2" />
<span class="font-medium">{{ data.bullets?.length || 0 }}</span>
<i class=pi pi-list mr-2 />
<span class=font-medium>{{ data.bullets?.length || 0 }}</span>
</Button>
<Button
icon="pi pi-pencil"
severity="secondary"
icon=pi pi-pencil
severity=secondary
outlined
size="small"
:disabled="loading || saving || bulletSaving"
@click="openEdit(data)"
size=small
:disabled=loading || saving || bulletSaving
@click=openEdit(data)
/>
</div>
</template>
@@ -602,407 +599,338 @@ onBeforeUnmount(() => {
</DataTable>
<!-- PREVIEW PÚBLICO (conceitual) -->
<div class="mt-10">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Hero -->
<div class="relative p-6 md:p-10">
<div class="absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)]" />
<div class="relative">
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
<div class="max-w-2xl">
<div class="flex items-center gap-2 mb-3 flex-wrap">
<Tag
:value="targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`"
:severity="targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')"
rounded
/>
<span class="text-sm text-color-secondary">
Ajuste nomes, descrições, badges e benefícios e veja o resultado aqui.
</span>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden>
<!-- Hero -->
<div class=relative p-6 md:p-10>
<div class=absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)] />
<div class=relative>
<div class=flex flex-col md:flex-row md:items-end md:justify-between gap-6>
<div class=max-w-2xl>
<div class=flex items-center gap-2 mb-3 flex-wrap>
<Tag
:value=targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`
:severity=targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')
rounded
/>
<div class=text-[1rem] text-[var(--text-color-secondary)]>
Ajuste nomes, descrições, badges e benefícios e veja o resultado aqui.
</div>
<h2 class="text-3xl md:text-5xl font-semibold leading-tight">
Um plano não é preço.<br />
É promessa organizada.
</h2>
<p class="text-color-secondary mt-3">
A vitrine é o lugar onde o produto deixa de ser tabela e vira escolha.
Clareza, contraste e uma hierarquia que guia o olhar sem ruído.
</p>
</div>
<div class="flex flex-col items-start md:items-end gap-4">
<div class="flex flex-col gap-2">
<div class="text-sm text-color-secondary">Cobrança</div>
<SelectButton
v-model="billingInterval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
<div class=text-3xl md:text-5xl font-semibold leading-tight>
Um plano não é preço.<br />
É promessa organizada.
</div>
<div class=text-[var(--text-color-secondary)] mt-3>
A vitrine é o lugar onde o produto deixa de ser tabela e vira escolha.
Clareza, contraste e uma hierarquia que guia o olhar sem ruído.
</div>
</div>
<div class=flex flex-col items-start md:items-end gap-4>
<div class=flex flex-col gap-2>
<div class=text-[1rem] text-[var(--text-color-secondary)]>Cobrança</div>
<SelectButton
v-model=billingInterval
:options=intervalOptions
optionLabel=label
optionValue=value
/>
</div>
<div class=flex flex-col gap-2>
<div class=text-[1rem] text-[var(--text-color-secondary)]>Planos sem preço</div>
<SelectButton
v-model=previewPricePolicy
:options=previewPolicyOptions
optionLabel=label
optionValue=value
/>
</div>
</div>
</div>
</div>
</div>
<!-- Cards -->
<div class=p-6 md:p-10 pt-0>
<div v-if=!previewPlans.length class=text-[1rem] text-[var(--text-color-secondary)]>
Nenhum plano visível para este filtro.
</div>
<div v-else class=mt-6 grid grid-cols-1 md:grid-cols-3 gap-6>
<div
v-for=p in previewPlans
:key=p.plan_id
:class=[
'relative rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
'shadow-sm transition-transform',
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
]
>
<div class=h-2 w-full opacity-50 bg-[var(--surface-100)] />
<div class=p-6>
<div class=flex items-center justify-between gap-3>
<div class=flex items-center gap-2 flex-wrap>
<Tag :value=targetLabel(normalizeTarget(p)) :severity=targetSeverity(normalizeTarget(p)) rounded />
<Tag
v-if=p.badge || p.is_featured
:value=p.badge || 'Destaque'
:severity=p.is_featured ? 'success' : 'secondary'
rounded
/>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-color-secondary">Planos sem preço</div>
<SelectButton
v-model="previewPricePolicy"
:options="previewPolicyOptions"
optionLabel="label"
optionValue="value"
/>
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)]>{{ p.plan_key }}</div>
</div>
<div class=mt-4>
<template v-if=priceDisplayForPreview(p).kind === 'paid'>
<div class=text-4xl font-semibold leading-none>
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
{{ priceDisplayForPreview(p).sub }}
</div>
</template>
<template v-else-if=priceDisplayForPreview(p).kind === 'free'>
<div class=text-4xl font-semibold leading-none>
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
{{ billingInterval === 'year' ? 'no anual' : 'no mensal' }}
</div>
</template>
<template v-else>
<div class=text-2xl font-semibold leading-none>
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
Fale com a equipe para montar o plano ideal.
</div>
</template>
</div>
<div class=text-[var(--text-color-secondary)] mt-3 min-h-[44px]>
{{ p.public_description || '—' }}
</div>
<Button
class=mt-5 w-full
:label=p.is_featured ? 'Começar agora' : 'Selecionar plano'
:severity=p.is_featured ? 'success' : 'secondary'
:outlined=!p.is_featured
/>
<div class=mt-6>
<div class=border-t border-dashed border-[var(--surface-border)] />
</div>
<ul v-if=p.bullets?.length class=mt-4 space-y-2>
<li v-for=b in p.bullets :key=b.id class=flex items-start gap-2>
<i class=pi pi-check mt-1 text-[1rem] text-[var(--text-color-secondary)]></i>
<span :class=['text-[1rem] leading-snug', b.highlight ? 'font-semibold' : '']>
{{ b.text }}
</span>
</li>
</ul>
<div v-else class=mt-4 text-[1rem] text-[var(--text-color-secondary)]>
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
<!-- Cards -->
<div class="p-6 md:p-10 pt-0">
<div v-if="!previewPlans.length" class="text-sm text-color-secondary">
Nenhum plano visível para este filtro.
</div>
<div v-else class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div
v-for="p in previewPlans"
:key="p.plan_id"
:class="[
'relative rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
'shadow-sm transition-transform',
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
]"
>
<div class="h-2 w-full opacity-50 bg-[var(--surface-100)]" />
<div class="p-6">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="targetLabel(normalizeTarget(p))" :severity="targetSeverity(normalizeTarget(p))" rounded />
<Tag
v-if="p.badge || p.is_featured"
:value="p.badge || 'Destaque'"
:severity="p.is_featured ? 'success' : 'secondary'"
rounded
/>
</div>
<span class="text-xs text-color-secondary">{{ p.plan_key }}</span>
</div>
<div class="mt-4">
<template v-if="priceDisplayForPreview(p).kind === 'paid'">
<div class="text-4xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm text-color-secondary mt-1">
{{ priceDisplayForPreview(p).sub }}
</div>
</template>
<template v-else-if="priceDisplayForPreview(p).kind === 'free'">
<div class="text-4xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm text-color-secondary mt-1">
{{ billingInterval === 'year' ? 'no anual' : 'no mensal' }}
</div>
</template>
<template v-else>
<div class="text-2xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm text-color-secondary mt-1">
Fale com a equipe para montar o plano ideal.
</div>
</template>
</div>
<p class="text-color-secondary mt-3 min-h-[44px]">
{{ p.public_description || '—' }}
</p>
<Button
class="mt-5 w-full"
:label="p.is_featured ? 'Começar agora' : 'Selecionar plano'"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
/>
<div class="mt-6">
<div class="border-t border-dashed border-[var(--surface-border)]" />
</div>
<ul v-if="p.bullets?.length" class="mt-4 space-y-2">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-sm text-color-secondary"></i>
<span :class="['text-sm leading-snug', b.highlight ? 'font-semibold' : '']">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="mt-4 text-sm text-color-secondary">
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
<div v-if="previewPricePolicy === 'hide'" class="mt-6 text-xs text-color-secondary">
Observação: planos sem preço não aparecem no preview (política atual).
Para exibir como Sob consulta, mude acima.
</div>
<div v-if=previewPricePolicy === 'hide' class=mt-6 text-[1rem] text-[var(--text-color-secondary)]>
Observação: planos sem preço não aparecem no preview (política atual).
Para exibir como Sob consulta, mude acima.
</div>
</div>
</div>
</div><!-- /px-4 pb-4 -->
</div><!-- /content -->
<!-- Dialog principal ( sem drag: removemos draggable) -->
<Dialog
v-model:visible="showDlg"
modal
header="Editar vitrine"
:style="{ width: '820px' }"
:closable="!saving"
:dismissableMask="!saving"
:draggable="false"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex flex-col gap-4">
<!-- Nome público (FloatLabel + Icon) -->
<FloatLabel variant="on">
<!-- Dialog principal ( sem drag: removemos draggable) -->
<Dialog
v-model:visible=showDlg
modal
header=Editar vitrine
:style={ width: '820px' }
:closable=!saving
:dismissableMask=!saving
:draggable=false
>
<div class=grid grid-cols-1 md:grid-cols-2 gap-6>
<div class=flex flex-col gap-4>
<!-- Nome público (FloatLabel + Icon) -->
<FloatLabel variant=on>
<IconField>
<InputIcon class=pi pi-tag />
<InputText
id=pp-public-name
v-model.trim=form.public_name
class=w-full
variant=filled
:disabled=saving
autocomplete=off
autofocus
@keydown.enter.prevent=save
/>
</IconField>
<label for=pp-public-name>Nome público *</label>
</FloatLabel>
<!-- Descrição pública -->
<FloatLabel variant=on>
<IconField>
<InputIcon class=pi pi-align-left />
<Textarea
id=pp-public-desc
v-model.trim=form.public_description
class=w-full
rows=3
autoResize
:disabled=saving
/>
</IconField>
<label for=pp-public-desc>Descrição pública</label>
</FloatLabel>
<!-- Badge -->
<FloatLabel variant=on>
<IconField>
<InputIcon class=pi pi-bookmark />
<InputText
id=pp-badge
v-model.trim=form.badge
class=w-full
variant=filled
:disabled=saving
autocomplete=off
@keydown.enter.prevent=save
/>
</IconField>
<label for=pp-badge>Badge (opcional)</label>
</FloatLabel>
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<!-- Ordem -->
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="pp-public-name"
v-model.trim="form.public_name"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@keydown.enter.prevent="save"
<InputIcon class=pi pi-sort-amount-up-alt />
<InputNumber
id=pp-sort
v-model=form.sort_order
class=w-full
inputClass=w-full
:disabled=saving
/>
</IconField>
<label for="pp-public-name">Nome público *</label>
<label for=pp-sort>Ordem</label>
</FloatLabel>
<!-- Descrição pública -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-align-left" />
<Textarea
id="pp-public-desc"
v-model.trim="form.public_description"
class="w-full"
rows="3"
autoResize
:disabled="saving"
/>
</IconField>
<label for="pp-public-desc">Descrição pública</label>
</FloatLabel>
<!-- Badge -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-bookmark" />
<InputText
id="pp-badge"
v-model.trim="form.badge"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="pp-badge">Badge (opcional)</label>
</FloatLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Ordem -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-sort-amount-up-alt" />
<InputNumber
id="pp-sort"
v-model="form.sort_order"
class="w-full"
inputClass="w-full"
:disabled="saving"
/>
</IconField>
<label for="pp-sort">Ordem</label>
</FloatLabel>
<div class="flex flex-col gap-3 pt-2">
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_visible" :binary="true" :disabled="saving" />
<label>Visível no público</label>
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_featured" :binary="true" :disabled="saving" />
<label>Destaque</label>
</div>
<div class=flex flex-col gap-3 pt-2>
<div class=flex items-center gap-2>
<Checkbox v-model=form.is_visible :binary=true :disabled=saving />
<label>Visível no público</label>
</div>
<div class=flex items-center gap-2>
<Checkbox v-model=form.is_featured :binary=true :disabled=saving />
<label>Destaque</label>
</div>
</div>
</div>
<!-- bullets -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="font-semibold">Benefícios (bullets)</div>
<Button label="Adicionar" icon="pi pi-plus" size="small" :disabled="saving || bulletSaving" @click="openBulletCreate" />
</div>
<DataTable :value="bullets" dataKey="id" stripedRows responsiveLayout="scroll">
<Column field="text" header="Texto" />
<Column field="sort_order" header="Ordem" style="width: 7rem" />
<Column header="Destaque" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Ações" style="width: 9rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" :disabled="saving || bulletSaving" @click="openBulletEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" :disabled="saving || bulletSaving" @click="askDeleteBullet(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
<!-- bullets -->
<div>
<div class=flex items-center justify-between mb-3>
<div class=font-semibold>Benefícios (bullets)</div>
<Button label=Adicionar icon=pi pi-plus size=small :disabled=saving || bulletSaving @click=openBulletCreate />
</div>
<!-- Dialog bullet ( sem drag + inputs padronizados) -->
<Dialog
v-model:visible="showBulletDlg"
modal
:header="bulletIsEdit ? 'Editar benefício' : 'Novo benefício'"
:style="{ width: '560px' }"
:closable="!bulletSaving"
:dismissableMask="!bulletSaving"
:draggable="false"
>
<div class="flex flex-col gap-4">
<FloatLabel variant="on">
<DataTable :value=bullets dataKey=id stripedRows responsiveLayout=scroll>
<Column field=text header=Texto />
<Column field=sort_order header=Ordem style=width: 7rem />
<Column header=Destaque style=width: 8rem>
<template #body={ data }>
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header=Ações style=width: 9rem>
<template #body={ data }>
<div class=flex gap-2>
<Button icon=pi pi-pencil severity=secondary outlined size=small :disabled=saving || bulletSaving @click=openBulletEdit(data) />
<Button icon=pi pi-trash severity=danger outlined size=small :disabled=saving || bulletSaving @click=askDeleteBullet(data) />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<template #footer>
<Button label=Cancelar severity=secondary outlined :disabled=saving @click=showDlg = false />
<Button label=Salvar icon=pi pi-check :loading=saving @click=save />
</template>
</Dialog>
<!-- Dialog bullet ( sem drag + inputs padronizados) -->
<Dialog
v-model:visible=showBulletDlg
modal
:header=bulletIsEdit ? 'Editar benefício' : 'Novo benefício'
:style={ width: '560px' }
:closable=!bulletSaving
:dismissableMask=!bulletSaving
:draggable=false
>
<div class=flex flex-col gap-4>
<FloatLabel variant=on>
<IconField>
<InputIcon class=pi pi-list />
<Textarea
id=pp-bullet-text
v-model.trim=bulletForm.text
class=w-full
rows=3
autoResize
:disabled=bulletSaving
/>
</IconField>
<label for=pp-bullet-text>Texto *</label>
</FloatLabel>
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-list" />
<Textarea
id="pp-bullet-text"
v-model.trim="bulletForm.text"
class="w-full"
rows="3"
autoResize
:disabled="bulletSaving"
<InputIcon class=pi pi-sort-numeric-up />
<InputNumber
id=pp-bullet-order
v-model=bulletForm.sort_order
class=w-full
inputClass=w-full
:disabled=bulletSaving
/>
</IconField>
<label for="pp-bullet-text">Texto *</label>
<label for=pp-bullet-order>Ordem</label>
</FloatLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-sort-numeric-up" />
<InputNumber
id="pp-bullet-order"
v-model="bulletForm.sort_order"
class="w-full"
inputClass="w-full"
:disabled="bulletSaving"
/>
</IconField>
<label for="pp-bullet-order">Ordem</label>
</FloatLabel>
<div class="flex items-center gap-2 pt-7">
<Checkbox v-model="bulletForm.highlight" :binary="true" :disabled="bulletSaving" />
<label>Destaque</label>
</div>
<div class=flex items-center gap-2 pt-7>
<Checkbox v-model=bulletForm.highlight :binary=true :disabled=bulletSaving />
<label>Destaque</label>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="bulletSaving" @click="showBulletDlg = false" />
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
</template>
</Dialog>
</div>
</template>
<style scoped>
/* ─── Root ──────────────────────────────────────────────── */
.showcase-root { padding: 1rem; }
@media (min-width: 768px) { .showcase-root { padding: 1.5rem; } }
/* ─── Hero ──────────────────────────────────────────────── */
.showcase-hero-sentinel { height: 1px; }
.showcase-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;
}
.showcase-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.showcase-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.showcase-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.showcase-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(16,185,129,0.12); }
.showcase-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.showcase-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.showcase-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;
}
.showcase-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.showcase-hero__info { flex: 1; min-width: 0; }
.showcase-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;
}
.showcase-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.showcase-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.showcase-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.showcase-hero__actions--desktop { display: none; }
.showcase-hero__actions--mobile { display: flex; }
}
</style>
<template #footer>
<Button label=Cancelar severity=secondary outlined :disabled=bulletSaving @click=showBulletDlg = false />
<Button :label=bulletIsEdit ? 'Salvar' : 'Criar' icon=pi pi-check :loading=bulletSaving @click=saveBullet />
</template>
</Dialog>
</template>
@@ -316,41 +316,28 @@ onBeforeUnmount(() => {
<template>
<Toast />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="events-hero__icon-wrap">
<i class="pi pi-history events-hero__icon" />
</div>
<div class="events-hero__sub">
Auditoria read-only das mudanças de plano e status. Exibe até 500 eventos mais recentes.
<template v-if="!loading">
{{ totalCount }} evento(s) {{ changedCount }} troca(s) de plano
</template>
</div>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="events-hero"
:class="{ 'events-hero--stuck': heroStuck }"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="events-hero__blobs" aria-hidden="true">
<div class="events-hero__blob events-hero__blob--1" />
<div class="events-hero__blob events-hero__blob--2" />
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-orange-400/10" />
</div>
<div class="events-hero__inner">
<!-- Título -->
<div class="events-hero__info min-w-0">
<div class="events-hero__title">Histórico de assinaturas</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Histórico de assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Auditoria read-only das mudanças de plano e status.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="events-hero__actions events-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button
label="Voltar para assinaturas"
icon="pi pi-arrow-left"
@@ -380,7 +367,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="events-hero__actions--mobile">
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -394,20 +381,20 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card foco -->
<div
v-if="isFocused"
class="mb-3 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm"
class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]"
>
<div class="flex flex-wrap items-center justify-between gap-3 p-4 md:p-5">
<div class="flex flex-wrap items-center justify-between gap-3 p-5">
<div class="min-w-0">
<div class="text-lg font-semibold leading-none">Eventos em foco</div>
<div class="text-[1rem] font-semibold leading-none text-[var(--text-color)]">Eventos em foco</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<Tag value="Filtro ativo" severity="warning" rounded />
<small class="text-color-secondary break-all">
<div class="text-[1rem] text-[var(--text-color-secondary)] break-all">
{{ route.query.q }}
</small>
</div>
</div>
</div>
@@ -424,7 +411,7 @@ onBeforeUnmount(() => {
</div>
<!-- busca -->
<div class="mb-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -446,7 +433,6 @@ onBeforeUnmount(() => {
:loading="loading"
stripedRows
responsiveLayout="scroll"
class="events-table"
:rowHover="true"
paginator
:rows="15"
@@ -475,9 +461,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKeyFromEvent(data) }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ displayOwner(data) }}
</small>
</div>
</div>
</template>
</Column>
@@ -501,7 +487,7 @@ onBeforeUnmount(() => {
<Column field="subscription_id" header="Subscription" style="min-width: 22rem">
<template #body="{ data }">
<span class="font-mono text-sm">{{ data.subscription_id }}</span>
<span class="font-mono text-[1rem]">{{ data.subscription_id }}</span>
</template>
</Column>
@@ -527,87 +513,14 @@ onBeforeUnmount(() => {
</Column>
<template #empty>
<div class="p-4 text-color-secondary">
<div class="p-4 text-[var(--text-color-secondary)]">
Nenhum evento encontrado com os filtros atuais.
</div>
</template>
</DataTable>
<div class="text-color-secondary mt-3 text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Mostrando até 500 eventos mais recentes.
</div>
</div>
</template>
<style scoped>
.events-table :deep(.p-paginator) {
border-top: 1px solid var(--surface-border);
}
.events-table :deep(.p-datatable-tbody > tr > td) {
vertical-align: top;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Hero */
.events-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;
}
.events-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.events-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.events-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.events-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(251,191,36,0.12); }
.events-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(249,115,22,0.09); }
.events-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.events-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;
}
.events-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.events-hero__info { flex: 1; min-width: 0; }
.events-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;
}
.events-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.events-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.events-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.events-hero__actions--desktop { display: none; }
.events-hero__actions--mobile { display: flex; }
}
</style>
@@ -401,39 +401,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- 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>
<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" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="health-hero"
:class="{ 'health-hero--stuck': heroStuck }"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<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 class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</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 class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Saúde das Assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Terapeutas: divergências entre plano (esperado) e entitlements (atual). Clínicas: exceções comerciais (features liberadas manualmente fora do plano).</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="health-hero__actions health-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
label="Recarregar"
icon="pi pi-refresh"
@@ -467,7 +456,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="health-hero__actions--mobile">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -481,9 +470,9 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- busca -->
<div class="mb-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -505,7 +494,7 @@ onBeforeUnmount(() => {
<!-- Terapeutas (Personal) -->
<!-- ===================================================== -->
<TabPanel header="Terapeutas (Pessoal)">
<div class="surface-100 border-round p-3 mb-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] 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" />
@@ -514,7 +503,7 @@ onBeforeUnmount(() => {
<Tag v-if="totalPersonalWithoutOwner > 0" :value="`Sem owner: ${totalPersonalWithoutOwner}`" severity="warn" />
</div>
<div class="text-color-secondary text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
<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>
@@ -545,9 +534,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ helpForMismatch(data.mismatch_type) || '—' }}
</small>
</div>
</div>
</template>
</Column>
@@ -588,12 +577,12 @@ onBeforeUnmount(() => {
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-sm line-height-3">
<p class="mb-0">
<div class="text-[1rem] line-height-3">
<div 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>
</div>
</Message>
</TabPanel>
@@ -602,13 +591,13 @@ onBeforeUnmount(() => {
<!-- Clínicas (Tenant) -->
<!-- ===================================================== -->
<TabPanel header="Clínicas (Exceções)">
<div class="surface-100 border-round p-3 mb-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] 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">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Exceções comerciais: features liberadas manualmente fora do plano. Útil para testes, suporte e acordos.
</div>
</div>
@@ -627,7 +616,7 @@ onBeforeUnmount(() => {
<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 class="text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_name ? data.tenant_id : '—' }}</div>
</div>
</template>
</Column>
@@ -642,9 +631,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ helpForException() }}
</small>
</div>
</div>
</template>
</Column>
@@ -684,83 +673,22 @@ onBeforeUnmount(() => {
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-sm line-height-3">
<p class="mb-2">
<div class="text-[1rem] line-height-3">
<div 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>
</div>
<p class="mb-0">
<div 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>
</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>
+26 -102
View File
@@ -417,39 +417,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="subs-hero__icon-wrap">
<i class="pi pi-credit-card subs-hero__icon" />
</div>
<div class="subs-hero__sub">
Painel operacional do SaaS: revise plano, status e período (Clínica x Terapeuta) com segurança.
<template v-if="!loading">
<br />{{ totalCount }} registro(s) {{ activeCount }} ativa(s)
</template>
</div>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="subs-hero"
:class="{ 'subs-hero--stuck': heroStuck }"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="subs-hero__blob subs-hero__blob--1" />
<div class="subs-hero__blob subs-hero__blob--2" />
<div class="subs-hero__inner">
<!-- Título -->
<div class="subs-hero__info min-w-0">
<div class="subs-hero__title">Assinaturas</div>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Painel operacional do SaaS: revise plano, status e período (Clínica x Terapeuta) com segurança.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="subs-hero__actions subs-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton
v-model="typeFilter"
:options="typeOptions"
@@ -471,7 +460,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="subs-hero__actions--mobile">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -485,13 +474,13 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Header foco -->
<div v-if="isFocused" class="mb-3 p-3 surface-100 border-round">
<div v-if="isFocused" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex align-items-center justify-content-between gap-3 flex-wrap">
<div>
<div class="text-lg font-semibold">Assinatura em foco</div>
<small class="text-color-secondary">Filtro: {{ route.query.q }}</small>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Assinatura em foco</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Filtro: {{ route.query.q }}</div>
</div>
<Button
@@ -506,7 +495,7 @@ onBeforeUnmount(() => {
</div>
<!-- busca -->
<div class="mb-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -528,7 +517,6 @@ onBeforeUnmount(() => {
:loading="loading"
stripedRows
responsiveLayout="scroll"
class="subs-table"
:rowHover="true"
paginator
:rows="15"
@@ -546,9 +534,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKey(data) }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ data.tenant_id ? `tenant_id: ${data.tenant_id}` : `user_id: ${data.user_id || '—'}` }}
</small>
</div>
</div>
</template>
</Column>
@@ -585,9 +573,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div>
<div>{{ fmtDate(data.current_period_start) }}</div>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
até {{ fmtDate(data.current_period_end) }}
</small>
</div>
</div>
</template>
</Column>
@@ -642,74 +630,10 @@ onBeforeUnmount(() => {
</Column>
<template #empty>
<div class="p-4 text-color-secondary">
<div class="p-4 text-[var(--text-color-secondary)]">
Nenhuma assinatura encontrada com os filtros atuais.
</div>
</template>
</DataTable>
</div>
</template>
<style scoped>
.subs-table :deep(.p-paginator) {
border-top: 1px solid var(--surface-border);
}
.subs-table :deep(.p-datatable-tbody > tr > td) {
vertical-align: top;
}
/* Hero */
.subs-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;
}
.subs-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.subs-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.subs-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(96,165,250,0.12); }
.subs-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.subs-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.subs-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;
}
.subs-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.subs-hero__info { flex: 1; min-width: 0; }
.subs-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;
}
.subs-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.subs-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.subs-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.subs-hero__actions--desktop { display: none; }
.subs-hero__actions--mobile { display: flex; }
}
</style>
+70 -54
View File
@@ -240,17 +240,29 @@ function sessionStatusLabel (session) {
</script>
<template>
<div class="saas-support p-4 md:p-6">
<Toast />
<Toast />
<!-- Cabeçalho -->
<div class="flex items-center gap-3 mb-5">
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30">
<i class="pi pi-headphones text-orange-600 dark:text-orange-400 text-lg" />
</div>
<div class="flex-1">
<h1 class="text-xl font-bold m-0">Suporte Técnico</h1>
<p class="text-sm text-surface-500 m-0">Gere e gerencie links seguros de acesso em modo debug</p>
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex-shrink-0">
<i class="pi pi-headphones text-[var(--text-color)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Suporte Técnico</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gere e gerencie links seguros de acesso em modo debug</div>
</div>
</div>
<Tag
v-if="activeSessionCount > 0"
@@ -258,6 +270,10 @@ function sessionStatusLabel (session) {
severity="warning"
/>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Tabs -->
<TabView @tab-change="onTabChange">
@@ -267,15 +283,15 @@ function sessionStatusLabel (session) {
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-2">
<!-- Formulário -->
<div class="card">
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
<i class="pi pi-plus-circle text-primary" />
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-plus-circle text-[var(--primary-color)]" />
Configurar acesso de suporte
</h2>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Selecionar Cliente (Tenant)</label>
<label class="text-[1rem] font-medium">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
@@ -290,7 +306,7 @@ function sessionStatusLabel (session) {
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Duração do Acesso</label>
<label class="text-[1rem] font-medium">Duração do Acesso</label>
<Select
v-model="ttlMinutes"
:options="ttlOptions"
@@ -301,9 +317,9 @@ function sessionStatusLabel (session) {
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">
<label class="text-[1rem] font-medium">
Nota / Motivo
<span class="text-surface-400 font-normal">(opcional)</span>
<span class="text-[var(--text-color-secondary)] font-normal">(opcional)</span>
</label>
<InputText
v-model="sessionNote"
@@ -325,45 +341,45 @@ function sessionStatusLabel (session) {
</div>
<!-- URL Gerada -->
<div class="card">
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
<i class="pi pi-link text-primary" />
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-link text-[var(--primary-color)]" />
URL Gerada
</h2>
</div>
<div v-if="generatedUrl" class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Link de Acesso</label>
<label class="text-[1rem] font-medium">Link de Acesso</label>
<div class="flex gap-2">
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-xs" />
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-[1rem]" />
<Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(generatedUrl)" />
</div>
</div>
<div class="flex items-center gap-2 text-sm">
<div class="flex items-center gap-2 text-[1rem]">
<i class="pi pi-clock text-orange-500" />
<span class="text-surface-500">Expira em:</span>
<span class="text-[var(--text-color-secondary)]">Expira em:</span>
<strong>{{ expiresLabel }}</strong>
</div>
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)] font-mono">
<i class="pi pi-key" />
<span>{{ tokenPreview }}</span>
</div>
<div v-if="sessionNote" class="flex items-start gap-2 text-sm text-surface-500">
<div v-if="sessionNote" class="flex items-start gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
<span class="italic">{{ sessionNote }}</span>
</div>
<Message severity="info" :closable="false" class="text-sm">
Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.
<Message severity="info" :closable="false">
<div class="text-[1rem]">Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.</div>
</Message>
</div>
<div v-else class="flex flex-col items-center justify-center py-12 text-surface-400 gap-3">
<div v-else class="flex flex-col items-center justify-center py-12 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-shield text-4xl opacity-25" />
<span class="text-sm">Nenhuma sessão gerada ainda</span>
<div class="text-[1rem]">Nenhuma sessão gerada ainda</div>
</div>
</div>
</div>
@@ -378,12 +394,12 @@ function sessionStatusLabel (session) {
</span>
</template>
<div class="card mt-2">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-circle-fill text-green-500 text-xs" />
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-circle-fill text-green-500" />
Sessões em vigor
</h2>
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
@@ -405,15 +421,15 @@ function sessionStatusLabel (session) {
<Column header="Tenant" style="min-width: 200px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-sm">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-xs text-surface-400">{{ data.token.slice(0, 12) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
@@ -427,14 +443,14 @@ function sessionStatusLabel (session) {
<Column header="Criada em">
<template #body="{ data }">
<span class="text-sm text-surface-500">{{ formatDate(data.created_at) }}</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-xs italic text-surface-500">{{ data._note }}</span>
<span v-else class="text-xs text-surface-300"></span>
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
@@ -452,12 +468,12 @@ function sessionStatusLabel (session) {
<!-- Tab 2: Histórico -->
<TabPanel header="Histórico">
<div class="card mt-2">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-history text-primary" />
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-history text-[var(--primary-color)]" />
Últimas 100 sessões
</h2>
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
@@ -480,41 +496,41 @@ function sessionStatusLabel (session) {
>
<Column header="Status" style="width: 110px">
<template #body="{ data }">
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" class="text-xs" />
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" />
</template>
</Column>
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-sm">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-xs text-surface-400">{{ data.token.slice(0, 12) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Criada em" sortable field="created_at">
<template #body="{ data }">
<span class="text-sm text-surface-500">{{ formatDate(data.created_at) }}</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Expirava em">
<template #body="{ data }">
<span class="text-sm text-surface-500">{{ formatDate(data.expires_at) }}</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.expires_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-xs italic text-surface-500">{{ data._note }}</span>
<span v-else class="text-xs text-surface-300"></span>
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
+132 -209
View File
@@ -581,35 +581,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="intents-hero__icon-wrap">
<i class="pi pi-inbox intents-hero__icon" />
</div>
<div class="intents-hero__sub">
Caixa de entrada de pagamento manual (PIX/boleto). Marque como <b>pago</b> para ativar a assinatura ou
<b>cancele</b> quando o pagamento não será concluído.
</div>
</div>
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="heroSentinelRef" class="intents-hero-sentinel" />
<!-- hero -->
<div ref="heroEl" class="intents-hero mb-4" :class="{ 'intents-hero--stuck': heroStuck }">
<div class="intents-hero__blobs" aria-hidden="true">
<div class="intents-hero__blob intents-hero__blob--1" />
<div class="intents-hero__blob intents-hero__blob--2" />
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="intents-hero__inner">
<!-- Título -->
<div class="intents-hero__info min-w-0">
<div class="intents-hero__title">Intenções de assinatura</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Intenções de assinatura</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Caixa de entrada de pagamento manual (PIX/boleto). Marque como <b>pago</b> para ativar a assinatura ou <b>cancele</b> quando o pagamento não será concluído.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="intents-hero__actions intents-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
label="Atualizar"
icon="pi pi-refresh"
@@ -633,7 +626,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="intents-hero__actions--mobile">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -647,120 +640,116 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card: Resumo + Filtros -->
<Card class="mb-4">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2">
<i class="pi pi-filter text-color-secondary" />
<span>Busca & Filtros</span>
</div>
<!-- contagens -->
<div class="flex flex-wrap gap-2 items-center">
<Tag :value="`Total: ${totals.total}`" severity="secondary" rounded />
<Tag :value="`Novas: ${totals.new}`" severity="info" rounded />
<Tag :value="`Aguardando: ${totals.waiting}`" severity="warning" rounded />
<Tag :value="`Pagas: ${totals.paid}`" severity="success" rounded />
<Tag :value="`Canceladas: ${totals.canceled}`" severity="danger" rounded />
<span class="text-xs text-color-secondary">
<template v-if="lastRefreshAt">· {{ fmtDate(lastRefreshAt) }}</template>
</span>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-center justify-between gap-3 flex-wrap mb-4">
<div class="flex items-center gap-2">
<i class="pi pi-filter text-[var(--text-color-secondary)]" />
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Busca &amp; Filtros</div>
</div>
<!-- contagens -->
<div class="flex flex-wrap gap-2 items-center">
<Tag :value="`Total: ${totals.total}`" severity="secondary" rounded />
<Tag :value="`Novas: ${totals.new}`" severity="info" rounded />
<Tag :value="`Aguardando: ${totals.waiting}`" severity="warning" rounded />
<Tag :value="`Pagas: ${totals.paid}`" severity="success" rounded />
<Tag :value="`Canceladas: ${totals.canceled}`" severity="danger" rounded />
<div class="text-[1rem] text-[var(--text-color-secondary)]">
<template v-if="lastRefreshAt">· {{ fmtDate(lastRefreshAt) }}</template>
</div>
</div>
</template>
</div>
<template #content>
<div class="grid grid-cols-12 gap-3">
<!-- Busca -->
<div class="col-span-12 md:col-span-5">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="si-search"
class="w-full pr-10"
variant="filled"
:disabled="acting"
placeholder="ex.: email@dominio.com"
@keyup.enter="refresh"
/>
</IconField>
<label for="si-search">Buscar por e-mail / plano / tenant_id</label>
</FloatLabel>
</div>
<!-- Status -->
<div class="col-span-12 md:col-span-3">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
<div class="grid grid-cols-12 gap-3">
<!-- Busca -->
<div class="col-span-12 md:col-span-5">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="si-search"
class="w-full pr-10"
variant="filled"
:disabled="acting"
placeholder="ex.: email@dominio.com"
@keyup.enter="refresh"
/>
<label>Status</label>
</FloatLabel>
</div>
<!-- Intervalo -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="interval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Intervalo</label>
</FloatLabel>
</div>
<!-- Plano -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="planKey"
:options="planOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Plano</label>
</FloatLabel>
</div>
<!-- Botões -->
<div class="col-span-12 flex gap-2 flex-wrap">
<Button
label="Aplicar"
icon="pi pi-filter"
@click="refresh"
:disabled="acting"
/>
<Button
v-if="hasAnyFilter"
label="Limpar filtros"
icon="pi pi-times"
severity="secondary"
outlined
@click="clearFilters"
:disabled="acting"
/>
</div>
</IconField>
<label for="si-search">Buscar por e-mail / plano / tenant_id</label>
</FloatLabel>
</div>
</template>
</Card>
<!-- Status -->
<div class="col-span-12 md:col-span-3">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Status</label>
</FloatLabel>
</div>
<!-- Intervalo -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="interval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Intervalo</label>
</FloatLabel>
</div>
<!-- Plano -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="planKey"
:options="planOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Plano</label>
</FloatLabel>
</div>
<!-- Botões -->
<div class="col-span-12 flex gap-2 flex-wrap">
<Button
label="Aplicar"
icon="pi pi-filter"
@click="refresh"
:disabled="acting"
/>
<Button
v-if="hasAnyFilter"
label="Limpar filtros"
icon="pi pi-times"
severity="secondary"
outlined
@click="clearFilters"
:disabled="acting"
/>
</div>
</div>
</div>
<DataTable
:value="filteredRows"
@@ -768,7 +757,6 @@ onBeforeUnmount(() => {
paginator
:rows="20"
:rowsPerPageOptions="[10, 20, 50]"
class="text-sm intents-table"
responsiveLayout="scroll"
sortField="created_at"
:sortOrder="-1"
@@ -776,7 +764,7 @@ onBeforeUnmount(() => {
>
<Column field="id" header="Intent ID" style="min-width: 18rem">
<template #body="{ data }">
<span class="text-xs">{{ data.id }}</span>
<div class="text-[1rem]">{{ data.id }}</div>
</template>
</Column>
@@ -786,9 +774,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.plan_key || '—' }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ intervalLabel(data.interval) }} {{ moneyBRL(data.amount_cents) }}
</small>
</div>
</div>
</template>
</Column>
@@ -803,9 +791,9 @@ onBeforeUnmount(() => {
:value="c.label"
rounded
/>
<span v-if="!diagChips(data).length" class="text-color-secondary"></span>
<span v-if="!diagChips(data).length" class="text-[var(--text-color-secondary)]"></span>
</div>
<div v-if="data.tenant_id" class="mt-1 text-xs text-color-secondary">
<div v-if="data.tenant_id" class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
tenant_id: {{ data.tenant_id }}
</div>
</template>
@@ -862,13 +850,13 @@ onBeforeUnmount(() => {
:dismissableMask="!acting"
:draggable="false"
>
<div v-if="selected" class="text-sm">
<div v-if="selected" class="text-[1rem]">
<div class="mb-3">
<div class="font-semibold">{{ selected.email }}</div>
<div class="text-color-secondary">
<div class="text-[var(--text-color-secondary)]">
Plano: {{ selected.plan_key }} Intervalo: {{ intervalLabel(selected.interval) }} Valor: {{ moneyBRL(selected.amount_cents) }}
</div>
<div class="text-color-secondary mt-1" v-if="selected.tenant_id">
<div class="text-[var(--text-color-secondary)] mt-1" v-if="selected.tenant_id">
tenant_id: {{ selected.tenant_id }}
</div>
</div>
@@ -901,21 +889,21 @@ onBeforeUnmount(() => {
:dismissableMask="!acting"
:draggable="false"
>
<div v-if="selectedSub" class="text-sm">
<div v-if="selectedSub" class="text-[1rem]">
<div class="mb-3">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<div class="font-semibold">
{{ selectedSub.plan_key || '—' }} {{ intervalLabel(selectedSub.interval) }}
</div>
<div class="text-color-secondary mt-1">
<div class="text-[var(--text-color-secondary)] mt-1">
Período: {{ fmtDate(selectedSub.current_period_start) }} {{ fmtDate(selectedSub.current_period_end) }}
</div>
<div class="text-color-secondary mt-1">
<div class="text-[var(--text-color-secondary)] mt-1">
owner(user_id): {{ selectedSub.user_id }}
<span v-if="selectedSub.tenant_id"> tenant_id: {{ selectedSub.tenant_id }}</span>
</div>
<div class="text-color-secondary mt-1">
<div class="text-[var(--text-color-secondary)] mt-1">
subscription_id: {{ selectedSub.id }}
</div>
</div>
@@ -975,68 +963,3 @@ onBeforeUnmount(() => {
</div>
</Dialog>
</template>
<style scoped>
.intents-hero-sentinel { height: 1px; }
.intents-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;
}
.intents-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.intents-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.intents-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.intents-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(167,139,250,0.12); }
.intents-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(52,211,153,0.09); }
.intents-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.intents-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;
}
.intents-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.intents-hero__info { flex: 1; min-width: 0; }
.intents-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;
}
.intents-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.intents-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.intents-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.intents-hero__actions--desktop { display: none; }
.intents-hero__actions--mobile { display: flex; }
}
.intents-table :deep(.p-paginator) {
border-top: 1px solid var(--surface-border);
}
</style>