CRM WhatsApp Grupo 3 completo + Marco A/B (Asaas) + admin SaaS + refactors polimórficos

Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.

═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════

3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.

3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).

3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).

3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.

3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".

═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════

- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
  Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
  1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
  whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
  reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
  envolve envio em dedução atômica + rollback). Consumido por Evolution E
  Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
  MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand

═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════

Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
  transactions, packages, purchases) + RPCs add_whatsapp_credits e
  deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
  de 11 ou 14 dígitos)

Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
  QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
  quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance

Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
  utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
  persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
  error.context.json()

Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
  - Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
    toggle is_active inline, dialog de edição com validação
  - Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
    add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
    das últimas 20 transações topup/adjustment/refund

═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════

2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)

2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha

═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════

5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords

═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════

- contact_types + contact_phones (entity_type + entity_id) — migration
  20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
  remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
  unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
  legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
  e cria/atualiza phone como WhatsApp vinculado

═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════

- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
  .dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
  (fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
  error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
  Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
  sino + popup + browser notification)

═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════

20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj

═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════

Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound

═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════

Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits

Stores: conversationDrawerStore

Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor

Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage

Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats

Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)

Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados

═══════════════════════════════════════════════════════════════════════════
NOTAS

- Após subir, rodar supabase functions serve --no-verify-jwt
  --env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
  providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-23 07:05:24 -03:00
parent 037ba3721f
commit 2644e60bb6
191 changed files with 38629 additions and 3756 deletions
+14 -28
View File
@@ -754,42 +754,28 @@ onBeforeUnmount(() => {
</script>
<template>
<!-- HERO -->
<div ref="heroSentinelRef" class="p-2" />
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2"
:style="{ top: 'var(--layout-sticky-top, 55px)' }"
:class="{ 'rounded-tl-none rounded-tr-none': heroStuck }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-80 h-80 -top-20 -right-16 bg-indigo-500/[0.14]" />
<div class="absolute rounded-full blur-[70px] w-[22rem] h-[22rem] top-4 -left-20 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-64 h-64 -bottom-12 right-20 bg-fuchsia-500/[0.09]" />
</div>
<div class="relative z-10 flex items-center gap-5 flex-wrap">
<div class="flex items-center gap-4 flex-1 min-w-0">
<div class="relative shrink-0">
<img v-if="ui.avatarPreview" :src="ui.avatarPreview" class="w-16 h-16 rounded-[1.125rem] border-2 border-[var(--surface-border)] block object-cover" alt="avatar" />
<div v-else class="w-16 h-16 rounded-[1.125rem] border-2 border-[var(--surface-border)] grid place-items-center bg-[var(--surface-ground)] text-[1.3rem] font-extrabold text-[var(--text-color)]">{{ initials }}</div>
<span class="absolute bottom-[-3px] right-[-3px] w-[0.9rem] h-[0.9rem] rounded-full bg-emerald-400 border-[2.5px] border-[var(--surface-card)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] leading-snug truncate">{{ form.full_name || 'Meu Perfil' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5 truncate">{{ userEmail || 'Gerencie suas informações pessoais e segurança' }}</div>
</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="relative shrink-0">
<img v-if="ui.avatarPreview" :src="ui.avatarPreview" class="w-10 h-10 rounded-md border border-[var(--surface-border)] block object-cover" alt="avatar" />
<div v-else class="w-10 h-10 rounded-md border border-[var(--surface-border)] grid place-items-center bg-[var(--surface-ground)] text-[0.85rem] font-bold text-[var(--text-color)]">{{ initials }}</div>
<span class="absolute bottom-[-2px] right-[-2px] w-[0.6rem] h-[0.6rem] rounded-full bg-emerald-400 border-2 border-[var(--surface-card)]" />
</div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title truncate">{{ form.full_name || 'Meu Perfil' }}</div>
<div class="cfg-subheader__sub truncate">{{ userEmail || 'Gerencie suas informações pessoais e segurança' }}</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" @click="router.back()" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" :disabled="!dirty" @click="saveAll" />
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined size="small" @click="router.back()" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="saving" :disabled="!dirty" @click="saveAll" />
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center gap-2 shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => heroMenuRef.toggle(e)" />
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" :model="heroMenuItems" :popup="true" />
</div>
</div>
+13 -32
View File
@@ -170,41 +170,21 @@ async function sendResetEmail() {
</script>
<template>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="headerEl"
class="sticky mx-auto max-w-2xl px-3 md:px-5 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-12 bg-indigo-500/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-2 -left-20 bg-emerald-500/[0.08]" />
</div>
<div class="relative z-10 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-[0.875rem] shrink-0" style="background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent); color: var(--p-primary-500, #6366f1)">
<i class="pi pi-shield text-lg" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Segurança</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gerencie o acesso e a senha da sua conta</div>
</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader max-w-2xl mx-auto w-full">
<div class="cfg-subheader__icon"><i class="pi pi-shield" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Segurança</div>
<div class="cfg-subheader__sub">Gerencie o acesso e a senha da sua conta.</div>
</div>
<span class="hidden xl:inline-flex items-center gap-2 text-[1rem] px-3 py-1.5 rounded-full border border-emerald-200 text-emerald-700 bg-emerald-50 shrink-0">
<span class="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span class="hidden xl:inline-flex items-center gap-2 text-[0.7rem] px-2.5 py-1 rounded-full border border-emerald-200 text-emerald-700 bg-emerald-50 shrink-0">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Sessão ativa
</span>
</div>
</div>
<div class="px-3 md:px-5 pb-8 flex justify-center">
<div class="flex justify-center">
<div class="w-full max-w-2xl space-y-4">
<!-- Card principal -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
@@ -311,8 +291,9 @@ async function sendResetEmail() {
</div>
</div>
<div class="px-3 md:px-4 pb-3">
<LoadedPhraseBlock v-if="mounted" />
<div class="flex justify-center pb-3">
<LoadedPhraseBlock v-if="mounted" />
</div>
</div>
</template>
@@ -415,7 +415,7 @@ onMounted(fetchMeuPlanoClinic);
<!--
HERO sticky
-->
<section class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<section class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
@@ -328,7 +328,7 @@ onBeforeUnmount(() => {
-->
<section
ref="headerEl"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
@@ -268,7 +268,7 @@ onMounted(loadData);
<!--
HERO sticky
-->
<section class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<section class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
+1 -1
View File
@@ -352,7 +352,7 @@ watch(
<!--
HERO sticky
-->
<section class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<section class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
+171 -40
View File
@@ -21,6 +21,16 @@ import { useLayout } from '@/layout/composables/layout';
import Menu from 'primevue/menu';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useClinicKPIs } from '@/composables/useClinicKPIs';
// Fase 3a — KPIs financeiros/operacionais da clínica
const kpis = useClinicKPIs();
const brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
function fmtBRL(v) { return brl.format(Number(v) || 0); }
const revenueMax = computed(() => {
const arr = kpis.revenueSeries.value || [];
return arr.reduce((m, r) => Math.max(m, r.received || 0), 0) || 1;
});
const dashHeroSentinelRef = ref(null);
const heroStuck = ref(false);
@@ -441,6 +451,9 @@ async function load() {
_terapeutasBruto.value = membrosRes.data || [];
_solicitacoesBruto.value = solRes.data || [];
_cadastrosBruto.value = cadRes.data || [];
// KPIs financeiros em paralelo (não bloqueante)
kpis.load();
} catch (e) {
console.error('[ClinicDashboard] load:', e);
} finally {
@@ -612,9 +625,9 @@ onMounted(async () => {
<main class="flex-1 min-w-0 p-4 xl:p-[1.125rem_1.375rem] flex flex-col gap-4 overflow-y-auto" :style="{ marginLeft: isMobileLayout ? undefined : '272px' }">
<!-- Hero Header Skeleton -->
<section v-if="loading" class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2.5">
<div class="flex items-center gap-3 mb-3">
<Skeleton width="40px" height="40px" border-radius="8px" />
<section v-if="loading" class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mx-3 md:mx-4 mt-4">
<div class="flex items-center gap-3">
<Skeleton width="40px" height="40px" border-radius="6px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton width="12rem" height="14px" />
<Skeleton width="18rem" height="11px" />
@@ -622,32 +635,35 @@ onMounted(async () => {
<Skeleton width="36px" height="36px" border-radius="999px" />
<Skeleton width="36px" height="36px" border-radius="999px" />
</div>
<div class="flex flex-wrap gap-2.5">
<div v-for="n in 4" :key="n" class="flex flex-col gap-1.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] flex-1 min-w-[90px]">
<Skeleton width="2rem" height="20px" />
<Skeleton width="4rem" height="10px" />
</div>
</section>
<!-- Quick stats Skeleton -->
<section v-if="loading" class="grid grid-cols-2 md:grid-cols-4 gap-2.5 mx-3 md:mx-4 mb-4">
<div v-for="n in 4" :key="n" class="flex flex-col gap-1.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]">
<Skeleton width="2rem" height="20px" />
<Skeleton width="4rem" height="10px" />
</div>
</section>
<!-- Hero Header -->
<section
v-if="!loading"
class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2.5"
class="sticky mx-3 md:mx-4 mt-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
:class="{ 'rounded-tl-none rounded-tr-none': heroStuck }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-72 h-72 -top-16 -right-12 rounded-full blur-[70px] bg-teal-500/10" />
<div class="absolute w-80 h-80 top-2 -left-20 rounded-full blur-[70px] bg-indigo-400/[0.08]" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-1 flex items-center gap-4">
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-teal-500/10 text-teal-600">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-600 dark:text-indigo-400">
<i class="pi pi-building text-lg" />
</div>
<div class="min-w-0">
<div class="text-[1.1rem] font-bold tracking-tight text-[var(--text-color-secondary)]">{{ saudacao }} <span class="text-[var(--primary-color,#6366f1)]">👋</span></div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">{{ saudacao }} <span class="text-[var(--primary-color,#6366f1)]">👋</span></div>
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">{{ resumoHoje }}</div>
</div>
</div>
@@ -656,35 +672,150 @@ onMounted(async () => {
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="$router.push('/configuracoes')" />
</div>
</div>
</section>
<Divider class="hidden xl:block my-2" />
<!-- Quick stats (separados do hero) -->
<section v-if="!loading" class="grid grid-cols-2 md:grid-cols-4 gap-2.5 mx-3 md:mx-4 mb-4">
<div
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-3 rounded-md border transition-colors duration-150"
:class="{
'border-red-500/25 bg-red-500/5': s.cls === 'qs-urgente',
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
'border-[var(--surface-border)] bg-[var(--surface-card)]': !s.cls
}"
>
<div
class="text-[1.35rem] font-bold leading-none"
:class="{
'text-red-500': s.cls === 'qs-urgente',
'text-green-500': s.cls === 'qs-ok',
'text-[var(--text-color)]': !s.cls
}"
>
{{ s.value }}
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-75 truncate">{{ s.label }}</div>
</div>
</section>
<!-- Quick stats -->
<div class="relative z-1 mt-2">
<div class="flex flex-wrap gap-2.5">
<div
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[90px] text-center xl:text-left transition-colors duration-150"
:class="{
'border-red-500/25 bg-red-500/5': s.cls === 'qs-urgente',
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
'border-[var(--surface-border)] bg-[var(--surface-ground)]': !s.cls
}"
>
<div
class="text-[1.35rem] font-bold leading-none"
:class="{
'text-red-500': s.cls === 'qs-urgente',
'text-green-500': s.cls === 'qs-ok',
'text-[var(--text-color)]': !s.cls
}"
>
{{ s.value }}
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-75">{{ s.label }}</div>
<!--
KPIs financeiros/operacionais (Fase 3a)
-->
<section v-if="!loading" class="flex flex-col gap-3">
<!-- Cards KPI -->
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2.5">
<div class="flex flex-col gap-0.5 px-4 py-3 rounded-md border border-emerald-500/25 bg-emerald-500/5">
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Recebido no mês</div>
<div class="text-[1.25rem] font-bold text-emerald-600 leading-tight">{{ fmtBRL(kpis.mrrCurrentCents.value) }}</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
Ticket médio: {{ fmtBRL(kpis.avgTicket.value) }}
</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-3 rounded-md border"
:class="kpis.overdueCount.value > 0 ? 'border-red-500/25 bg-red-500/5' : 'border-[var(--surface-border)] bg-[var(--surface-ground)]'"
>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Inadimplência</div>
<div class="text-[1.25rem] font-bold leading-tight" :class="kpis.overdueCount.value > 0 ? 'text-red-500' : 'text-[var(--text-color)]'">
{{ fmtBRL(kpis.overdueCents.value) }}
</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
{{ kpis.overdueCount.value }} recebível(is) em atraso
</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">A receber</div>
<div class="text-[1.25rem] font-bold text-[var(--text-color)] leading-tight">{{ fmtBRL(kpis.pendingCents.value) }}</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">Pendentes no mês</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Pacientes</div>
<div class="text-[1.25rem] font-bold text-[var(--text-color)] leading-tight">
{{ kpis.activePatients.value }}
<span class="text-[0.75rem] text-[var(--text-color-secondary)] font-normal">/ {{ kpis.totalPatients.value }}</span>
</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">Ativos · {{ kpis.inactivePatients.value }} inativos</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Sessões no mês</div>
<div class="text-[1.25rem] font-bold text-[var(--text-color)] leading-tight">{{ kpis.sessionsDone.value }}</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
de {{ kpis.sessionsScheduled.value }} agendadas
</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-3 rounded-md border"
:class="(kpis.noShowRate.value ?? 0) > 15 ? 'border-amber-500/25 bg-amber-500/5' : 'border-[var(--surface-border)] bg-[var(--surface-ground)]'"
>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Taxa de falta</div>
<div class="text-[1.25rem] font-bold leading-tight" :class="(kpis.noShowRate.value ?? 0) > 15 ? 'text-amber-600' : 'text-[var(--text-color)]'">
{{ kpis.noShowRate.value !== null ? kpis.noShowRate.value + '%' : '—' }}
</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
{{ kpis.sessionsNoShow.value }} faltas · {{ kpis.sessionsCancelled.value }} cancel.
</div>
</div>
</div>
<!-- Grid: gráfico 6 meses + top pacientes -->
<div class="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-3">
<!-- Gráfico de receita (barras SVG simples) -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-semibold text-[var(--text-color)]">Receita recebida · últimos 6 meses</div>
<i class="pi pi-chart-bar text-[var(--text-color-secondary)]" />
</div>
<div v-if="!kpis.revenueSeries.value.length" class="text-xs text-[var(--text-color-secondary)] py-6 text-center">
Sem dados no período.
</div>
<div v-else class="flex items-end gap-2 h-40">
<div
v-for="(m, i) in kpis.revenueSeries.value"
:key="i"
class="flex-1 flex flex-col items-center gap-1 min-w-0"
>
<div class="text-[0.65rem] text-[var(--text-color-secondary)] truncate w-full text-center">
{{ fmtBRL(m.received) }}
</div>
<div
class="w-full bg-emerald-500/70 rounded-t-md transition-all duration-300 min-h-[2px]"
:style="{ height: `${Math.max(2, (m.received / revenueMax) * 130)}px` }"
/>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] uppercase">{{ m.label }}</div>
</div>
</div>
</div>
<!-- Top 5 pacientes -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-semibold text-[var(--text-color)]">Top pacientes · 6 meses</div>
<i class="pi pi-users text-[var(--text-color-secondary)]" />
</div>
<div v-if="!kpis.topPatients.value.length" class="text-xs text-[var(--text-color-secondary)] py-6 text-center">
Sem dados.
</div>
<ol v-else class="flex flex-col gap-1.5">
<li
v-for="(p, i) in kpis.topPatients.value"
:key="p.patient_id"
class="flex items-center justify-between gap-2 text-xs"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="w-5 h-5 rounded-full bg-[var(--surface-ground)] grid place-items-center text-[0.65rem] font-bold shrink-0">{{ i + 1 }}</span>
<span class="truncate text-[var(--text-color)]">{{ p.nome_completo }}</span>
</div>
<span class="font-semibold text-emerald-600 shrink-0">{{ fmtBRL(p.total) }}</span>
</li>
</ol>
</div>
</div>
</section>
+17 -9
View File
@@ -19,17 +19,25 @@ import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue'
</script>
<template>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> do Editor</span>
</div>
<div class="flex items-center justify-center bg-orange-100 dark:bg-orange-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-pencil text-orange-500 text-xl!"></i>
<div
class="sticky mx-3 md:mx-4 mt-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-amber-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-orange-500/10 text-orange-600 dark:text-orange-400">
<i class="pi pi-pencil text-lg" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Área do Editor</div>
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">Crie e gerencie cursos, módulos e conteúdos da plataforma de microlearning.</div>
</div>
</div>
</div>
<p class="text-muted-color text-sm mt-0">Crie e gerencie cursos, módulos e conteúdos da plataforma de microlearning.</p>
</div>
<div class="grid grid-cols-12 gap-8">
+17 -8
View File
@@ -23,14 +23,23 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Portal</span>
<span class="text-muted-color"> = Área do Paciente</span>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-user text-blue-500 text-xl!"></i>
<div
class="sticky mx-3 md:mx-4 mt-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-blue-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-cyan-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-blue-500/10 text-blue-600 dark:text-blue-400">
<i class="pi pi-user text-lg" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Portal</div>
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">Área do paciente sessões, documentos e pagamentos.</div>
</div>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+444 -32
View File
@@ -275,6 +275,212 @@ async function loadTransactions() {
if (data) transactions.value = data;
}
// ══════════════════════════════════════════════════════════════
// ABA 4 — WhatsApp: Pacotes (CRUD whatsapp_credit_packages)
// ══════════════════════════════════════════════════════════════
const waPackages = ref([]);
const waPackagesLoading = ref(false);
const waPkgDialog = ref(false);
const waEditingPkgId = ref(null);
const emptyWaPkg = () => ({
name: '',
description: '',
credits: 100,
price_brl: 0,
is_active: true,
is_featured: false,
position: 100
});
const waPkgForm = ref(emptyWaPkg());
async function loadWaPackages() {
waPackagesLoading.value = true;
try {
const { data, error } = await supabase
.from('whatsapp_credit_packages')
.select('*')
.order('position', { ascending: true })
.order('price_brl', { ascending: true });
if (error) throw error;
waPackages.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
waPackagesLoading.value = false;
}
}
function openNewWaPkg() {
waEditingPkgId.value = null;
waPkgForm.value = emptyWaPkg();
waPkgDialog.value = true;
}
function openEditWaPkg(row) {
waEditingPkgId.value = row.id;
waPkgForm.value = {
name: row.name || '',
description: row.description || '',
credits: row.credits,
price_brl: Number(row.price_brl) || 0,
is_active: row.is_active,
is_featured: row.is_featured,
position: row.position ?? 100
};
waPkgDialog.value = true;
}
function sanitizeWaPkg(f) {
return {
name: String(f.name || '').trim().slice(0, 100),
description: f.description ? String(f.description).trim().slice(0, 500) : null,
credits: Math.max(1, Math.round(Number(f.credits) || 0)),
price_brl: Math.max(0.01, Number(f.price_brl) || 0),
is_active: !!f.is_active,
is_featured: !!f.is_featured,
position: Math.max(0, Math.round(Number(f.position) || 100))
};
}
async function saveWaPkg() {
const clean = sanitizeWaPkg(waPkgForm.value);
if (!clean.name) { toast.add({ severity: 'warn', summary: 'Nome é obrigatório', life: 2500 }); return; }
if (clean.credits < 1) { toast.add({ severity: 'warn', summary: 'Créditos deve ser > 0', life: 2500 }); return; }
if (clean.price_brl <= 0) { toast.add({ severity: 'warn', summary: 'Preço deve ser > 0', life: 2500 }); return; }
try {
if (waEditingPkgId.value) {
const { error } = await supabase.from('whatsapp_credit_packages').update(clean).eq('id', waEditingPkgId.value);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Pacote atualizado', life: 2000 });
} else {
const { error } = await supabase.from('whatsapp_credit_packages').insert(clean);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Pacote criado', life: 2000 });
}
waPkgDialog.value = false;
await loadWaPackages();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
}
}
function deleteWaPkg(row) {
confirm.require({
group: 'headless',
header: 'Remover pacote',
message: `Remover "${row.name}"? Compras existentes continuam válidas (FK SET NULL).`,
icon: 'pi-trash',
color: '#ef4444',
accept: async () => {
try {
const { error } = await supabase.from('whatsapp_credit_packages').delete().eq('id', row.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Pacote removido', life: 2000 });
await loadWaPackages();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
}
}
});
}
async function toggleWaPkgActive(row) {
try {
const { error } = await supabase.from('whatsapp_credit_packages').update({ is_active: !row.is_active }).eq('id', row.id);
if (error) throw error;
row.is_active = !row.is_active;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
}
}
function formatBrl(v) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(v) || 0);
}
// ══════════════════════════════════════════════════════════════
// ABA 5 — WhatsApp: Topup manual (add_whatsapp_credits RPC)
// ══════════════════════════════════════════════════════════════
const waTopup = ref({
tenantId: null,
amount: 100,
kind: 'topup_manual',
note: ''
});
const waTopupKinds = [
{ label: 'Topup manual (cortesia)', value: 'topup_manual' },
{ label: 'Ajuste', value: 'adjustment' },
{ label: 'Estorno / Refund', value: 'refund' }
];
const waTenantBalance = ref(null);
const waRecentTopups = ref([]);
const waTopupSaving = ref(false);
async function loadWaBalance(tenantId) {
if (!tenantId) { waTenantBalance.value = null; waRecentTopups.value = []; return; }
const [{ data: bal }, { data: txs }] = await Promise.all([
supabase
.from('whatsapp_credits_balance')
.select('balance, lifetime_purchased, lifetime_used, low_balance_threshold')
.eq('tenant_id', tenantId)
.maybeSingle(),
supabase
.from('whatsapp_credits_transactions')
.select('id, kind, amount, balance_after, note, created_at, admin_id')
.eq('tenant_id', tenantId)
.in('kind', ['topup_manual', 'adjustment', 'refund'])
.order('created_at', { ascending: false })
.limit(20)
]);
waTenantBalance.value = bal || { balance: 0, lifetime_purchased: 0, lifetime_used: 0, low_balance_threshold: 20 };
waRecentTopups.value = txs || [];
}
async function onWaTenantChange() {
await loadWaBalance(waTopup.value.tenantId);
}
async function submitWaTopup() {
const t = waTopup.value;
if (!t.tenantId) { toast.add({ severity: 'warn', summary: 'Selecione o tenant', life: 2500 }); return; }
const amt = Math.round(Number(t.amount) || 0);
if (amt < 1) { toast.add({ severity: 'warn', summary: 'Créditos deve ser >= 1', life: 2500 }); return; }
const note = String(t.note || '').trim().slice(0, 500) || null;
waTopupSaving.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const adminId = authData?.user?.id || null;
const { error } = await supabase.rpc('add_whatsapp_credits', {
p_tenant_id: t.tenantId,
p_amount: amt,
p_kind: t.kind,
p_purchase_id: null,
p_admin_id: adminId,
p_note: note
});
if (error) throw error;
toast.add({ severity: 'success', summary: `+${amt} créditos`, detail: tenantName(t.tenantId), life: 3000 });
waTopup.value.note = '';
waTopup.value.amount = 100;
await loadWaBalance(t.tenantId);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
waTopupSaving.value = false;
}
}
const waKindBadge = {
topup_manual: { label: 'Topup', cls: 'bg-sky-500/10 text-sky-600' },
adjustment: { label: 'Ajuste', cls: 'bg-slate-500/10 text-slate-600' },
refund: { label: 'Refund', cls: 'bg-orange-500/10 text-orange-600' }
};
function txTypeLabel(type) {
const map = { purchase: 'Compra', consume: 'Consumo', adjustment: 'Ajuste', refund: 'Reembolso', expiration: 'Expiração' };
return map[type] || type;
@@ -298,11 +504,12 @@ onMounted(() => {
loadProducts();
loadCredits();
loadTransactions();
loadWaPackages();
});
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 p-4">
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
@@ -319,43 +526,46 @@ onMounted(() => {
</template>
</ConfirmDialog>
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold m-0">Recursos Extras (Add-ons)</h2>
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-box" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Recursos Extras (Add-ons)</div>
<div class="cfg-subheader__sub">Produtos, créditos WhatsApp/SMS e transações consumidas por tenants.</div>
</div>
</div>
<!-- Próximos passos -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card class="border-l-4" style="border-left-color: var(--p-yellow-500)">
<template #content>
<div class="flex items-start gap-3">
<i class="pi pi-bell text-2xl" style="color: var(--p-yellow-500)" />
<div>
<h4 class="font-semibold m-0 mb-1">Alerta de saldo baixo</h4>
<p class="text-sm text-surface-500 m-0">
Próximo passo: Notificar tenants automaticamente quando o saldo de créditos estiver abaixo do limite configurado. O campo <code>low_balance_threshold</code> existe no banco falta a Edge Function de verificação
periódica.
</p>
<Tag value="Planejado" severity="warn" class="mt-2" />
</div>
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 border-l-4" style="border-left-color: var(--p-yellow-500)">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0 bg-yellow-100 text-yellow-700">
<i class="pi pi-bell text-lg" />
</div>
</template>
</Card>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[var(--text-color)] mb-1">Alerta de saldo baixo</div>
<p class="text-sm text-[var(--text-color-secondary)] m-0">
Próximo passo: notificar tenants automaticamente quando o saldo de créditos estiver abaixo do limite configurado. O campo <code class="font-mono text-xs">low_balance_threshold</code> existe no banco falta a Edge Function de verificação periódica.
</p>
<Tag value="Planejado" severity="warn" class="mt-2 text-[0.65rem]" />
</div>
</div>
</div>
<Card class="border-l-4" style="border-left-color: var(--p-blue-500)">
<template #content>
<div class="flex items-start gap-3">
<i class="pi pi-credit-card text-2xl" style="color: var(--p-blue-500)" />
<div>
<h4 class="font-semibold m-0 mb-1">Compra online (Gateway)</h4>
<p class="text-sm text-surface-500 m-0">
Próximo passo: Integrar gateway de pagamento (PIX, cartão) para que tenants comprem créditos diretamente pela plataforma. Os campos <code>payment_method</code> e <code>payment_reference</code> estão prontos no
banco.
</p>
<Tag value="Planejado" severity="info" class="mt-2" />
</div>
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 border-l-4" style="border-left-color: var(--p-blue-500)">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0 bg-blue-100 text-blue-700">
<i class="pi pi-credit-card text-lg" />
</div>
</template>
</Card>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[var(--text-color)] mb-1">Compra online (Gateway)</div>
<p class="text-sm text-[var(--text-color-secondary)] m-0">
Próximo passo: integrar gateway de pagamento (PIX, cartão) para que tenants comprem créditos diretamente pela plataforma. Os campos <code class="font-mono text-xs">payment_method</code> e <code class="font-mono text-xs">payment_reference</code> estão prontos no banco.
</p>
<Tag value="Planejado" severity="info" class="mt-2 text-[0.65rem]" />
</div>
</div>
</div>
</div>
<Tabs v-model:value="activeTab">
@@ -363,6 +573,8 @@ onMounted(() => {
<Tab :value="0">Produtos</Tab>
<Tab :value="1">Recursos Extras por Tenant</Tab>
<Tab :value="2">Transações</Tab>
<Tab :value="3"><i class="pi pi-whatsapp mr-1 text-emerald-500" />Pacotes WhatsApp</Tab>
<Tab :value="4"><i class="pi pi-whatsapp mr-1 text-emerald-500" />Topup WhatsApp</Tab>
</TabList>
<TabPanels>
@@ -580,6 +792,139 @@ onMounted(() => {
</Column>
</DataTable>
</TabPanel>
<!-- ABA 4: Pacotes WhatsApp (loja Twilio/Asaas) -->
<TabPanel :value="3">
<div class="flex items-start justify-between gap-3 mb-3 flex-wrap">
<div class="text-xs text-[var(--text-color-secondary)] max-w-[660px]">
Pacotes que os tenants veem em <code>/configuracoes/creditos-whatsapp</code>. Consumidos no canal
<strong>AgenciaPSI Oficial (Twilio)</strong> WhatsApp Pessoal (Evolution) é gratuito.
<strong>Destaque</strong> aparece com estrela; <strong>posição</strong> ordena (menor primeiro).
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="waPackagesLoading" @click="loadWaPackages" />
<Button label="Novo pacote" icon="pi pi-plus" size="small" @click="openNewWaPkg" />
</div>
</div>
<DataTable :value="waPackages" :loading="waPackagesLoading" size="small" stripedRows
emptyMessage="Nenhum pacote cadastrado.">
<Column field="position" header="#" style="width: 60px" />
<Column header="Nome">
<template #body="{ data }">
<div class="flex items-center gap-1.5">
<span class="font-semibold">{{ data.name }}</span>
<i v-if="data.is_featured" class="pi pi-star-fill text-amber-500 text-xs" v-tooltip.top="'Destaque'" />
</div>
<div v-if="data.description" class="text-xs text-[var(--text-color-secondary)] truncate max-w-[360px]">
{{ data.description }}
</div>
</template>
</Column>
<Column field="credits" header="Créditos" style="width: 100px" />
<Column header="Preço" style="width: 130px">
<template #body="{ data }">
<span class="font-mono">{{ formatBrl(data.price_brl) }}</span>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">{{ formatBrl(data.price_brl / data.credits) }} / msg</div>
</template>
</Column>
<Column header="Ativo" style="width: 80px">
<template #body="{ data }">
<ToggleSwitch :modelValue="data.is_active" @update:modelValue="() => toggleWaPkgActive(data)" />
</template>
</Column>
<Column header="Ações" style="width: 100px">
<template #body="{ data }">
<div class="flex gap-1">
<Button icon="pi pi-pencil" text rounded size="small" @click="openEditWaPkg(data)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="deleteWaPkg(data)" />
</div>
</template>
</Column>
</DataTable>
</TabPanel>
<!-- ABA 5: Topup manual WhatsApp -->
<TabPanel :value="4">
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-4">
<!-- Form -->
<div class="flex flex-col gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2">
<i class="pi pi-plus-circle text-sky-500" />
<h3 class="text-sm font-bold uppercase tracking-wide m-0">Adicionar créditos WhatsApp a um tenant</h3>
</div>
<p class="text-xs text-[var(--text-color-secondary)] m-0">
Ações: cortesia onboarding, reembolso fora do Asaas, correção de falha técnica. Fica no extrato do tenant
com <code>admin_id = você</code> pra auditoria.
</p>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Tenant</label>
<Select v-model="waTopup.tenantId" :options="tenants" optionLabel="label" optionValue="value"
filter placeholder="Selecionar tenant" class="w-full"
:loading="loadingTenants"
@update:modelValue="onWaTenantChange" />
</div>
<div v-if="waTenantBalance" class="grid grid-cols-3 gap-2 rounded-md bg-[var(--surface-ground)] p-2 text-xs">
<div><span class="text-[var(--text-color-secondary)]">Saldo:</span> <strong>{{ waTenantBalance.balance }}</strong></div>
<div><span class="text-[var(--text-color-secondary)]">Comprados:</span> <strong>{{ waTenantBalance.lifetime_purchased }}</strong></div>
<div><span class="text-[var(--text-color-secondary)]">Usados:</span> <strong>{{ waTenantBalance.lifetime_used }}</strong></div>
</div>
<div class="flex gap-2">
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Quantidade</label>
<InputNumber v-model="waTopup.amount" :min="1" :max="100000" class="w-full" fluid />
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Tipo</label>
<Select v-model="waTopup.kind" :options="waTopupKinds" optionLabel="label" optionValue="value" class="w-full" />
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Nota / motivo</label>
<Textarea v-model="waTopup.note" rows="2" autoResize
placeholder="Ex: Cortesia onboarding, ressarcimento #T123…"
maxlength="500" />
<small class="text-[var(--text-color-secondary)]">Visível pro tenant no extrato. Max 500 chars.</small>
</div>
<Button label="Adicionar créditos" icon="pi pi-check"
class="rounded-full self-start"
:loading="waTopupSaving"
:disabled="!waTopup.tenantId || !waTopup.amount"
@click="submitWaTopup" />
</div>
<!-- Histórico -->
<div class="flex flex-col gap-2 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2">
<i class="pi pi-history text-[var(--primary-color)]" />
<h3 class="text-sm font-bold uppercase tracking-wide m-0">Topups / ajustes recentes</h3>
</div>
<div v-if="!waTopup.tenantId" class="text-xs text-[var(--text-color-secondary)] italic py-4 text-center">
Selecione um tenant pra ver o histórico.
</div>
<div v-else-if="!waRecentTopups.length" class="text-xs text-[var(--text-color-secondary)] italic py-4 text-center">
Nenhum topup/ajuste ainda pra esse tenant.
</div>
<div v-else class="flex flex-col gap-1 max-h-[340px] overflow-y-auto text-xs">
<div v-for="tx in waRecentTopups" :key="tx.id"
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-2 p-2 rounded hover:bg-[var(--surface-hover)]">
<span class="inline-flex items-center px-1.5 py-px rounded text-[0.62rem] font-bold uppercase"
:class="waKindBadge[tx.kind]?.cls">{{ waKindBadge[tx.kind]?.label || tx.kind }}</span>
<span class="truncate">{{ tx.note || '—' }}</span>
<span class="font-bold font-mono" :class="tx.amount > 0 ? 'text-green-600' : 'text-orange-600'">
{{ tx.amount > 0 ? '+' : '' }}{{ tx.amount }}
</span>
<span class="text-[var(--text-color-secondary)]">{{ formatDate(tx.created_at) }}</span>
</div>
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
@@ -741,5 +1086,72 @@ onMounted(() => {
</div>
</template>
</Dialog>
<!-- Dialog: Novo/Editar pacote WhatsApp -->
<Dialog
v-model:visible="waPkgDialog"
modal
:draggable="false"
:closable="true"
:dismissableMask="true"
class="dc-dialog w-[32rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center gap-3 px-1">
<i class="pi pi-whatsapp text-emerald-500 text-xl" />
<div class="min-w-0">
<div class="text-base font-semibold truncate">{{ waEditingPkgId ? 'Editar pacote' : 'Novo pacote WhatsApp' }}</div>
<div class="text-xs opacity-50">Créditos consumidos no canal AgenciaPSI Oficial (Twilio)</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Nome *</label>
<InputText v-model="waPkgForm.name" maxlength="100" placeholder="Ex: Pacote Mensal 500" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Descrição</label>
<InputText v-model="waPkgForm.description" maxlength="500" placeholder="Ex: Ideal pra clínicas pequenas" class="w-full" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Créditos *</label>
<InputNumber v-model="waPkgForm.credits" :min="1" :max="100000" fluid />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Preço *</label>
<InputNumber v-model="waPkgForm.price_brl" mode="currency" currency="BRL" locale="pt-BR" :min="0.01" :maxFractionDigits="2" fluid />
</div>
</div>
<div class="grid grid-cols-3 gap-3 items-end">
<div class="flex items-center gap-2">
<ToggleSwitch v-model="waPkgForm.is_active" />
<label class="text-sm">Ativo</label>
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="waPkgForm.is_featured" />
<label class="text-sm">Destaque</label>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium">Posição</label>
<InputNumber v-model="waPkgForm.position" :min="0" :max="9999" fluid />
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" @click="waPkgDialog = false" />
<Button :label="waEditingPkgId ? 'Salvar' : 'Criar'" icon="pi pi-check" class="rounded-full" @click="saveWaPkg" />
</div>
</template>
</Dialog>
</div>
</template>
</template>
+1 -1
View File
@@ -425,7 +425,7 @@ onBeforeUnmount(() => {
<div ref="sentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="heroRef" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div ref="heroRef" class="sticky mx-3 md:mx-4 mt-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
+17 -31
View File
@@ -494,43 +494,29 @@ function mediasCount(doc) {
<!-- Input oculto para importação de JSON -->
<input ref="jsonFileInputRef" type="file" accept=".json,application/json" style="display: none" @change="onJsonFileChange" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-blue-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</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 shrink-0">
<i class="pi pi-question-circle text-xl text-blue-500" />
</div>
<div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Documentação do Sistema</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Artigos de ajuda exibidos dinamicamente nas páginas.</div>
</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-book" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Documentação do Sistema</div>
<div class="cfg-subheader__sub">Artigos de ajuda exibidos dinamicamente nas páginas.</div>
</div>
<!-- Desktop actions -->
<div class="hidden xl:flex items-center gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
<Button icon="pi pi-upload" label="Importar JSON" severity="secondary" outlined class="rounded-full" v-tooltip.bottom="'Carrega um arquivo .json gerado pelo assistente'" @click="triggerJsonImport" />
<Button icon="pi pi-comment" label="Prompt" severity="secondary" outlined class="rounded-full" v-tooltip.bottom="'Ver instruções para gerar documentação com IA'" @click="promptDlgOpen = true" />
<Button icon="pi pi-plus" label="Novo documento" class="rounded-full" @click="abrirDialog()" />
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined rounded size="small" :loading="loading" @click="load" />
<Button icon="pi pi-upload" label="Importar JSON" severity="secondary" outlined size="small" v-tooltip.bottom="'Carrega um arquivo .json gerado pelo assistente'" @click="triggerJsonImport" />
<Button icon="pi pi-comment" label="Prompt" severity="secondary" outlined size="small" v-tooltip.bottom="'Ver instruções para gerar documentação com IA'" @click="promptDlgOpen = true" />
<Button icon="pi pi-plus" label="Novo documento" size="small" @click="abrirDialog()" />
</div>
<!-- Mobile actions -->
<div class="flex xl:hidden items-center gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
<Button icon="pi pi-upload" severity="secondary" outlined rounded v-tooltip.bottom="'Importar JSON'" @click="triggerJsonImport" />
<Button icon="pi pi-comment" severity="secondary" outlined rounded v-tooltip.bottom="'Prompt IA'" @click="promptDlgOpen = true" />
<Button icon="pi pi-plus" rounded @click="abrirDialog()" />
<div class="flex xl:hidden items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined rounded size="small" :loading="loading" @click="load" />
<Button icon="pi pi-upload" severity="secondary" outlined rounded size="small" v-tooltip.bottom="'Importar JSON'" @click="triggerJsonImport" />
<Button icon="pi pi-comment" severity="secondary" outlined rounded size="small" v-tooltip.bottom="'Prompt IA'" @click="promptDlgOpen = true" />
<Button icon="pi pi-plus" rounded size="small" @click="abrirDialog()" />
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Cards de saúde -->
<div class="flex flex-wrap gap-2.5">
<!-- Total -->
@@ -304,19 +304,17 @@ function insertVariable(key) {
</script>
<template>
<div class="px-4 py-6 max-w-[1200px] mx-auto">
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div>
<h1 class="text-xl font-bold">Templates de Documentos</h1>
<p class="text-sm text-[var(--text-color-secondary)]">
Templates globais disponíveis para todos os tenants (is_global = true)
</p>
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-file-edit" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Templates de Documentos</div>
<div class="cfg-subheader__sub">Templates globais disponíveis para todos os tenants (is_global = true).</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 shrink-0">
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openCreate" />
<Button icon="pi pi-refresh" text rounded size="small" @click="fetchAll" :loading="loading" />
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" v-tooltip.bottom="'Recarregar'" @click="fetchAll" />
</div>
</div>
@@ -256,16 +256,17 @@ onMounted(() => {
</script>
<template>
<div class="p-4 md:p-6 max-w-[1100px] mx-auto">
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de E-mail Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">Templates base do sistema. Tenants podem criar overrides sem alterar estes.</p>
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-envelope" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Templates de E-mail Globais</div>
<div class="cfg-subheader__sub">Templates base do sistema. Tenants podem criar overrides sem alterar estes.</div>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
<Button label="Novo template" icon="pi pi-plus" @click="openNew" />
<div class="flex gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openNew" />
</div>
</div>
+18 -33
View File
@@ -125,42 +125,27 @@ function selecionarCat(cat) {
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<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 shrink-0">
<i class="pi pi-comments text-xl text-[var(--text-color)]" />
</div>
<div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Central de Ajuda</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Encontre respostas para as dúvidas mais comuns</div>
</div>
</div>
<!-- Busca -->
<div>
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="busca" placeholder="Buscar pergunta…" class="w-full" />
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 mt-1 ml-1">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-comments" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Central de Ajuda</div>
<div class="cfg-subheader__sub">Encontre respostas para as dúvidas mais comuns.</div>
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Busca -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="busca" placeholder="Buscar pergunta…" class="w-full" />
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="text-[0.85rem] text-[var(--text-color-secondary)] opacity-70 mt-1.5 ml-1">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
+10 -25
View File
@@ -272,38 +272,23 @@ onBeforeUnmount(() => {
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="p-2" />
<!-- Hero sticky -->
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-fuchsia-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Recursos do Sistema</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Cadastre os recursos (features) que os planos podem habilitar.</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-sparkles" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Recursos do Sistema</div>
<div class="cfg-subheader__sub">Cadastre os recursos (features) que os planos podem habilitar.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<div class="flex xl:hidden shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" aria-haspopup="true" aria-controls="features_hero_menu" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" id="features_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div class="flex items-center gap-3 flex-wrap">
<FloatLabel variant="on" class="w-full md:w-[380px]">
@@ -410,4 +395,4 @@ onBeforeUnmount(() => {
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>
</template>
+11 -21
View File
@@ -410,35 +410,24 @@ async function excluir(id) {
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div class="relative z-10 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-star text-amber-500" />
Feriados Municipais
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Feriados cadastrados pelos tenants alimentam o banco central de feriados do SAAS.</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-calendar" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Feriados Municipais</div>
<div class="cfg-subheader__sub">Feriados cadastrados pelos tenants alimentam o banco central de feriados do SaaS.</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 shrink-0">
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
<span class="font-bold text-[1rem] w-14 text-center">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
<Button icon="pi pi-plus" label="Cadastrar feriado" @click="abrirDialog" />
<Button icon="pi pi-plus" label="Cadastrar" size="small" @click="abrirDialog" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-6">
<div class="flex flex-col gap-6">
<!-- Feriados Nacionais -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="flex items-center gap-2 px-5 py-3 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
@@ -589,6 +578,7 @@ async function excluir(id) {
</template>
</div>
<!-- /municipais -->
</div>
</div>
<!-- /content -->
@@ -230,16 +230,17 @@ onMounted(load);
</script>
<template>
<div class="p-4 md:p-6 max-w-[1200px] mx-auto">
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Avisos Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">Banners no topo da aplicação segmentados por role e contexto.</p>
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-megaphone" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Avisos Globais</div>
<div class="cfg-subheader__sub">Banners no topo da aplicação segmentados por role e contexto.</div>
</div>
<div class="flex gap-2">
<Button label="Ajuda" icon="pi pi-question-circle" severity="secondary" text @click="showHelp = true" />
<Button label="Novo aviso" icon="pi pi-plus" @click="openCreate" />
<div class="flex gap-2 shrink-0">
<Button label="Ajuda" icon="pi pi-question-circle" severity="secondary" text size="small" @click="showHelp = true" />
<Button label="Novo aviso" icon="pi pi-plus" size="small" @click="openCreate" />
</div>
</div>
+11 -24
View File
@@ -185,32 +185,19 @@ onMounted(load);
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-images text-indigo-500" />
Carrossel do Login
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gerencie os slides exibidos na tela de login do sistema</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-images" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Carrossel do Login</div>
<div class="cfg-subheader__sub">Gerencie os slides exibidos na tela de login do sistema.</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined title="Recarregar" :loading="loading" @click="load" />
<Button icon="pi pi-plus" label="Novo slide" @click="openNew" />
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" v-tooltip.bottom="'Recarregar'" :loading="loading" @click="load" />
<Button icon="pi pi-plus" label="Novo slide" size="small" @click="openNew" />
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-6 items-start">
<!-- Tabela de slides -->
<div class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border)] overflow-hidden">
@@ -265,7 +252,7 @@ onMounted(load);
<!-- Toggle ativo -->
<div class="flex justify-center w-[60px]">
<InputSwitch :modelValue="slide.ativo" @update:modelValue="() => toggleAtivo(slide)" />
<ToggleSwitch :modelValue="slide.ativo" @update:modelValue="() => toggleAtivo(slide)" />
</div>
<!-- Ações -->
@@ -463,7 +450,7 @@ create policy "public_read" on public.login_carousel_slides
<!-- Ativo -->
<div class="flex items-center gap-3">
<InputSwitch v-model="form.ativo" inputId="slide-ativo" />
<ToggleSwitch v-model="form.ativo" inputId="slide-ativo" />
<label for="slide-ativo" class="text-[1rem] text-[var(--text-color)] cursor-pointer select-none"> Slide ativo (visível no carrossel) </label>
</div>
@@ -211,16 +211,81 @@ async function save() {
}
// Toggle ativo
async function toggleActive(t) {
const togglingId = ref(null);
async function countAffectedTenants(t) {
const [{ data: schedules }, { data: overrides }] = await Promise.all([
supabase.from('notification_schedules').select('tenant_id').eq('event_type', t.event_type).eq('channel', t.channel).eq('is_active', true).is('deleted_at', null),
supabase.from('notification_templates').select('tenant_id').eq('key', t.key).eq('is_active', true).is('deleted_at', null).not('tenant_id', 'is', null)
]);
const overrideIds = new Set((overrides || []).map((o) => o.tenant_id).filter(Boolean));
const affected = new Set((schedules || []).map((s) => s.tenant_id).filter((id) => id && !overrideIds.has(id)));
return affected.size;
}
async function doToggleActive(t) {
togglingId.value = t.id;
try {
const { error } = await supabase.from('notification_templates').update({ is_active: !t.is_active }).eq('id', t.id);
const next = !t.is_active;
const { error } = await supabase.from('notification_templates').update({ is_active: next }).eq('id', t.id);
if (error) throw error;
t.is_active = !t.is_active;
t.is_active = next;
toast.add({
severity: next ? 'success' : 'warn',
summary: next ? 'Template reativado' : 'Template desativado',
detail: next ? 'Tenants voltam a usar este template padrão.' : 'Tenants sem personalização ficarão sem este template.',
life: 3500
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
} finally {
togglingId.value = null;
}
}
async function requestToggleActive(t) {
if (togglingId.value) return;
if (!t.is_active) {
await doToggleActive(t);
return;
}
togglingId.value = t.id;
let affectedCount = 0;
try {
affectedCount = await countAffectedTenants(t);
} catch {
// silenciosamente ignora mostramos aviso genérico
} finally {
togglingId.value = null;
}
const channelLabel = t.channel === 'whatsapp' ? 'WhatsApp' : 'SMS';
const eventLabel = EVENT_TYPE_LABELS[t.event_type] || t.event_type;
const impactLine =
affectedCount > 0
? `<strong>${affectedCount} ${affectedCount === 1 ? 'tenant está' : 'tenants estão'}</strong> agendando este evento sem template personalizado — ${affectedCount === 1 ? 'ficará' : 'ficarão'} sem mensagem.`
: 'Atualmente nenhum tenant depende deste template, mas futuros eventos não terão mensagem padrão.';
const msg = [
`Você vai desativar o template padrão de <strong>${channelLabel}</strong> para o evento "<strong>${eventLabel}</strong>".`,
impactLine,
'Tenants com template personalizado <strong>NÃO</strong> são afetados.'
].join('<br><br>');
confirm.require({
group: 'headless',
header: `Desativar "${eventLabel}"?`,
message: msg,
icon: 'pi-exclamation-triangle',
color: '#f59e0b',
accept: () => doToggleActive(t)
});
}
// Soft delete
function deleteTemplate(t) {
confirm.require({
@@ -259,7 +324,7 @@ onMounted(load);
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<p class="mb-0 text-center text-[var(--text-color-secondary)]" v-html="message.message"></p>
<div class="flex items-center gap-2 mt-6">
<Button label="Confirmar" class="rounded-full" :style="{ background: message.color || 'var(--p-primary-color)', borderColor: message.color || 'var(--p-primary-color)' }" @click="acceptCallback" />
<Button label="Cancelar" variant="outlined" class="rounded-full" @click="rejectCallback" />
@@ -268,21 +333,22 @@ onMounted(load);
</template>
</ConfirmDialog>
<div class="p-4 md:p-6 max-w-[1200px] mx-auto">
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de Notificação</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">Templates globais de WhatsApp e SMS. Tenants herdam automaticamente.</p>
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-comment" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Templates de Notificação</div>
<div class="cfg-subheader__sub">Templates globais de WhatsApp e SMS. Tenants herdam automaticamente.</div>
</div>
<div class="flex gap-2">
<div class="flex gap-2 shrink-0">
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openNew" />
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
</div>
</div>
<!-- Tabs canal -->
<div class="flex gap-2 mb-5">
<div class="flex gap-2 flex-wrap">
<Button v-for="ch in CHANNELS" :key="ch.value" :label="ch.label" :icon="ch.icon" size="small" :severity="activeChannel === ch.value ? 'primary' : 'secondary'" :outlined="activeChannel !== ch.value" @click="activeChannel = ch.value" />
</div>
@@ -291,8 +357,9 @@ onMounted(load);
<ProgressSpinner />
</div>
<!-- DataTable -->
<DataTable v-else :value="filtered" stripedRows size="small" class="text-sm" :rowClass="(data) => (!data.is_active ? 'opacity-50' : '')">
<!-- DataTable encapsulada em card -->
<div v-else class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<DataTable :value="filtered" stripedRows size="small" class="text-sm" :rowClass="(data) => (!data.is_active ? 'opacity-50' : '')">
<Column field="key" header="Key" sortable style="min-width: 200px">
<template #body="{ data }">
<code class="font-mono text-xs">{{ data.key }}</code>
@@ -325,7 +392,7 @@ onMounted(load);
<Column header="Ativo" style="width: 70px" class="text-center">
<template #body="{ data }">
<ToggleSwitch :modelValue="data.is_active" @update:modelValue="() => toggleActive(data)" />
<ToggleSwitch :modelValue="data.is_active" :disabled="togglingId === data.id" @update:modelValue="() => requestToggleActive(data)" />
</template>
</Column>
@@ -345,6 +412,7 @@ onMounted(load);
</div>
</template>
</DataTable>
</div>
<!-- Dialog Cadastro / Edição -->
<Dialog
@@ -440,4 +508,4 @@ onMounted(load);
</template>
</Dialog>
</div>
</template>
</template>
@@ -416,24 +416,15 @@ onBeforeUnmount(() => {
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="p-2" />
<!-- Hero sticky -->
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Controle de Recursos</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Defina quais recursos cada plano habilita. Mudanças ficam pendentes até salvar.</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-th-large" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Controle de Recursos</div>
<div class="cfg-subheader__sub">Defina quais recursos cada plano habilita. Mudanças ficam pendentes até salvar.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button
label="Recarregar"
@@ -449,17 +440,11 @@ onBeforeUnmount(() => {
<Button label="Descartar" icon="pi pi-undo" severity="secondary" outlined size="small" :disabled="loading || saving || !hasPending" @click="confirmReset" />
<Button label="Salvar alterações" icon="pi pi-save" size="small" :loading="saving" :disabled="loading || !hasPending" @click="saveChanges" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<div class="flex xl:hidden shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" aria-haspopup="true" aria-controls="matrix_hero_menu" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" id="matrix_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-[340px]">
@@ -536,4 +521,4 @@ onBeforeUnmount(() => {
</Column>
</DataTable>
</div>
</template>
</template>
+10 -25
View File
@@ -349,38 +349,23 @@ onBeforeUnmount(() => {
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="p-2" />
<!-- Hero sticky -->
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Limites por Plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Configure os limites reais de cada feature por plano.</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-sliders-h" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Limites por Plano</div>
<div class="cfg-subheader__sub">Configure os limites reais de cada feature por plano.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<div class="flex xl:hidden shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" aria-haspopup="true" aria-controls="limits_hero_menu" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" id="limits_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-80">
@@ -594,4 +579,4 @@ onBeforeUnmount(() => {
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
</template>
</Dialog>
</template>
</template>
+10 -25
View File
@@ -426,39 +426,24 @@ onBeforeUnmount(() => {
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="p-2" />
<!-- Hero sticky -->
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Planos e preços</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Catálogo de planos do SaaS.</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Planos e preços</div>
<div class="cfg-subheader__sub">Catálogo de planos do SaaS.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton v-model="targetFilter" :options="targetFilterOptions" optionLabel="label" optionValue="value" size="small" />
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar plano" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<div class="flex xl:hidden shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" aria-haspopup="true" aria-controls="plans_hero_menu" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" id="plans_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
<Column field="key" header="Key" sortable />
@@ -584,4 +569,4 @@ onBeforeUnmount(() => {
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>
</template>
+10 -25
View File
@@ -450,38 +450,23 @@ onBeforeUnmount(() => {
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="p-2" />
<!-- Hero sticky -->
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Vitrine de Planos</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Configure como os planos aparecem na página pública.</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-shop" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Vitrine de Planos</div>
<div class="cfg-subheader__sub">Configure como os planos aparecem na página pública.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving || bulletSaving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || bulletSaving" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<div class="flex xl:hidden shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" aria-haspopup="true" aria-controls="showcase_hero_menu" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" id="showcase_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-80">
@@ -807,4 +792,4 @@ onBeforeUnmount(() => {
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
</template>
</Dialog>
</template>
</template>
+8 -4
View File
@@ -168,10 +168,14 @@ onMounted(async () => {
</script>
<template>
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1.1rem] font-bold tracking-tight">Segurança defesa contra bots</div>
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">Configuração da defesa em camadas para endpoints públicos (cadastro de paciente, signup, agendador).</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-shield" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Segurança defesa contra bots</div>
<div class="cfg-subheader__sub">Configuração da defesa em camadas para endpoints públicos (cadastro de paciente, signup, agendador).</div>
</div>
</div>
<!-- Card explicativo (colapsável) -->
@@ -333,39 +333,24 @@ onBeforeUnmount(() => {
</script>
<template>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="heroRef" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-orange-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Histórico de assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Auditoria read-only das mudanças de plano e status.</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-history" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Histórico de assinaturas</div>
<div class="cfg-subheader__sub">Auditoria read-only das mudanças de plano e status.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Voltar para assinaturas" icon="pi pi-arrow-left" severity="secondary" outlined size="small" :disabled="loading" @click="router.push('/saas/subscriptions')" />
<SelectButton v-model="ownerType" :options="ownerTypeOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<div class="flex xl:hidden shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card foco -->
<div v-if="isFocused" class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="flex flex-wrap items-center justify-between gap-3 p-5">
@@ -473,4 +458,4 @@ onBeforeUnmount(() => {
<div class="text-[1rem] text-[var(--text-color-secondary)]">Mostrando até 500 eventos mais recentes.</div>
</div>
</template>
</template>
@@ -431,39 +431,24 @@ onBeforeUnmount(() => {
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="heroRef" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Saúde das Assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Terapeutas: divergências entre plano (esperado) e entitlements (atual). Clínicas: exceções comerciais (features liberadas manualmente fora do plano).</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-heart" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Saúde das Assinaturas</div>
<div class="cfg-subheader__sub">Terapeutas: divergências entre plano e entitlements. Clínicas: exceções comerciais.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="fixing" @click="reloadActiveTab" />
<Button label="Recarregar tudo" icon="pi pi-sync" severity="secondary" outlined size="small" :loading="loading" :disabled="fixing" @click="fetchAll" />
<Button v-if="activeTab === 0 && totalPersonal > 0" label="Corrigir tudo (terapeutas)" icon="pi pi-wrench" severity="danger" size="small" :loading="fixing" :disabled="loading" @click="askFixAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- busca -->
<div>
<FloatLabel variant="on" class="w-full">
@@ -654,4 +639,4 @@ onBeforeUnmount(() => {
</TabPanel>
</TabView>
</div>
</template>
</template>
+8 -23
View File
@@ -425,38 +425,23 @@ onBeforeUnmount(() => {
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="heroRef" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Painel operacional do SaaS: revise plano, status e período (Clínica x Terapeuta) com segurança.</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-credit-card" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Assinaturas</div>
<div class="cfg-subheader__sub">Painel operacional do SaaS: revise plano, status e período (Clínica x Terapeuta) com segurança.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton v-model="typeFilter" :options="typeOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || savingId !== null" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="savingId !== null" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Header foco -->
<div v-if="isFocused" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex align-items-center justify-content-between gap-3 flex-wrap">
@@ -585,4 +570,4 @@ onBeforeUnmount(() => {
</template>
</DataTable>
</div>
</template>
</template>
+8 -23
View File
@@ -246,31 +246,16 @@ function sessionStatusLabel(session) {
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] shrink-0">
<i class="pi pi-headphones text-[var(--text-color)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Suporte Técnico</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gere e gerencie links seguros de acesso em modo debug</div>
</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-headphones" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Suporte Técnico</div>
<div class="cfg-subheader__sub">Gere e gerencie links seguros de acesso em modo debug.</div>
</div>
<Tag v-if="activeSessionCount > 0" :value="`${activeSessionCount} sessão${activeSessionCount > 1 ? 'ões' : ''} ativa${activeSessionCount > 1 ? 's' : ''}`" severity="warning" />
<Tag v-if="activeSessionCount > 0" :value="`${activeSessionCount} ativa${activeSessionCount > 1 ? 's' : ''}`" severity="warn" class="shrink-0" />
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Tabs -->
<TabView @tab-change="onTabChange">
<!-- Tab 0: Nova Sessão -->
@@ -215,16 +215,18 @@ onMounted(async () => {
</script>
<template>
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1.1rem] font-bold tracking-tight text-[var(--text-color)]">Recursos por Clínica</div>
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">Gerencia overrides de features por tenant. Exceções comerciais (ativar feature fora do plano) exigem motivo e ficam logadas.</div>
</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-sitemap" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Recursos por Clínica</div>
<div class="cfg-subheader__sub">Overrides de features por tenant. Exceções comerciais exigem motivo e ficam logadas.</div>
</div>
</div>
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label class="text-[0.85rem] text-[var(--text-color-secondary)]">Clínica</label>
<Select v-model="selectedTenantId" :options="tenants" optionLabel="name" optionValue="id" placeholder="Selecione…" class="w-full mt-1" filter showClear />
@@ -319,4 +321,4 @@ onMounted(async () => {
</template>
</Dialog>
</div>
</template>
</template>
+13 -8
View File
@@ -150,17 +150,22 @@ onMounted(async () => {
</script>
<template>
<div class="px-3 md:px-4 py-4 flex flex-col gap-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1.1rem] font-bold tracking-tight">Configuração Twilio</div>
<div class="text-[0.95rem] text-[var(--text-color-secondary)] mt-1">
Edite a config operacional sem redeploy. O <b>Auth Token</b> (secret) continua em variável de ambiente da Edge Function por segurança.
</div>
<div v-if="meta.updated_at" class="text-xs text-[var(--text-color-secondary)] mt-2">
Última atualização: {{ new Date(meta.updated_at).toLocaleString('pt-BR') }}
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-phone" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Configuração Twilio</div>
<div class="cfg-subheader__sub">
Edite a config operacional sem redeploy. O Auth Token (secret) continua em variável de ambiente da Edge Function por segurança.
</div>
</div>
</div>
<div v-if="meta.updated_at" class="text-xs text-[var(--text-color-secondary)] px-1">
Última atualização: {{ new Date(meta.updated_at).toLocaleString('pt-BR') }}
</div>
<!-- Card explicativo -->
<div class="rounded-md border border-indigo-400/30 bg-indigo-400/5">
<Accordion :value="helpOpen ? '0' : null" @update:value="(v) => helpOpen = (v === '0')">
+39 -5
View File
@@ -240,8 +240,28 @@ async function checkConnectionStatus() {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const instances = await res.json();
const inst = Array.isArray(instances) ? instances.find((i) => i.instance?.instanceName === credentials.value.instance_name) : null;
connectionStatus.value = inst?.instance?.status || 'close';
// Evolution v1: [{ instance: { instanceName, status } }] · v2: [{ instanceName, connectionStatus/state }]
const arr = Array.isArray(instances) ? instances : [];
const inst = arr.find((i) => {
const name = i?.instance?.instanceName ?? i?.instanceName ?? i?.name;
return name === credentials.value.instance_name;
});
const rawState = inst?.instance?.status ?? inst?.instance?.state ?? inst?.connectionStatus ?? inst?.state ?? inst?.status;
connectionStatus.value = rawState || 'close';
// Persiste no DB pra a tabela ficar em sync (mapeia pra valores que channelStatusTag entende)
if (channel.value?.id) {
const dbStatus = rawState === 'open' ? 'connected' : rawState === 'connecting' ? 'connecting' : 'disconnected';
if (channel.value.connection_status !== dbStatus) {
await supabase
.from('notification_channels')
.update({ connection_status: dbStatus, last_health_check: new Date().toISOString() })
.eq('id', channel.value.id);
channel.value.connection_status = dbStatus;
// Atualiza lista da tabela (sem bloquear)
loadAllChannels();
}
}
} catch (e) {
connectionStatus.value = 'close';
toast.add({
@@ -265,15 +285,29 @@ async function fetchQrCode() {
const res = await fetch(`${credentials.value.api_url}/instance/connect/${credentials.value.instance_name}`, { headers: { apikey: credentials.value.api_key } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const base64 = data?.base64;
// Evolution v1: data.qrcode.base64 · v2: data.base64 · variantes: data.code/qr/qrCode
const base64 =
data?.base64 ||
data?.qrcode?.base64 ||
data?.qrCode?.base64 ||
data?.qr?.base64 ||
data?.qr ||
data?.code ||
null;
if (!base64) {
if (data?.instance?.status === 'open') {
const openState =
data?.instance?.status === 'open' ||
data?.instance?.state === 'open' ||
data?.state === 'open' ||
data?.status === 'open';
if (openState) {
connectionStatus.value = 'open';
toast.add({ severity: 'success', summary: 'WhatsApp já está conectado!', life: 3000 });
qrDialog.value = false;
return;
}
throw new Error('QR Code não retornado pela API.');
console.error('[QR] Resposta inesperada da Evolution:', data);
throw new Error('QR Code não retornado pela API. Veja o console (F12) pra debug.');
}
qrCodeBase64.value = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
startQrCountdown();
@@ -614,38 +614,23 @@ onBeforeUnmount(() => {
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="p-2" />
<!-- Hero sticky -->
<div ref="heroEl" class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Intenções de assinatura</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Caixa de entrada de pagamento manual (PIX/boleto). Marque como <b>pago</b> para ativar a assinatura ou <b>cancele</b> quando o pagamento não será concluído.</div>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-inbox" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Intenções de assinatura</div>
<div class="cfg-subheader__sub">Caixa de entrada de pagamento manual (PIX/boleto). Marque como pago para ativar ou cancele quando não for concluído.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="acting" @click="refresh" />
<Button v-if="hasAnyFilter" label="Limpar filtros" icon="pi pi-times" severity="secondary" outlined size="small" :disabled="acting" @click="clearFilters" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" @click="(e) => heroMenuRef.toggle(e)" />
<Menu ref="heroMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card: Resumo + Filtros -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-center justify-between gap-3 flex-wrap mb-4">
@@ -818,4 +803,4 @@ onBeforeUnmount(() => {
</div>
</div>
</Dialog>
</template>
</template>
@@ -46,19 +46,13 @@ const activeComponent = computed(() => tabs[activeTab.value]?.component);
<template>
<div class="dev-page">
<!-- Header -->
<section class="dev-header">
<div class="flex items-center gap-3 mb-1">
<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-code text-lg" />
</div>
<div>
<h1 class="font-bold text-xl tracking-tight text-[var(--text-color)]">Desenvolvimento</h1>
<p class="text-xs text-[var(--text-color-secondary)]">
Área interna de trabalho roadmap, auditoria, concorrentes, banco
</p>
</div>
<div class="cfg-subheader mb-3.5">
<div class="cfg-subheader__icon"><i class="pi pi-code" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Desenvolvimento</div>
<div class="cfg-subheader__sub">Área interna de trabalho roadmap, auditoria, concorrentes, banco.</div>
</div>
</section>
</div>
<!-- Tabs -->
<nav class="dev-tabs">
@@ -87,14 +81,6 @@ const activeComponent = computed(() => tabs[activeTab.value]?.component);
margin: 0 auto;
}
.dev-header {
padding: 10px 14px 14px;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 10px;
margin-bottom: 14px;
}
.dev-tabs {
display: flex;
gap: 6px;
@@ -21,17 +21,25 @@ import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue'
</script>
<template>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> do Supervisor</span>
</div>
<div class="flex items-center justify-center bg-purple-100 dark:bg-purple-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-eye text-purple-500 text-xl!"></i>
<div
class="sticky mx-3 md:mx-4 mt-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-purple-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-purple-500/10 text-purple-600 dark:text-purple-400">
<i class="pi pi-eye text-lg" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Área do Supervisor</div>
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">Supervisione sessões, evolução dos pacientes e indicadores da clínica.</div>
</div>
</div>
</div>
<p class="text-muted-color text-sm mt-0">Supervisione sessões, evolução dos pacientes e indicadores da clínica.</p>
</div>
<div class="grid grid-cols-12 gap-8">
@@ -0,0 +1,569 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { formatDistanceToNow, format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { supabase } from '@/lib/supabase/client';
import { useNotificationStore } from '@/stores/notificationStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { useTenantStore } from '@/stores/tenantStore';
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const notifStore = useNotificationStore();
const conversationDrawer = useConversationDrawerStore();
const tenantStore = useTenantStore();
// State
const ownerId = ref(null);
const items = ref([]);
const loading = ref(true);
const search = ref('');
const filter = ref('all'); // all | unread | read | archived
const typeFilter = ref(null);
const headerEl = ref(null);
const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _headerObserver = null;
// Type map
const typeMap = {
new_scheduling: { icon: 'pi-inbox', color: 'text-red-500', border: 'border-red-500', label: 'Agendamento' },
new_patient: { icon: 'pi-user-plus', color: 'text-sky-500', border: 'border-sky-500', label: 'Novo paciente' },
recurrence_alert: { icon: 'pi-refresh', color: 'text-amber-500', border: 'border-amber-500', label: 'Recorrência' },
session_status: { icon: 'pi-calendar-times', color: 'text-orange-500', border: 'border-orange-500', label: 'Sessão' },
inbound_message: { icon: 'pi-whatsapp', color: 'text-emerald-500', border: 'border-emerald-500', label: 'Mensagem' }
};
function metaFor(item) {
return typeMap[item.type] || { icon: 'pi-bell', color: 'text-gray-400', border: 'border-gray-300', label: item.type };
}
// Filters + search
const quickStats = computed(() => {
const total = items.value.length;
const unread = items.value.filter((n) => !n.read_at && !n.archived).length;
const read = items.value.filter((n) => n.read_at && !n.archived).length;
const archived = items.value.filter((n) => n.archived).length;
return [
{ label: 'Total', value: total, key: 'all', cls: '' },
{ label: 'Não lidas', value: unread, key: 'unread', cls: unread > 0 ? 'qs-warn' : '' },
{ label: 'Lidas', value: read, key: 'read', cls: '' },
{ label: 'Arquivadas', value: archived, key: 'archived', cls: '' }
];
});
const filteredItems = computed(() => {
const q = search.value.trim().toLowerCase();
return items.value.filter((n) => {
if (filter.value === 'unread' && (n.read_at || n.archived)) return false;
if (filter.value === 'read' && (!n.read_at || n.archived)) return false;
if (filter.value === 'archived' && !n.archived) return false;
if (filter.value === 'all' && n.archived) return false;
if (typeFilter.value && n.type !== typeFilter.value) return false;
if (q) {
const title = (n.payload?.title || '').toLowerCase();
const detail = (n.payload?.detail || '').toLowerCase();
if (!title.includes(q) && !detail.includes(q)) return false;
}
return true;
});
});
const hasActiveFilters = computed(() => filter.value !== 'all' || !!typeFilter.value || !!search.value.trim());
function clearFilters() {
filter.value = 'all';
typeFilter.value = null;
search.value = '';
}
// Data loading
async function load() {
loading.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
ownerId.value = authData?.user?.id || null;
if (!ownerId.value) {
loading.value = false;
return;
}
const { data, error } = await supabase.from('notifications').select('*').eq('owner_id', ownerId.value).order('created_at', { ascending: false }).limit(500);
if (error) throw error;
items.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar notificações.', life: 4500 });
} finally {
loading.value = false;
}
}
// Actions
async function markRead(id) {
const now = new Date().toISOString();
const { error } = await supabase.from('notifications').update({ read_at: now }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
const item = items.value.find((n) => n.id === id);
if (item) item.read_at = now;
// Sync no store (drawer)
const storeItem = notifStore.items.find((n) => n.id === id);
if (storeItem) storeItem.read_at = now;
}
async function markUnread(id) {
const { error } = await supabase.from('notifications').update({ read_at: null }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
const item = items.value.find((n) => n.id === id);
if (item) item.read_at = null;
const storeItem = notifStore.items.find((n) => n.id === id);
if (storeItem) storeItem.read_at = null;
}
async function archive(id) {
const { error } = await supabase.from('notifications').update({ archived: true }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
const item = items.value.find((n) => n.id === id);
if (item) item.archived = true;
// Remove do store do drawer (que só lista não-arquivadas)
notifStore.items = notifStore.items.filter((n) => n.id !== id);
}
async function unarchive(id) {
const { error } = await supabase.from('notifications').update({ archived: false }).eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
const item = items.value.find((n) => n.id === id);
if (item) item.archived = false;
// Readiciona no store do drawer
if (item && !notifStore.items.find((n) => n.id === id)) {
notifStore.items.unshift({ ...item });
}
}
function confirmRemove(id) {
confirm.require({
message: 'Remover esta notificação permanentemente? Essa ação não pode ser desfeita.',
header: 'Remover notificação',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: () => remove(id)
});
}
async function remove(id) {
const { error } = await supabase.from('notifications').delete().eq('id', id);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
items.value = items.value.filter((n) => n.id !== id);
notifStore.items = notifStore.items.filter((n) => n.id !== id);
toast.add({ severity: 'success', summary: 'Removida', life: 2500 });
}
async function markAllRead() {
const unreadIds = items.value.filter((n) => !n.read_at && !n.archived).map((n) => n.id);
if (!unreadIds.length) return;
const now = new Date().toISOString();
const { error } = await supabase.from('notifications').update({ read_at: now }).in('id', unreadIds);
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 });
return;
}
items.value.forEach((n) => {
if (unreadIds.includes(n.id)) n.read_at = now;
});
notifStore.items.forEach((n) => {
if (unreadIds.includes(n.id)) n.read_at = now;
});
toast.add({ severity: 'success', summary: `${unreadIds.length} marcada${unreadIds.length > 1 ? 's' : ''} como lida${unreadIds.length > 1 ? 's' : ''}`, life: 2500 });
}
// Row actions
function handleRowClick(n) {
if (n.type === 'inbound_message') {
const payload = n.payload || {};
if (payload.patient_id) {
conversationDrawer.openForPatient(payload.patient_id);
} else if (payload.from_number) {
conversationDrawer.openForThread({
thread_key: `anon:${payload.from_number}`,
tenant_id: tenantStore.activeTenantId,
patient_id: null,
patient_name: null,
contact_number: payload.from_number,
channel: payload.channel || 'whatsapp',
message_count: 1,
unread_count: 1,
kanban_status: 'awaiting_us',
last_message_at: new Date().toISOString()
});
}
if (!n.read_at) markRead(n.id);
return;
}
const deeplink = n.payload?.deeplink;
if (deeplink) {
if (!n.read_at) markRead(n.id);
router.push(deeplink);
} else if (!n.read_at) {
markRead(n.id);
}
}
// Helpers
function timeAgo(iso) {
try {
return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ptBR });
} catch {
return '—';
}
}
function fullDate(iso) {
try {
return format(new Date(iso), "dd 'de' MMMM 'às' HH:mm", { locale: ptBR });
} catch {
return '';
}
}
function initials(item) {
return item.payload?.avatar_initials || '?';
}
// Lifecycle
onMounted(() => {
load();
if (headerSentinelRef.value) {
_headerObserver = new IntersectionObserver(
([entry]) => {
headerStuck.value = !entry.isIntersecting;
},
{ threshold: 0 }
);
_headerObserver.observe(headerSentinelRef.value);
}
});
onBeforeUnmount(() => {
_headerObserver?.disconnect();
});
</script>
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<section
ref="headerEl"
class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.07]" />
</div>
<div class="relative z-1 flex items-center gap-3">
<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-bell text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Notificações</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Histórico completo leia, arquive ou remova</div>
</div>
</div>
<div class="flex items-center gap-1 shrink-0 ml-auto">
<Button
v-if="quickStats[1].value > 0"
label="Marcar todas lidas"
icon="pi pi-check-circle"
severity="secondary"
outlined
size="small"
class="rounded-full"
:disabled="loading"
@click="markAllRead"
/>
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
</div>
</div>
</section>
<!-- Quick-stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 px-3 md:px-4 mb-3">
<template v-if="loading">
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="rounded-md" />
</template>
<template v-else>
<div
v-for="s in quickStats"
:key="s.key"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="[
filter === s.key
? 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)] bg-[var(--surface-card,#fff)]'
: s.cls === 'qs-warn'
? 'border-amber-500/25 bg-amber-500/5 hover:border-amber-500/40'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] hover:border-indigo-300/50'
]"
@click="filter = s.key"
>
<div class="text-[1.35rem] font-bold leading-none" :class="s.cls === 'qs-warn' ? 'text-amber-500' : 'text-[var(--text-color)]'">{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">{{ s.label }}</div>
</div>
</template>
</div>
<!-- Toolbar: busca + type filter + clear -->
<div class="px-3 md:px-4 mb-3 flex flex-col md:flex-row items-stretch md:items-center gap-2">
<IconField class="flex-1">
<InputIcon class="pi pi-search" />
<InputText v-model="search" placeholder="Buscar por título ou descrição…" class="w-full" />
</IconField>
<Select
v-model="typeFilter"
:options="[
{ label: 'Todos os tipos', value: null },
{ label: 'Agendamento', value: 'new_scheduling' },
{ label: 'Novo paciente', value: 'new_patient' },
{ label: 'Recorrência', value: 'recurrence_alert' },
{ label: 'Sessão', value: 'session_status' },
{ label: 'Mensagem', value: 'inbound_message' }
]"
optionLabel="label"
optionValue="value"
placeholder="Tipo"
class="md:w-[200px]"
/>
<Button v-if="hasActiveFilters" label="Limpar filtros" icon="pi pi-filter-slash" severity="secondary" outlined size="small" class="rounded-full" @click="clearFilters" />
</div>
<!-- Lista -->
<div class="px-3 md:px-4 pb-5">
<!-- Skeleton list -->
<div v-if="loading" class="flex flex-col gap-2">
<Skeleton v-for="n in 8" :key="n" height="5rem" class="rounded-md" />
</div>
<!-- Empty -->
<div v-else-if="!filteredItems.length" class="flex flex-col items-center justify-center gap-2 py-12 text-center">
<i class="pi pi-bell-slash text-4xl text-[var(--text-color-secondary)] opacity-40" />
<div class="text-[1rem] font-semibold text-[var(--text-color)]">{{ hasActiveFilters ? 'Nada encontrado' : 'Nenhuma notificação ainda' }}</div>
<div class="text-sm text-[var(--text-color-secondary)]">{{ hasActiveFilters ? 'Ajuste os filtros ou limpe-os para ver tudo.' : 'Quando algo acontecer, você será avisado aqui.' }}</div>
</div>
<!-- Items -->
<div v-else class="flex flex-col gap-2">
<div
v-for="n in filteredItems"
:key="n.id"
class="notif-row"
:class="[metaFor(n).border, !n.read_at && !n.archived ? 'notif-row--unread' : '', n.archived ? 'notif-row--archived' : '']"
role="button"
tabindex="0"
@click="handleRowClick(n)"
@keydown.enter="handleRowClick(n)"
>
<div class="notif-row__icon" :class="metaFor(n).color">
<i :class="['pi', metaFor(n).icon]" />
</div>
<div class="notif-row__avatar">{{ initials(n) }}</div>
<div class="notif-row__body">
<div class="flex items-center gap-2 flex-wrap">
<span class="notif-row__title">{{ n.payload?.title || '(sem título)' }}</span>
<span class="notif-row__type-pill">{{ metaFor(n).label }}</span>
<span v-if="n.archived" class="notif-row__type-pill notif-row__type-pill--muted">Arquivada</span>
</div>
<div class="notif-row__detail">{{ n.payload?.detail || '—' }}</div>
<div class="notif-row__time" :title="fullDate(n.created_at)">{{ timeAgo(n.created_at) }}</div>
</div>
<div class="notif-row__actions" @click.stop>
<button v-if="!n.read_at && !n.archived" class="notif-row__btn" v-tooltip.top="'Marcar como lida'" @click="markRead(n.id)">
<i class="pi pi-check" />
</button>
<button v-else-if="n.read_at && !n.archived" class="notif-row__btn" v-tooltip.top="'Marcar como não lida'" @click="markUnread(n.id)">
<i class="pi pi-envelope" />
</button>
<button v-if="!n.archived" class="notif-row__btn" v-tooltip.top="'Arquivar'" @click="archive(n.id)">
<i class="pi pi-inbox" />
</button>
<button v-else class="notif-row__btn" v-tooltip.top="'Desarquivar'" @click="unarchive(n.id)">
<i class="pi pi-undo" />
</button>
<button class="notif-row__btn notif-row__btn--danger" v-tooltip.top="'Remover'" @click="confirmRemove(n.id)">
<i class="pi pi-trash" />
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.notif-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-left-width: 3px;
border-left-style: solid;
border-top: 1px solid var(--surface-border);
border-right: 1px solid var(--surface-border);
border-bottom: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
cursor: pointer;
transition:
background 0.15s,
box-shadow 0.15s,
border-color 0.15s;
}
.notif-row:hover {
background: var(--surface-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.notif-row--unread {
background: color-mix(in srgb, var(--primary-color) 5%, var(--surface-card));
}
.notif-row--unread:hover {
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
}
.notif-row--archived {
opacity: 0.7;
}
.notif-row__icon {
flex-shrink: 0;
padding-top: 0.2rem;
font-size: 1rem;
}
.notif-row__avatar {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #38bdf8);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
display: flex;
align-items: center;
justify-content: center;
}
.notif-row__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.notif-row__title {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.notif-row__type-pill {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-color) 6%, transparent);
color: var(--text-color-secondary);
font-size: 0.65rem;
font-weight: 600;
white-space: nowrap;
}
.notif-row__type-pill--muted {
background: color-mix(in srgb, var(--text-color-secondary) 12%, transparent);
}
.notif-row__detail {
font-size: 0.82rem;
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
max-width: 100%;
}
.notif-row__time {
font-size: 0.72rem;
color: var(--text-color-secondary);
opacity: 0.7;
}
.notif-row__actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
}
.notif-row:hover .notif-row__actions,
.notif-row:focus-within .notif-row__actions {
opacity: 1;
}
.notif-row__btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-color-secondary);
display: grid;
place-items: center;
cursor: pointer;
font-size: 0.82rem;
transition:
background 0.15s,
color 0.15s;
}
.notif-row__btn:hover {
background: var(--surface-border);
color: var(--text-color);
}
.notif-row__btn--danger:hover {
background: color-mix(in srgb, #ef4444 12%, transparent);
color: #ef4444;
}
</style>
+3 -3
View File
@@ -244,7 +244,7 @@ onMounted(loadSessions);
HERO sticky
-->
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
@@ -313,7 +313,7 @@ onMounted(loadSessions);
<template v-else>
<!-- QUICK-STATS clicáveis -->
<div class="flex flex-wrap gap-2">
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2">
<div
v-for="s in quickStats"
:key="s.label"
@@ -335,7 +335,7 @@ onMounted(loadSessions);
@click="s.filter !== null ? toggleFiltroTabela(s.filter) : null"
>
<div class="text-[1.35rem] font-bold leading-none" :class="s.valCls">{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">{{ s.label }}</div>
</div>
</div>
+65 -139
View File
@@ -790,45 +790,40 @@ onMounted(async () => {
</div>
<!-- Skeleton -->
<template v-if="loading">
<div class="grid grid-cols-7 mb-0.5">
<Skeleton v-for="n in 7" :key="n" height="14px" class="mx-0.5" />
</div>
<div class="grid grid-cols-7 gap-px mt-1">
<Skeleton v-for="n in 35" :key="n" height="22px" class="rounded" />
</div>
</template>
<Skeleton v-if="loading" width="100%" height="180px" border-radius="6px" />
<template v-else>
<div class="grid grid-cols-7 mb-0.5">
<span v-for="d in diasSemana" :key="d" class="text-center text-xs font-bold text-[var(--text-color-secondary,#94a3b8)] py-0.5">{{ d }}</span>
</div>
<div class="grid grid-cols-7 gap-px">
<button
v-for="cell in calCells"
:key="cell.key"
class="relative aspect-square flex items-center justify-center text-xs font-medium rounded border-none cursor-pointer transition-colors duration-100"
:class="{
'bg-[var(--primary-color,#6366f1)] text-white font-bold rounded-md': cell.isToday,
'text-[var(--text-color-secondary,#cbd5e1)] cursor-default': cell.isOther,
'bg-indigo-500/10 text-[var(--primary-color,#6366f1)] font-bold': cell.day === selectedDay && !cell.isToday,
'text-[var(--text-color,#1e293b)] hover:bg-[var(--surface-hover,#f1f5f9)]': !cell.isToday && !cell.isOther
}"
@click="cell.day && onCalDayClick($event, cell.day)"
>
<span>{{ cell.day }}</span>
<span
v-if="cell.count"
class="absolute bottom-px right-0.5 w-1 h-1 rounded-full"
<Transition name="fade-up" appear>
<div v-if="!loading">
<div class="grid grid-cols-7 mb-0.5">
<span v-for="d in diasSemana" :key="d" class="text-center text-xs font-bold text-[var(--text-color-secondary,#94a3b8)] py-0.5">{{ d }}</span>
</div>
<div class="grid grid-cols-7 gap-px">
<button
v-for="cell in calCells"
:key="cell.key"
class="relative aspect-square flex items-center justify-center text-xs font-medium rounded border-none cursor-pointer transition-colors duration-100"
:class="{
'bg-red-500': cell.urgency === 'urg-alta',
'bg-amber-500': cell.urgency === 'urg-media',
'bg-green-500': cell.urgency === 'urg-baixa'
'bg-[var(--primary-color,#6366f1)] text-white font-bold rounded-md': cell.isToday,
'text-[var(--text-color-secondary,#cbd5e1)] cursor-default': cell.isOther,
'bg-indigo-500/10 text-[var(--primary-color,#6366f1)] font-bold': cell.day === selectedDay && !cell.isToday,
'text-[var(--text-color,#1e293b)] hover:bg-[var(--surface-hover,#f1f5f9)]': !cell.isToday && !cell.isOther
}"
/>
</button>
@click="cell.day && onCalDayClick($event, cell.day)"
>
<span>{{ cell.day }}</span>
<span
v-if="cell.count"
class="absolute bottom-px right-0.5 w-1 h-1 rounded-full"
:class="{
'bg-red-500': cell.urgency === 'urg-alta',
'bg-amber-500': cell.urgency === 'urg-media',
'bg-green-500': cell.urgency === 'urg-baixa'
}"
/>
</button>
</div>
</div>
</template>
</Transition>
</div>
<!-- Eventos do dia -->
@@ -838,23 +833,10 @@ onMounted(async () => {
</div>
<!-- Skeleton -->
<template v-if="loading">
<div class="flex flex-col gap-2">
<div v-for="n in 3" :key="n" class="aside-ev aside-ev--skeleton">
<div class="flex flex-col gap-1 min-w-[36px] items-end">
<Skeleton width="32px" height="10px" />
<Skeleton width="24px" height="8px" />
</div>
<Skeleton shape="square" size="28px" border-radius="4px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton :width="n === 1 ? '75%' : n === 2 ? '60%' : '70%'" height="10px" />
<Skeleton width="40%" height="8px" />
</div>
</div>
</div>
</template>
<Skeleton v-if="loading" width="100%" height="160px" border-radius="6px" />
<div v-else class="flex flex-col gap-2 max-h-[260px] overflow-y-auto pr-0.5">
<Transition name="fade-up" appear>
<div v-if="!loading" class="flex flex-col gap-2 max-h-[260px] overflow-y-auto pr-0.5">
<div
v-for="ev in eventosDoDia"
:key="ev.id"
@@ -883,6 +865,7 @@ onMounted(async () => {
</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>
</Transition>
</div>
<!-- Recorrências ativas -->
@@ -893,20 +876,10 @@ onMounted(async () => {
</div>
<!-- Skeleton -->
<template v-if="loading">
<div class="flex flex-col gap-2">
<div v-for="n in 4" :key="n" class="aside-rec aside-rec--skeleton">
<Skeleton shape="square" size="30px" border-radius="4px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton :width="n % 2 === 0 ? '65%' : '50%'" height="10px" />
<Skeleton width="75%" height="8px" />
</div>
<Skeleton width="28px" height="10px" />
</div>
</div>
</template>
<Skeleton v-if="loading" width="100%" height="180px" border-radius="6px" />
<div v-else class="flex flex-col gap-1.5 max-h-[210px] overflow-y-auto pr-0.5">
<Transition name="fade-up" appear>
<div v-if="!loading" 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="shrink-0" />
<div class="flex-1 min-w-0">
@@ -917,6 +890,7 @@ onMounted(async () => {
</div>
<div v-if="!recorrencias.length" class="flex items-center gap-2 py-3 text-[var(--text-color-secondary)] text-sm"><i class="pi pi-info-circle" /><span>Nenhuma recorrência ativa</span></div>
</div>
</Transition>
</div>
</aside>
@@ -925,27 +899,12 @@ onMounted(async () => {
-->
<div ref="dashHeroSentinelRef" class="h-px" />
<main class="flex-1 min-w-0 p-4 xl:p-[1.125rem_1.375rem] flex flex-col gap-4 overflow-y-auto" :style="{ marginLeft: isMobileLayout ? undefined : '272px' }">
<main class="flex-1 min-w-0 py-4 xl:py-[1.125rem] px-3 md:px-4 flex flex-col gap-4 overflow-y-auto" :style="{ marginLeft: isMobileLayout ? undefined : '272px' }">
<!-- Hero Header -->
<!-- Skeleton hero -->
<section v-if="loading" class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2.5">
<div class="flex items-center gap-3 mb-3">
<Skeleton width="40px" height="40px" border-radius="8px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton width="12rem" height="14px" />
<Skeleton width="18rem" height="11px" />
</div>
<Skeleton width="36px" height="36px" border-radius="999px" />
<Skeleton width="36px" height="36px" border-radius="999px" />
</div>
<div class="flex flex-wrap gap-2.5">
<div v-for="n in 4" :key="n" class="flex flex-col gap-1.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] flex-1 min-w-[90px]">
<Skeleton width="2rem" height="20px" />
<Skeleton width="4rem" height="10px" />
</div>
</div>
</section>
<Skeleton v-if="loading" width="100%" height="140px" border-radius="6px" />
<Transition name="fade-up" appear>
<section v-if="!loading" class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2.5" :class="{ 'rounded-tl-none rounded-tr-none': heroStuck }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-72 h-72 -top-16 -right-12 rounded-full blur-[70px] bg-indigo-500/10" />
@@ -974,7 +933,7 @@ onMounted(async () => {
<!-- Quick stats -->
<div class="relative z-1 mt-2">
<div class="flex flex-wrap gap-2.5">
<div class="grid grid-cols-2 md:grid-cols-4 gap-2.5">
<div
v-for="s in quickStats"
:key="s.label"
@@ -995,11 +954,12 @@ onMounted(async () => {
>
{{ s.value }}
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-75">{{ s.label }}</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-75 truncate">{{ s.label }}</div>
</div>
</div>
</div>
</section>
</Transition>
<!-- Toggle aside mobile -->
<button
@@ -1015,14 +975,9 @@ onMounted(async () => {
</button>
<!-- Linha do tempo -->
<section v-if="loading" class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] p-2.5">
<div class="flex items-center justify-between mb-2.5">
<Skeleton width="12rem" height="12px" />
<Skeleton width="6rem" height="12px" />
</div>
<Skeleton width="100%" height="40px" border-radius="6px" class="mt-2.5" />
</section>
<Skeleton v-if="loading" width="100%" height="110px" border-radius="6px" />
<Transition name="fade-up" appear>
<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">
@@ -1073,43 +1028,25 @@ onMounted(async () => {
</div>
</div>
</section>
</Transition>
<!-- Cards de notificação -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-3.5">
<!-- SKELETON dos cards -->
<template v-if="loading">
<div v-for="n in 4" :key="n" class="flex flex-col bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)]">
<!-- header -->
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)] bg-[var(--surface-ground,#f8fafc)]">
<Skeleton width="32px" height="32px" border-radius="6px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton :width="n % 2 === 0 ? '9rem' : '7rem'" height="12px" />
<Skeleton :width="n % 3 === 0 ? '13rem' : '10rem'" height="10px" />
</div>
</div>
<!-- body -->
<div class="flex-1 flex flex-col gap-2 px-3.5 py-3 min-h-[72px]">
<div v-for="i in 2" :key="i" class="flex items-center gap-2">
<Skeleton shape="circle" size="26px" />
<div class="flex flex-col gap-1 flex-1">
<Skeleton :width="i === 1 ? '65%' : '50%'" height="10px" />
<Skeleton width="75%" height="9px" />
</div>
</div>
</div>
<!-- footer -->
<div class="px-3.5 py-2 border-t border-[var(--surface-border,#f1f5f9)]">
<Skeleton width="5rem" height="10px" />
</div>
</div>
<Skeleton v-for="n in 4" :key="n" width="100%" height="180px" border-radius="6px" />
<!-- frase durante carregamento -->
<div class="lg:col-span-2">
<AppLoadingPhrases action="Carregando seu dashboard..." containerClass="py-8" />
</div>
</template>
<!-- Wrapper único para stagger via anim-child (duration cobre o delay do último card) -->
<Transition name="fade-up" appear :duration="700">
<div v-if="!loading" class="contents">
<!-- Agendador Online -->
<div v-if="!loading" id="card-agendador" class="dash-card rounded-md" :class="{ '': solicitacoesPendentes > 0 }">
<div id="card-agendador" class="anim-child [--delay:0ms] dash-card rounded-md" :class="{ '': solicitacoesPendentes > 0 }">
<div class="dash-card__head gap-2.5 p-2.5">
<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-inbox text-lg" />
@@ -1146,7 +1083,7 @@ onMounted(async () => {
</div>
<!-- Cadastros externos -->
<div v-if="!loading" id="card-cadastros" class="dash-card">
<div id="card-cadastros" class="anim-child [--delay:80ms] dash-card">
<div class="dash-card__head gap-2.5 p-2.5">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9">
<i class="pi pi-user-plus text-lg" />
@@ -1181,7 +1118,7 @@ onMounted(async () => {
</div>
<!-- Recorrências com alerta -->
<div v-if="!loading" id="card-recorrencias" class="dash-card">
<div id="card-recorrencias" class="anim-child [--delay:160ms] dash-card">
<div class="dash-card__head gap-2.5 p-2.5">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #f59e0b 15%, transparent); color: #f59e0b">
<i class="pi pi-refresh text-lg" />
@@ -1217,7 +1154,7 @@ onMounted(async () => {
</div>
<!-- Radar da semana -->
<div v-if="!loading" id="card-radar" class="dash-card">
<div id="card-radar" class="anim-child [--delay:240ms] dash-card">
<div class="dash-card__head gap-2.5 p-2.5">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1">
<i class="pi pi-chart-pie text-lg" />
@@ -1255,29 +1192,15 @@ onMounted(async () => {
</div>
</div>
</div>
</div><!-- /contents wrapper -->
</Transition>
</section>
<!-- Compromissos especiais -->
<section v-if="loading" class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-[1.125rem] py-3.5">
<div class="flex items-center justify-between mb-2.5">
<Skeleton width="10rem" height="12px" />
<Skeleton width="5rem" height="12px" />
</div>
<div class="flex flex-col gap-1.5">
<div v-for="n in 3" :key="n" class="flex items-center gap-2.5 px-2.5 py-2 rounded-md bg-[var(--surface-ground)]">
<Skeleton width="3px" height="28px" border-radius="4px" />
<div class="flex flex-col gap-1 flex-1">
<Skeleton :width="n === 1 ? '12rem' : n === 2 ? '9rem' : '11rem'" height="11px" />
<Skeleton width="6rem" height="10px" />
</div>
<div class="flex flex-col items-end gap-1">
<Skeleton width="4rem" height="11px" />
<Skeleton width="5rem" height="10px" />
</div>
</div>
</div>
</section>
<Skeleton v-if="loading" width="100%" height="180px" border-radius="6px" />
<Transition name="fade-up" appear>
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)]"><i class="pi pi-briefcase" /> Compromissos especiais (Em breve)</div>
@@ -1322,8 +1245,11 @@ onMounted(async () => {
</div>
</div>
</section>
</Transition>
<LoadedPhraseBlock v-if="!loading" />
<Transition name="fade-up" appear>
<LoadedPhraseBlock v-if="!loading" />
</Transition>
</main>
<!-- Menus de contexto (fora do aside para evitar visibility:hidden) -->