+ Menu Hover no Layout Rail, Twilio, Sms, Email, Templates, LNovo Layout Configurações

This commit is contained in:
Leonardo
2026-03-25 08:39:45 -03:00
parent 53a4980396
commit 3f1786c9bf
59 changed files with 2553 additions and 1106 deletions
+1 -1
View File
@@ -1270,7 +1270,7 @@ onBeforeUnmount(() => {
</div>
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 flex-shrink-0" />
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
O layout Rail exibe ícones no canto esquerdo. Ao clicar em um ícone, o painel lateral expande com os itens de navegação. Disponível apenas no desktop.
</div>
+3 -3
View File
@@ -408,7 +408,7 @@ onBeforeUnmount(() => {
title="Em breve"
class="flex items-center justify-center gap-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] opacity-50 cursor-not-allowed"
>
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
@@ -423,7 +423,7 @@ onBeforeUnmount(() => {
title="Em breve"
class="flex items-center justify-center gap-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] opacity-50 cursor-not-allowed"
>
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path
d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"
/>
@@ -461,7 +461,7 @@ onBeforeUnmount(() => {
<!-- Erro -->
<div v-if="authError" class="rounded-xl border border-red-200 bg-red-50 dark:border-red-900/30 dark:bg-red-950/20 px-4 py-3 text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
<i class="pi pi-exclamation-triangle flex-shrink-0" />
<i class="pi pi-exclamation-triangle shrink-0" />
{{ authError }}
</div>
+4 -4
View File
@@ -177,19 +177,19 @@ async function submit() {
<!-- Dicas -->
<ul class="space-y-3">
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0" />
Mínimo de 8 caracteres
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-300 flex-shrink-0" />
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-300 shrink-0" />
Combine letras maiúsculas, minúsculas e números
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-300 flex-shrink-0" />
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-300 shrink-0" />
Não reutilize a mesma senha de outros serviços
</li>
<li class="flex items-start gap-3 text-sm text-white/70">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-300 flex-shrink-0" />
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-300 shrink-0" />
Exemplo seguro: <span class="font-semibold text-white/90">"Noite#Calma7"</span>
</li>
</ul>
+5 -5
View File
@@ -271,7 +271,7 @@ async function sendResetEmail() {
<!-- Aviso -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 flex-shrink-0" />
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Ao trocar sua senha, você será desconectado de todos os dispositivos por segurança.</div>
</div>
@@ -291,19 +291,19 @@ async function sendResetEmail() {
</div>
<ul class="space-y-2">
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400 flex-shrink-0" />
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400 shrink-0" />
Use pelo menos 8 caracteres com maiúscula, minúscula e número.
</li>
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0" />
Evite datas, nomes e sequências óbvias (1234, qwerty).
</li>
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400 flex-shrink-0" />
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400 shrink-0" />
Se estiver em computador compartilhado, encerre a sessão depois.
</li>
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-400 flex-shrink-0" />
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-400 shrink-0" />
Não reutilize a mesma senha de outros serviços.
</li>
</ul>
+10 -10
View File
@@ -422,10 +422,10 @@ onMounted(fetchMeuPlanoClinic);
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-[1] flex items-center gap-3">
<div class="relative z-1 flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-credit-card text-base" />
</div>
<div class="min-w-0 hidden sm:block">
@@ -435,14 +435,14 @@ onMounted(fetchMeuPlanoClinic);
</div>
<!-- Ações desktop -->
<div class="hidden sm:flex items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" title="Atualizar" @click="fetchMeuPlanoClinic" />
<div class="hidden sm:flex items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" title="Atualizar" @click="fetchMeuPlanoClinic" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgradeClinic" />
</div>
<!-- Ações mobile -->
<div class="flex sm:hidden items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" @click="fetchMeuPlanoClinic" />
<div class="flex sm:hidden items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" @click="fetchMeuPlanoClinic" />
<Button label="Upgrade" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgradeClinic" />
</div>
</div>
@@ -480,7 +480,7 @@ onMounted(fetchMeuPlanoClinic);
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<div v-for="n in 3" :key="n" class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="w-10 h-10 rounded-full flex-shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="w-10 h-10 rounded-full shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="flex flex-col gap-2 flex-1">
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
@@ -574,7 +574,7 @@ onMounted(fetchMeuPlanoClinic);
<!-- Grid de features -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<div v-for="f in g.items" :key="f.key" class="flex items-start gap-2 py-1 px-2 rounded-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors" :title="f.description || f.key">
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 flex-shrink-0" />
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 shrink-0" />
<div class="min-w-0">
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
@@ -623,7 +623,7 @@ onMounted(fetchMeuPlanoClinic);
<pre class="m-0 text-[1rem] text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">Mostrando até 50 eventos (mais recentes).</div>
@@ -338,10 +338,10 @@ onBeforeUnmount(() => {
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-[1] flex items-center gap-3">
<div class="relative z-1 flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-credit-card text-base" />
</div>
<div class="min-w-0 hidden sm:block">
@@ -354,13 +354,13 @@ onBeforeUnmount(() => {
</div>
<!-- Ações desktop ( xl) -->
<div class="hidden xl:flex items-center gap-1 flex-shrink-0 ml-auto">
<div class="hidden xl:flex items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchMeuPlanoTherapist" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
</div>
<!-- Ações mobile (< xl) -->
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchMeuPlanoTherapist" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgrade" />
</div>
@@ -399,7 +399,7 @@ onBeforeUnmount(() => {
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<div v-for="n in 3" :key="n" class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="w-10 h-10 rounded-full flex-shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="w-10 h-10 rounded-full shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="flex flex-col gap-2 flex-1">
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
@@ -485,7 +485,7 @@ onBeforeUnmount(() => {
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<div v-for="f in g.items" :key="f.key" class="flex items-start gap-2 py-1 px-2 rounded-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors" :title="f.description || f.key">
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 flex-shrink-0" />
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 shrink-0" />
<div class="min-w-0">
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
@@ -533,7 +533,7 @@ onBeforeUnmount(() => {
<pre class="m-0 text-[1rem] text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">Mostrando até 50 eventos (mais recentes).</div>
@@ -275,12 +275,12 @@ onMounted(loadData);
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-[1] flex flex-col gap-2.5">
<div class="relative z-1 flex flex-col gap-2.5">
<!-- Linha 1: brand + busca + ações -->
<div class="flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-arrow-up-right text-base" />
</div>
<div class="min-w-0 hidden sm:block">
@@ -298,8 +298,8 @@ onMounted(loadData);
</div>
<!-- Ações -->
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="saving" title="Atualizar" @click="loadData" />
<div class="flex items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" :disabled="saving" title="Atualizar" @click="loadData" />
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="goBack" />
</div>
</div>
@@ -315,7 +315,7 @@ onMounted(loadData);
</div>
<!-- Intervalo chips -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<div class="flex items-center gap-1.5 shrink-0">
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
<button
v-for="opt in intervalOptions"
+11 -11
View File
@@ -360,12 +360,12 @@ watch(
<div class="absolute w-56 h-56 -bottom-8 right-1/4 rounded-full blur-[55px] bg-fuchsia-400/[0.07]" />
</div>
<div class="relative z-[1] flex flex-col gap-2.5">
<div class="relative z-1 flex flex-col gap-2.5">
<!-- Linha 1: brand + busca + ações -->
<div class="flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-sparkles text-base" />
</div>
<div class="min-w-0 hidden sm:block">
@@ -387,8 +387,8 @@ watch(
</div>
<!-- Ações -->
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="upgrading" title="Recarregar" @click="fetchAll" />
<div class="flex items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" :disabled="upgrading" title="Recarregar" @click="fetchAll" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined class="rounded-full hidden sm:inline-flex" :disabled="upgrading" @click="goBilling" />
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="upgrading" @click="goBack" />
</div>
@@ -405,7 +405,7 @@ watch(
</div>
<!-- Intervalo chips -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<div class="flex items-center gap-1.5 shrink-0">
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
<button
v-for="opt in intervalOptions"
@@ -431,7 +431,7 @@ watch(
-->
<Transition name="up-banner">
<div v-if="requestedFeatureLabel && !loading" class="mx-3 md:mx-4 mb-3 flex items-center gap-3 px-4 py-3 rounded-md border border-amber-300/60 bg-amber-50">
<div class="grid place-items-center w-8 h-8 rounded-md bg-amber-400/20 text-amber-600 flex-shrink-0">
<div class="grid place-items-center w-8 h-8 rounded-md bg-amber-400/20 text-amber-600 shrink-0">
<i class="pi pi-lock text-[0.95rem]" />
</div>
<div class="flex-1 min-w-0">
@@ -447,7 +447,7 @@ watch(
severity="secondary"
outlined
size="small"
class="rounded-full flex-shrink-0"
class="rounded-full shrink-0"
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
/>
</div>
@@ -524,7 +524,7 @@ watch(
</div>
<!-- Cabeçalho do card -->
<div class="relative z-[1] flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="relative z-1 flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="text-[0.9rem]" :class="String(p.key).toLowerCase() === 'pro' ? 'pi pi-sparkles text-[var(--primary-color,#6366f1)]' : 'pi pi-leaf text-emerald-500 opacity-70'" />
<span class="font-bold text-[0.95rem] text-[var(--text-color)]">Plano {{ String(p.key || '').toUpperCase() }}</span>
@@ -536,7 +536,7 @@ watch(
</div>
<!-- Corpo do card -->
<div class="relative z-[1] p-4 flex flex-col gap-4 flex-1">
<div class="relative z-1 p-4 flex flex-col gap-4 flex-1">
<!-- Descrição + preço -->
<div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mb-2">
@@ -551,7 +551,7 @@ watch(
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] p-3">
<ul class="list-none p-0 m-0 flex flex-col gap-2.5">
<li v-for="(b, i) in planBenefits(p.id)" :key="i" class="flex items-start gap-2">
<i class="text-[1rem] mt-0.5 flex-shrink-0" :class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle text-[var(--text-color-secondary)] opacity-40'" />
<i class="text-[1rem] mt-0.5 shrink-0" :class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle text-[var(--text-color-secondary)] opacity-40'" />
<span class="text-[1rem]" :class="b.ok ? 'text-[var(--text-color)]' : 'text-[var(--text-color-secondary)]'">{{ b.text }}</span>
</li>
</ul>
@@ -264,7 +264,7 @@ onMounted(init);
<div class="flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center gap-4">
<div
class="grid place-items-center w-12 h-12 rounded-full flex-shrink-0"
class="grid place-items-center w-12 h-12 rounded-full shrink-0"
:style="{ background: `color-mix(in srgb, ${statusInfo.color} 15%, transparent)` }"
>
<i :class="statusInfo.icon" class="text-xl" :style="{ color: statusInfo.color }" />
+2 -2
View File
@@ -509,7 +509,7 @@ function mediasCount(doc) {
</div>
<div class="relative z-10 flex flex-wrap items-center justify-between 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">
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center shrink-0">
<i class="pi pi-question-circle text-xl text-blue-500" />
</div>
<div>
@@ -964,7 +964,7 @@ function mediasCount(doc) {
<Button icon="pi pi-upload" label="Enviar imagem" severity="secondary" outlined :loading="uploadingIdx.has(idx)" class="w-full" @click="triggerUpload(idx)" />
</div>
<div v-else class="flex items-center gap-2 flex-1 min-w-0">
<img :src="m.url" class="w-14 h-14 object-cover rounded-md border border-[var(--surface-border)] flex-shrink-0" :alt="`Imagem ${idx + 1}`" />
<img :src="m.url" class="w-14 h-14 object-cover rounded-md border border-[var(--surface-border)] shrink-0" :alt="`Imagem ${idx + 1}`" />
<div class="flex flex-col gap-1 flex-1 min-w-0">
<span class="text-[1rem] font-mono truncate text-[var(--text-color-secondary)]">{{ m.url }}</span>
<Button icon="pi pi-refresh" label="Trocar imagem" text size="small" severity="secondary" :loading="uploadingIdx.has(idx)" @click="triggerUpload(idx)" />
+4 -4
View File
@@ -136,7 +136,7 @@ function selecionarCat(cat) {
</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">
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center shrink-0">
<i class="pi pi-comments text-xl text-[var(--text-color)]" />
</div>
<div>
@@ -169,7 +169,7 @@ function selecionarCat(cat) {
<template v-else>
<div class="flex gap-4 items-start flex-col sm:flex-row">
<!-- Sidebar de categorias -->
<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">
<aside v-if="categorias.length" class="w-full sm:w-48 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="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)]"
@@ -218,7 +218,7 @@ function selecionarCat(cat) {
<div v-for="doc in docsComResultado" :key="doc.id" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Cabeçalho do grupo (doc) -->
<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">
<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 shrink-0">
<i class="pi pi-file-edit" />
</div>
<div class="flex-1 min-w-0">
@@ -226,7 +226,7 @@ function selecionarCat(cat) {
<div v-if="doc.categoria" class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-px">{{ doc.categoria }}</div>
</div>
<button
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)]"
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 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)"
>
+4 -4
View File
@@ -217,7 +217,7 @@ onMounted(load);
<!-- 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="w-10 h-10 rounded-md bg-[var(--surface-ground)] 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)]" />
@@ -248,7 +248,7 @@ onMounted(load);
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="relative 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>
@@ -356,7 +356,7 @@ onMounted(load);
<!-- 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" />
<i class="pi pi-info-circle text-indigo-500 mt-px 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>
@@ -469,7 +469,7 @@ create policy "public_read" on public.login_carousel_slides
<!-- 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">
<div class="grid h-12 w-12 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">
+2 -2
View File
@@ -262,7 +262,7 @@ function sessionStatusLabel(session) {
</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">
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] shrink-0">
<i class="pi pi-headphones text-[var(--text-color)]" />
</div>
<div class="min-w-0">
@@ -349,7 +349,7 @@ function sessionStatusLabel(session) {
</div>
<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" />
<i class="pi pi-comment mt-0.5 shrink-0" />
<span class="italic">{{ sessionNote }}</span>
</div>
@@ -0,0 +1,864 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/views/pages/saas/SaasTwilioWhatsappPage.vue
| Data: 2026
|--------------------------------------------------------------------------
| Painel SaaS Admin Gerenciamento de subcontas Twilio WhatsApp.
| Aba 1 Visão geral: todos os tenants provisionados + KPIs do mês
| Aba 2 Provisionar: criar subconta para um tenant
| Aba 3 Consumo: relatório de uso e margem por período
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useTwilioWhatsappStore } from '@/stores/twilioWhatsappStore';
import { supabase } from '@/lib/supabase/client';
const toast = useToast();
const confirm = useConfirm();
const store = useTwilioWhatsappStore();
// ── Card de setup ──────────────────────────────────────────────────────────
const showSetupCard = ref(true);
// ── Tabs ───────────────────────────────────────────────────────────────────
const activeTab = ref(0);
// ── Tenants ────────────────────────────────────────────────────────────────
const tenants = ref([]);
const tenantMap = ref({});
const loadingTenants = ref(false);
async function loadTenants() {
loadingTenants.value = true;
try {
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name');
if (error) throw error;
tenantMap.value = Object.fromEntries((data ?? []).map(t => [t.id, t.name || t.id]));
tenants.value = (data ?? []).map(t => ({ value: t.id, label: `${t.name} (${t.kind ?? 'tenant'})` }));
} finally {
loadingTenants.value = false;
}
}
// ── Provisionar ────────────────────────────────────────────────────────────
const provisionForm = ref({
tenant_id: null,
phone_number: '',
country: 'BR',
display_name: '',
cost_per_message_usd: '0.005',
price_per_message_brl: '',
});
const showNumberSearch = ref(false);
function calcDefaultPrice() {
const cost = parseFloat(provisionForm.value.cost_per_message_usd) || 0;
const usdBrl = 5.5;
const margin = 1.4;
provisionForm.value.price_per_message_brl = (cost * usdBrl * margin).toFixed(4);
}
async function doProvision() {
if (!provisionForm.value.tenant_id) {
toast.add({ severity: 'warn', summary: 'Selecione um tenant', life: 3000 });
return;
}
try {
const opts = {
country: provisionForm.value.country,
display_name: provisionForm.value.display_name || undefined,
cost_per_message_usd: provisionForm.value.cost_per_message_usd,
price_per_message_brl: provisionForm.value.price_per_message_brl,
};
if (provisionForm.value.phone_number) opts.phone_number = provisionForm.value.phone_number;
const result = await store.provision(provisionForm.value.tenant_id, opts);
toast.add({
severity: 'success',
summary: 'Subconta provisionada',
detail: result.message,
life: 5000,
});
activeTab.value = 0;
resetProvisionForm();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao provisionar', detail: e.message, life: 6000 });
}
}
function resetProvisionForm() {
provisionForm.value = {
tenant_id: null, phone_number: '', country: 'BR',
display_name: '', cost_per_message_usd: '0.005', price_per_message_brl: '',
};
}
async function searchNumbers() {
showNumberSearch.value = true;
try {
await store.loadAvailableNumbers(provisionForm.value.country);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao buscar números', detail: e.message, life: 4000 });
}
}
function selectNumber(num) {
provisionForm.value.phone_number = num.phone_number;
showNumberSearch.value = false;
}
// ── Ações sobre canais existentes ─────────────────────────────────────────
function confirmSuspend(ch) {
confirm.require({
message: `Suspender WhatsApp de "${tenantMap.value[ch.tenant_id] || ch.tenant_id}"? O tenant não poderá mais enviar mensagens.`,
header: 'Suspender subconta',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-warning',
accept: async () => {
try {
await store.suspend(ch.channel_id);
toast.add({ severity: 'success', summary: 'Subconta suspensa', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
},
});
}
function confirmReactivate(ch) {
confirm.require({
message: `Reativar WhatsApp de "${tenantMap.value[ch.tenant_id] || ch.tenant_id}"?`,
header: 'Reativar subconta',
icon: 'pi pi-check-circle',
accept: async () => {
try {
await store.reactivate(ch.channel_id);
toast.add({ severity: 'success', summary: 'Subconta reativada', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
},
});
}
function confirmDeprovision(ch) {
confirm.require({
message: `ATENÇÃO: Isso encerrará permanentemente a subconta Twilio de "${tenantMap.value[ch.tenant_id] || ch.tenant_id}" e liberará o número. Esta ação não pode ser desfeita.`,
header: 'Encerrar subconta',
icon: 'pi pi-times-circle',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await store.deprovision(ch.channel_id);
toast.add({ severity: 'success', summary: 'Subconta encerrada', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
},
});
}
// ── Teste de envio ─────────────────────────────────────────────────────────
const testDialog = ref(false);
const testChannel = ref(null);
const testToNumber = ref('');
const testMessage = ref('Mensagem de teste — AgenciaPsi ✓');
function openTestDialog(ch) {
testChannel.value = ch;
testToNumber.value = '';
testDialog.value = true;
}
async function doTestSend() {
if (!testToNumber.value) {
toast.add({ severity: 'warn', summary: 'Informe o número destino', life: 3000 });
return;
}
try {
const result = await store.testSendMessage(testChannel.value.channel_id, testToNumber.value, testMessage.value);
toast.add({
severity: 'success',
summary: 'Mensagem enviada',
detail: `SID: ${result.message_sid}`,
life: 5000,
});
testDialog.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro no envio', detail: e.message, life: 5000 });
}
}
// ── Precificação inline ────────────────────────────────────────────────────
const editingPricingId = ref(null);
const editPricing = ref({ cost_per_message_usd: '', price_per_message_brl: '' });
function startEditPricing(ch) {
editingPricingId.value = ch.channel_id;
editPricing.value = {
cost_per_message_usd: ch.cost_per_message_usd ?? '',
price_per_message_brl: ch.price_per_message_brl ?? '',
};
}
async function savePricing(ch) {
try {
await store.updatePricing(ch.channel_id, {
cost_per_message_usd: parseFloat(editPricing.value.cost_per_message_usd) || 0,
price_per_message_brl: parseFloat(editPricing.value.price_per_message_brl) || 0,
});
editingPricingId.value = null;
toast.add({ severity: 'success', summary: 'Precificação atualizada', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
// ── Consumo ────────────────────────────────────────────────────────────────
async function loadUsage() {
try {
await store.loadUsageReport({ months: 3 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar consumo', detail: e.message, life: 4000 });
}
}
async function syncAll() {
try {
const result = await store.syncUsageAll();
toast.add({ severity: 'success', summary: 'Consumo sincronizado', detail: `${result.synced?.length ?? 0} canal(is) atualizados`, life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
function statusTag(ch) {
if (!ch.twilio_subaccount_sid) return { label: 'Não provisionado', severity: 'secondary' };
if (!ch.is_active) return { label: 'Suspenso', severity: 'warn' };
switch (ch.connection_status) {
case 'connected': return { label: 'Ativo', severity: 'success' };
case 'disconnected': return { label: 'Desconectado', severity: 'danger' };
case 'error': return { label: 'Erro', severity: 'danger' };
default: return { label: 'Ativo', severity: 'success' };
}
}
function fmtBrl(v) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v ?? 0);
}
function fmtUsd(v) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v ?? 0);
}
function fmtDate(dt) {
if (!dt) return '—';
return new Date(dt).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function fmtPeriod(dt) {
if (!dt) return '—';
return new Date(dt + 'T12:00:00').toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' });
}
const countryCodes = [
{ label: 'Brasil (+55)', value: 'BR' },
{ label: 'EUA (+1)', value: 'US' },
{ label: 'Portugal (+351)', value: 'PT' },
];
// ── Init ───────────────────────────────────────────────────────────────────
onMounted(async () => {
await Promise.all([
store.loadAllChannels(),
loadTenants(),
]);
});
</script>
<template>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-whatsapp" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">WhatsApp Twilio Subcontas</div>
<div class="cfg-subheader__sub">Provisione e gerencie subcontas Twilio com número WhatsApp dedicado por tenant</div>
</div>
</div>
<!-- Card de configuração inicial -->
<div class="setup-card" v-if="showSetupCard">
<!-- Faixa topo colorida -->
<div class="setup-card__stripe" />
<div class="setup-card__body">
<!-- Título + fechar -->
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3">
<div class="setup-card__icon">
<i class="pi pi-key text-xl" />
</div>
<div>
<div class="setup-card__title">Configuração de variáveis de ambiente</div>
<div class="setup-card__sub">Obrigatório para que as Edge Functions Twilio funcionem</div>
</div>
</div>
<Button icon="pi pi-times" text rounded size="small" severity="secondary" class="shrink-0 mt-0.5" v-tooltip.left="'Ocultar'" @click="showSetupCard = false" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<!-- Bloco local -->
<div class="setup-block">
<div class="setup-block__header">
<i class="pi pi-desktop text-sm" />
<span>Ambiente local <code class="setup-code-inline">.env</code> na raiz do projeto</span>
</div>
<pre class="setup-pre">TWILIO_ACCOUNT_SID=<span class="setup-pre__placeholder">ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</span>
TWILIO_AUTH_TOKEN=<span class="setup-pre__placeholder">xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</span>
TWILIO_WHATSAPP_WEBHOOK=<span class="setup-pre__placeholder">https://&lt;project&gt;.supabase.co/functions/v1/twilio-whatsapp-webhook</span>
USD_BRL_RATE=<span class="setup-pre__placeholder">5.50</span>
MARGIN_MULTIPLIER=<span class="setup-pre__placeholder">1.40</span></pre>
<div class="setup-block__tip">
<i class="pi pi-info-circle text-xs" />
O <code class="setup-code-inline">supabase/config.toml</code> referencia essas vars via <code class="setup-code-inline">env()</code>.
</div>
</div>
<!-- Bloco produção -->
<div class="setup-block setup-block--prod">
<div class="setup-block__header">
<i class="pi pi-cloud text-sm" />
<span>Produção Supabase Secrets</span>
</div>
<pre class="setup-pre">supabase secrets set \
TWILIO_ACCOUNT_SID=<span class="setup-pre__placeholder">ACxxx</span> \
TWILIO_AUTH_TOKEN=<span class="setup-pre__placeholder">xxx</span> \
TWILIO_WHATSAPP_WEBHOOK=<span class="setup-pre__placeholder">https://...</span> \
USD_BRL_RATE=5.50 \
MARGIN_MULTIPLIER=1.40</pre>
<div class="setup-block__tip">
<i class="pi pi-info-circle text-xs" />
Após setar, faça deploy das 3 Edge Functions:
<code class="setup-code-inline">twilio-whatsapp-provision</code>,
<code class="setup-code-inline">process-whatsapp-queue</code>,
<code class="setup-code-inline">twilio-whatsapp-webhook</code>.
</div>
</div>
</div>
<!-- Canais no menu -->
<div class="setup-menu-info mt-3">
<i class="pi pi-sitemap text-sm shrink-0" />
<div class="text-sm">
<strong>Menu SaaS Canais:</strong>
<span class="mx-1 text-[var(--text-color-secondary)]">WhatsApp (Evolution API)</span>
<i class="pi pi-arrow-right text-xs opacity-40 mx-1" />
<span class="font-semibold text-green-600">/saas/whatsapp</span>
<span class="mx-2 opacity-30">|</span>
<span class="mx-1 text-[var(--text-color-secondary)]">WhatsApp Twilio (Subcontas)</span>
<i class="pi pi-arrow-right text-xs opacity-40 mx-1" />
<span class="font-semibold text-green-600">/saas/twilio-whatsapp</span>
<span class="ml-2 text-xs text-[var(--text-color-secondary)]">(esta página)</span>
</div>
</div>
</div>
</div>
<!-- KPIs do mês -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="kpi-card">
<div class="kpi-label">Subcontas ativas</div>
<div class="kpi-value text-green-600">{{ store.activeCount }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Suspensas</div>
<div class="kpi-value text-orange-500">{{ store.suspendedCount }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Custo real (mês)</div>
<div class="kpi-value text-red-500">{{ fmtBrl(store.totalMonthlyCostBrl) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Margem bruta (mês)</div>
<div class="kpi-value text-emerald-600">{{ fmtBrl(store.totalMonthlyMarginBrl) }}</div>
</div>
</div>
<!-- Tabs -->
<Tabs :value="activeTab" @update:value="activeTab = $event">
<TabList>
<Tab :value="0"><i class="pi pi-list mr-2" />Visão geral</Tab>
<Tab :value="1"><i class="pi pi-plus-circle mr-2" />Provisionar</Tab>
<Tab :value="2" @click="loadUsage"><i class="pi pi-chart-bar mr-2" />Consumo</Tab>
</TabList>
<TabPanels>
<!-- ABA 1 Visão geral -->
<TabPanel :value="0">
<div class="flex flex-col gap-3 pt-3">
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">
{{ store.allChannels.length }} subconta(s) Twilio WhatsApp
</span>
<div class="ml-auto flex gap-2">
<Button icon="pi pi-sync" size="small" severity="secondary" outlined label="Sincronizar uso" :loading="store.syncingUsage" @click="syncAll" />
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="store.loadingChannels" @click="store.loadAllChannels" />
</div>
</div>
<DataTable :value="store.allChannels" :loading="store.loadingChannels" striped-rows responsive-layout="scroll" class="text-sm">
<!-- Tenant -->
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ tenantMap[data.tenant_id] || '—' }}</span>
<span class="text-xs text-[var(--text-color-secondary)] font-mono">{{ data.tenant_id?.slice(0, 8) }}</span>
</div>
</template>
</Column>
<!-- Número -->
<Column header="Número WhatsApp" style="min-width: 150px">
<template #body="{ data }">
<span v-if="data.twilio_phone_number" class="font-mono text-sm font-semibold">
{{ data.twilio_phone_number }}
</span>
<span v-else class="text-[var(--text-color-secondary)] text-xs"></span>
</template>
</Column>
<!-- Status -->
<Column header="Status" style="min-width: 110px">
<template #body="{ data }">
<Tag :value="statusTag(data).label" :severity="statusTag(data).severity" class="text-[0.7rem]" />
</template>
</Column>
<!-- Consumo mês -->
<Column header="Msgs (mês)" style="min-width: 90px; text-align: right">
<template #body="{ data }">
<div class="text-right">
<span class="font-semibold">{{ data.current_month_sent ?? 0 }}</span>
<span class="text-xs text-[var(--text-color-secondary)] ml-1">
/ {{ data.current_month_delivered ?? 0 }} entregues
</span>
</div>
</template>
</Column>
<!-- Custo -->
<Column header="Custo real" style="min-width: 100px; text-align: right">
<template #body="{ data }">
<span class="text-red-500 font-mono text-xs">
{{ fmtBrl(data.current_month_cost_brl) }}
</span>
</template>
</Column>
<!-- Receita / Margem -->
<Column header="Margem" style="min-width: 110px; text-align: right">
<template #body="{ data }">
<div class="text-right">
<span class="text-emerald-600 font-mono text-xs font-semibold">
{{ fmtBrl(data.current_month_margin_brl) }}
</span>
<div class="text-[0.65rem] text-[var(--text-color-secondary)]">
receita {{ fmtBrl(data.current_month_revenue_brl) }}
</div>
</div>
</template>
</Column>
<!-- Precificação inline -->
<Column header="Preço/msg" style="min-width: 150px">
<template #body="{ data }">
<div v-if="editingPricingId === data.channel_id" class="flex gap-1 items-center">
<InputNumber v-model="editPricing.price_per_message_brl" mode="currency" currency="BRL" locale="pt-BR" :min-fraction-digits="4" :max-fraction-digits="4" class="w-24 text-xs" input-class="text-xs p-1" />
<Button icon="pi pi-check" text rounded size="small" severity="success" @click="savePricing(data)" />
<Button icon="pi pi-times" text rounded size="small" severity="secondary" @click="editingPricingId = null" />
</div>
<div v-else class="flex items-center gap-1 cursor-pointer group" @click="startEditPricing(data)">
<span class="font-mono text-xs">{{ fmtBrl(data.price_per_message_brl) }}</span>
<i class="pi pi-pencil text-[0.6rem] opacity-0 group-hover:opacity-50" />
</div>
</template>
</Column>
<!-- Provisionado em -->
<Column header="Provisionado" style="min-width: 110px">
<template #body="{ data }">
<span class="text-xs">{{ fmtDate(data.provisioned_at) }}</span>
</template>
</Column>
<!-- Ações -->
<Column header="" style="width: 130px">
<template #body="{ data }">
<div class="flex gap-1 justify-end">
<Button
icon="pi pi-send"
text rounded size="small" severity="info"
v-tooltip.left="'Testar envio'"
:disabled="!data.twilio_subaccount_sid || !data.is_active"
@click="openTestDialog(data)"
/>
<Button
v-if="data.is_active"
icon="pi pi-pause"
text rounded size="small" severity="warning"
v-tooltip.left="'Suspender'"
:loading="store.suspending"
@click="confirmSuspend(data)"
/>
<Button
v-else-if="data.twilio_subaccount_sid"
icon="pi pi-play"
text rounded size="small" severity="success"
v-tooltip.left="'Reativar'"
:loading="store.reactivating"
@click="confirmReactivate(data)"
/>
<Button
icon="pi pi-trash"
text rounded size="small" severity="danger"
v-tooltip.left="'Encerrar subconta'"
:disabled="!data.twilio_subaccount_sid"
:loading="store.deprovisioning"
@click="confirmDeprovision(data)"
/>
</div>
</template>
</Column>
<template #empty>
<div class="text-center py-8 text-sm text-[var(--text-color-secondary)]">
Nenhuma subconta Twilio WhatsApp provisionada ainda.
</div>
</template>
</DataTable>
</div>
</TabPanel>
<!-- ABA 2 Provisionar -->
<TabPanel :value="1">
<div class="flex flex-col gap-4 pt-3 max-w-2xl">
<Message severity="info" :closable="false">
<template #messageicon><i class="pi pi-info-circle" /></template>
Cada tenant recebe uma subconta Twilio dedicada com número WhatsApp exclusivo.
O número precisará ser aprovado pelo WhatsApp Business via Twilio (pode levar alguns dias para números novos).
</Message>
<!-- Tenant -->
<div class="field-group">
<label class="field-label">Tenant *</label>
<Select
v-model="provisionForm.tenant_id"
:options="tenants" option-label="label" option-value="value"
placeholder="Selecione o tenant..." filter
:loading="loadingTenants"
class="w-full"
/>
</div>
<!-- Nome exibição -->
<div class="field-group">
<label class="field-label">Nome de exibição</label>
<InputText v-model="provisionForm.display_name" placeholder="Ex: WhatsApp — Clínica Bem Estar" class="w-full" />
</div>
<!-- País + número -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="field-group">
<label class="field-label">País *</label>
<Select v-model="provisionForm.country" :options="countryCodes" option-label="label" option-value="value" class="w-full" />
</div>
<div class="field-group">
<label class="field-label">Número específico</label>
<div class="flex gap-2">
<InputText v-model="provisionForm.phone_number" placeholder="+5511..." class="flex-1" />
<Button icon="pi pi-search" size="small" severity="secondary" outlined v-tooltip.top="'Buscar disponíveis'" :loading="store.loadingNumbers" @click="searchNumbers" />
</div>
<small class="text-[var(--text-color-secondary)]">Deixe em branco para provisionar automaticamente.</small>
</div>
</div>
<!-- Resultados da busca de números -->
<div v-if="showNumberSearch && store.availableNumbers.length" class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-[var(--surface-ground)] flex items-center justify-between">
<span class="text-xs font-semibold">Números disponíveis</span>
<Button icon="pi pi-times" text rounded size="small" @click="showNumberSearch = false" />
</div>
<div class="max-h-48 overflow-y-auto divide-y divide-[var(--surface-border)]">
<div
v-for="num in store.availableNumbers" :key="num.phone_number"
class="px-3 py-2 flex items-center justify-between hover:bg-[var(--surface-ground)] cursor-pointer"
@click="selectNumber(num)"
>
<span class="font-mono text-sm font-semibold">{{ num.phone_number }}</span>
<span class="text-xs text-[var(--text-color-secondary)]">{{ num.locality }}, {{ num.region }}</span>
</div>
</div>
</div>
<!-- Precificação -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="px-4 py-3 bg-[var(--surface-ground)] flex items-center gap-2">
<i class="pi pi-dollar text-sm opacity-60" />
<span class="text-sm font-semibold">Precificação por mensagem</span>
</div>
<div class="px-4 py-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="field-group">
<label class="field-label">Custo Twilio (USD)</label>
<InputText
v-model="provisionForm.cost_per_message_usd"
placeholder="0.005"
class="w-full"
@blur="calcDefaultPrice"
/>
<small class="text-[var(--text-color-secondary)]">Custo real da Twilio por mensagem.</small>
</div>
<div class="field-group">
<label class="field-label">Preço cobrado do tenant (BRL)</label>
<InputText v-model="provisionForm.price_per_message_brl" placeholder="0.0385" class="w-full" />
<small class="text-[var(--text-color-secondary)]">Inclua sua margem (padrão: custo × câmbio × 1,4).</small>
</div>
</div>
</div>
<div class="flex justify-end gap-2">
<Button label="Limpar" severity="secondary" outlined @click="resetProvisionForm" />
<Button
label="Provisionar subconta"
icon="pi pi-plus"
:loading="store.provisioning"
:disabled="!provisionForm.tenant_id || store.provisioning"
@click="doProvision"
/>
</div>
</div>
</TabPanel>
<!-- ABA 3 Consumo -->
<TabPanel :value="2">
<div class="flex flex-col gap-3 pt-3">
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">Últimos 3 meses por subconta</span>
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="store.loadingUsage" class="ml-auto" @click="loadUsage" />
</div>
<DataTable :value="store.usageReport" :loading="store.loadingUsage" striped-rows responsive-layout="scroll" class="text-sm">
<Column header="Tenant" style="min-width: 160px">
<template #body="{ data }">{{ tenantMap[data.tenant_id] || data.tenant_id?.slice(0, 8) }}</template>
</Column>
<Column header="Período" style="min-width: 100px">
<template #body="{ data }">{{ fmtPeriod(data.period_start) }}</template>
</Column>
<Column header="Enviadas" field="messages_sent" style="min-width: 90px; text-align: right">
<template #body="{ data }"><span class="font-semibold">{{ data.messages_sent }}</span></template>
</Column>
<Column header="Entregues" field="messages_delivered" style="min-width: 90px; text-align: right">
<template #body="{ data }"><span class="text-green-600">{{ data.messages_delivered }}</span></template>
</Column>
<Column header="Falhas" field="messages_failed" style="min-width: 80px; text-align: right">
<template #body="{ data }"><span class="text-red-500">{{ data.messages_failed }}</span></template>
</Column>
<Column header="Custo (BRL)" style="min-width: 110px; text-align: right">
<template #body="{ data }"><span class="text-red-500 font-mono text-xs">{{ fmtBrl(data.cost_brl) }}</span></template>
</Column>
<Column header="Receita (BRL)" style="min-width: 110px; text-align: right">
<template #body="{ data }"><span class="text-blue-600 font-mono text-xs">{{ fmtBrl(data.revenue_brl) }}</span></template>
</Column>
<Column header="Margem" style="min-width: 110px; text-align: right">
<template #body="{ data }">
<span class="font-semibold font-mono text-xs" :class="data.margin_brl >= 0 ? 'text-emerald-600' : 'text-red-500'">
{{ fmtBrl(data.margin_brl) }}
</span>
</template>
</Column>
<template #empty>
<div class="text-center py-8 text-sm text-[var(--text-color-secondary)]">
Nenhum dado de consumo. Clique em "Sincronizar uso" na aba Visão geral.
</div>
</template>
</DataTable>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Dialog: Teste de envio -->
<Dialog v-model:visible="testDialog" header="Testar envio WhatsApp" modal :style="{ width: '420px', maxWidth: '96vw' }" :draggable="false">
<div class="flex flex-col gap-4 py-2">
<div v-if="testChannel" class="text-sm text-[var(--text-color-secondary)]">
Enviando de <strong class="font-mono">{{ testChannel.twilio_phone_number }}</strong>
({{ tenantMap[testChannel.tenant_id] || testChannel.tenant_id?.slice(0, 8) }})
</div>
<div class="field-group">
<label class="field-label">Número destino (E.164)</label>
<InputText v-model="testToNumber" placeholder="+5511999990000" class="w-full" />
</div>
<div class="field-group">
<label class="field-label">Mensagem</label>
<Textarea v-model="testMessage" rows="3" class="w-full" auto-resize />
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="testDialog = false" />
<Button label="Enviar" icon="pi pi-send" :loading="store.testingSend" @click="doTestSend" />
</template>
</Dialog>
<ConfirmDialog />
<Toast />
</div>
</template>
<style scoped>
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: '';
position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem;
border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem; font-weight: 700;
letter-spacing: -0.01em; color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85;
}
.kpi-card {
padding: 0.875rem 1rem;
border: 1px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
}
.kpi-label {
font-size: 0.7rem; color: var(--text-color-secondary);
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px;
}
.kpi-value {
font-size: 1.4rem; font-weight: 700; line-height: 1;
}
.field-group {
display: flex; flex-direction: column; gap: 0.375rem;
}
.field-label {
font-size: 0.75rem; font-weight: 600;
}
/* ── Card de Setup ──────────────────────────────────────────────────────── */
.setup-card {
border-radius: 10px;
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
background: color-mix(in srgb, #fbbf24 6%, var(--surface-card));
overflow: hidden;
position: relative;
box-shadow: 0 2px 12px color-mix(in srgb, #f59e0b 12%, transparent);
}
.setup-card__stripe {
height: 4px;
background: linear-gradient(90deg, #f59e0b 0%, #f97316 40%, #ef4444 100%);
}
.setup-card__body {
padding: 1rem 1.25rem 1.25rem;
}
.setup-card__icon {
display: grid; place-items: center;
width: 2.75rem; height: 2.75rem; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #f59e0b, #f97316);
color: #fff;
box-shadow: 0 2px 8px color-mix(in srgb, #f59e0b 40%, transparent);
}
.setup-card__title {
font-size: 0.95rem; font-weight: 700; color: #92400e;
}
.setup-card__sub {
font-size: 0.75rem; color: #b45309; margin-top: 2px;
}
.setup-block {
background: color-mix(in srgb, #000 4%, var(--surface-card));
border: 1px solid color-mix(in srgb, #f59e0b 25%, transparent);
border-radius: 8px; overflow: hidden;
}
.setup-block--prod {
border-color: color-mix(in srgb, #6366f1 30%, transparent);
background: color-mix(in srgb, #6366f1 4%, var(--surface-card));
}
.setup-block__header {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.875rem;
background: color-mix(in srgb, #f59e0b 10%, var(--surface-ground));
font-size: 0.75rem; font-weight: 600; color: #92400e;
border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, transparent);
}
.setup-block--prod .setup-block__header {
background: color-mix(in srgb, #6366f1 10%, var(--surface-ground));
color: #4338ca;
border-color: color-mix(in srgb, #6366f1 20%, transparent);
}
.setup-pre {
margin: 0; padding: 0.75rem 0.875rem;
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
font-size: 0.72rem; line-height: 1.7;
color: var(--text-color);
background: transparent;
white-space: pre-wrap; word-break: break-all;
}
.setup-pre__placeholder {
color: #9ca3af; font-style: italic;
}
.setup-block__tip {
display: flex; align-items: flex-start; gap: 0.4rem;
padding: 0.5rem 0.875rem;
font-size: 0.72rem; color: var(--text-color-secondary);
border-top: 1px dashed color-mix(in srgb, #f59e0b 20%, transparent);
background: color-mix(in srgb, #000 2%, transparent);
}
.setup-code-inline {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem; font-style: normal;
background: color-mix(in srgb, #6366f1 10%, var(--surface-ground));
color: #4338ca;
padding: 0.1em 0.35em; border-radius: 3px;
border: 1px solid color-mix(in srgb, #6366f1 20%, transparent);
}
.setup-menu-info {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.625rem 0.875rem;
background: color-mix(in srgb, #22c55e 6%, var(--surface-card));
border: 1px solid color-mix(in srgb, #22c55e 25%, transparent);
border-radius: 7px; flex-wrap: wrap;
}
</style>
+5 -5
View File
@@ -253,10 +253,10 @@ onMounted(loadSessions);
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-[1] flex items-center gap-3 flex-wrap">
<div class="relative z-1 flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-chart-bar text-base" />
</div>
<div class="min-w-0 hidden lg:block">
@@ -271,13 +271,13 @@ onMounted(loadSessions);
</div>
<!-- Refresh -->
<div class="flex items-center gap-1.5 flex-shrink-0 ml-auto">
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="loadSessions" />
</div>
</div>
<!-- Seletor de período mobile (abaixo da linha principal) -->
<div class="xl:hidden relative z-[1] mt-2.5 flex flex-wrap gap-1.5">
<div class="xl:hidden relative z-1 mt-2.5 flex flex-wrap gap-1.5">
<button
v-for="p in PERIODS"
:key="p.value"
@@ -34,7 +34,7 @@ let _heroObserver = null;
const agora = ref(new Date());
const asideOpen = ref(false);
const { effectiveVariant, layoutState, layoutConfig, isMobile } = useLayout();
const { effectiveVariant, layoutState, layoutConfig, isMobile, railPanelPushesLayout } = useLayout();
// ≤ xl: aside é drawer mobile, não usa left fixo
const isMobileLayout = computed(() => isMobile.value);
// left do aside fixo:
@@ -48,7 +48,7 @@ const asideLeft = computed(() => {
const isStaticActive = layoutConfig.menuMode === 'static' && !layoutState.staticMenuInactive;
return isStaticActive ? '20rem' : '0';
}
return layoutState.railPanelOpen ? 'calc(60px + 260px)' : '60px';
return railPanelPushesLayout.value ? 'calc(60px + 260px)' : '60px';
});
let timer = null;
onBeforeUnmount(() => {
@@ -781,7 +781,7 @@ onMounted(async () => {
-->
<div v-if="asideOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-[39] xl:hidden" @click="asideOpen = false" />
<aside class="aside-drawer flex flex-col overflow-y-auto flex-shrink-0 bg-[var(--surface-card,#fff)] border-r border-[var(--surface-border,#e2e8f0)]" :class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'" :style="{ left: asideLeft }">
<aside class="aside-drawer flex flex-col overflow-y-auto shrink-0 bg-[var(--surface-card,#fff)] border-r border-[var(--surface-border,#e2e8f0)]" :class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'" :style="{ left: asideLeft }">
<!-- Mini calendário -->
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
@@ -871,7 +871,7 @@ onMounted(async () => {
<span class="aside-ev__hora">{{ ev.hora }}</span>
<span class="aside-ev__dur">{{ ev.dur }}</span>
</div>
<Avatar :label="initials(ev.nome)" shape="square" size="small" class="flex-shrink-0" />
<Avatar :label="initials(ev.nome)" shape="square" size="small" class="shrink-0" />
<div class="flex-1 min-w-0">
<span class="block text-[0.8125rem] font-semibold truncate text-[var(--text-color)]">{{ ev.nome }}</span>
<div class="flex gap-1.5 mt-0.5 items-center">
@@ -879,7 +879,7 @@ onMounted(async () => {
<i v-if="ev.recorrente" class="pi pi-sync text-[0.6rem] text-[var(--primary-color,#6366f1)]" title="Recorrente" />
</div>
</div>
<i :class="ev.statusIcon" class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" />
<i :class="ev.statusIcon" class="text-xs text-[var(--text-color-secondary)] shrink-0" />
</div>
<div v-if="!eventosDoDia.length" class="flex items-center gap-2 py-3 text-[var(--text-color-secondary)] text-sm"><i class="pi pi-sun" /><span>Sem compromissos</span></div>
</div>
@@ -908,7 +908,7 @@ onMounted(async () => {
<div v-else class="flex flex-col gap-1.5 max-h-[210px] overflow-y-auto pr-0.5">
<div v-for="r in recorrencias" :key="r.id" class="aside-rec" @click="openRecMenu($event, r)">
<Avatar :label="r.initials" shape="square" size="normal" class="flex-shrink-0" />
<Avatar :label="r.initials" shape="square" size="normal" class="shrink-0" />
<div class="flex-1 min-w-0">
<span class="block text-[0.8125rem] font-semibold truncate text-[var(--text-color)]">{{ r.nome }}</span>
<span class="block text-xs text-[var(--text-color-secondary)]">{{ r.freq }}</span>
@@ -953,9 +953,9 @@ onMounted(async () => {
</div>
<!-- Linha 1 -->
<div class="relative z-[1] flex items-center gap-4">
<div class="relative z-1 flex items-center gap-4">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="grid place-items-center w-10 h-10 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-home text-lg" />
</div>
<div class="min-w-0">
@@ -964,7 +964,7 @@ onMounted(async () => {
</div>
</div>
<!-- Controles (desktop e mobile mesmo conteúdo, sempre visível) -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="load" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="$router.push('/configuracoes')" />
</div>
@@ -973,7 +973,7 @@ onMounted(async () => {
<Divider class="hidden xl:block my-2" />
<!-- Quick stats -->
<div class="relative z-[1] mt-2">
<div class="relative z-1 mt-2">
<div class="flex flex-wrap gap-2.5">
<div
v-for="s in quickStats"
@@ -1009,7 +1009,7 @@ onMounted(async () => {
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-calendar-clock text-[var(--primary-color,#6366f1)]" />
<span class="truncate">Agenda · {{ labelDiaSelecionado }}</span>
<span v-if="eventosDoDia.length" class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-xs font-bold flex-shrink-0">{{ eventosDoDia.length }}</span>
<span v-if="eventosDoDia.length" class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-xs font-bold shrink-0">{{ eventosDoDia.length }}</span>
</div>
<i class="pi transition-transform duration-200" :class="asideOpen ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
@@ -1026,7 +1026,7 @@ onMounted(async () => {
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5 shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
<div class="flex items-center justify-between mb-2.5">
<div class="flex items-center gap-2.5">
<i class="pi pi-chart-bar w-10 h-10 rounded-md cfg-subheader__icon flex-shrink-0" />
<i class="pi pi-chart-bar w-10 h-10 rounded-md cfg-subheader__icon shrink-0" />
<div class="flex flex-col leading-tight">
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo Hoje</div>
@@ -1239,9 +1239,9 @@ onMounted(async () => {
</div>
</div>
<div class="flex gap-3 mt-2 flex-wrap">
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-indigo-500 to-indigo-400 flex-shrink-0" />Presentes </span>
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-red-500 to-red-300 flex-shrink-0" />Faltas </span>
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-amber-500 to-amber-300 flex-shrink-0" />Reposição </span>
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-indigo-500 to-indigo-400 shrink-0" />Presentes </span>
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-red-500 to-red-300 shrink-0" />Faltas </span>
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-amber-500 to-amber-300 shrink-0" />Reposição </span>
</div>
</div>
</div>
@@ -1284,7 +1284,7 @@ onMounted(async () => {
<div class="flex flex-col gap-1.5">
<div v-for="c in commitments" :key="c.id" class="flex items-center gap-2.5 px-2.5 py-2 rounded-md bg-[var(--surface-ground,#f8fafc)] hover:bg-[var(--surface-hover,#f1f5f9)] transition-colors duration-100">
<div
class="w-[3px] h-7 rounded-sm flex-shrink-0"
class="w-[3px] h-7 rounded-sm shrink-0"
:class="{
'bg-indigo-500': c.cor === 'blue',
'bg-purple-500': c.cor === 'purple',