+ Menu Hover no Layout Rail, Twilio, Sms, Email, Templates, LNovo Layout Configurações
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }" />
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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://<project>.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> já 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>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user