Layout 100%, Notificações, SetupWizard
This commit is contained in:
@@ -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 só existir anual, MRR = anual/12; se só 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 só existir anual, MRR = anual/12; se só 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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 já 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: só 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 já 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: só 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user