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:
@@ -293,3 +293,52 @@
|
||||
.fc-timegrid-more-link {
|
||||
box-shadow: 0 0 0 1px #000000;
|
||||
}
|
||||
|
||||
/* ── Subheader padrão (cfg-subheader) ───────────────────────────
|
||||
Header canônico usado nas páginas SaaS e de configurações.
|
||||
Referência: SaasAddonsPage, SaasNotificationTemplatesPage. */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ const props = defineProps({
|
||||
|
||||
extraPayload: { type: Object, default: () => ({}) },
|
||||
|
||||
// Pré-preenchimento (usado ao converter número desconhecido em paciente, etc)
|
||||
initialData: { type: Object, default: () => ({}) },
|
||||
|
||||
closeOnCreated: { type: Boolean, default: true },
|
||||
resetOnOpen: { type: Boolean, default: true }
|
||||
});
|
||||
@@ -120,9 +123,10 @@ watch(
|
||||
);
|
||||
|
||||
function reset() {
|
||||
form.nome_completo = '';
|
||||
form.email_principal = '';
|
||||
form.telefone = '';
|
||||
const init = props.initialData || {};
|
||||
form.nome_completo = init.nome_completo ?? '';
|
||||
form.email_principal = init.email_principal ?? '';
|
||||
form.telefone = init.telefone ?? '';
|
||||
}
|
||||
|
||||
function close() {
|
||||
|
||||
@@ -20,7 +20,7 @@ import TabView from 'primevue/tabview';
|
||||
import TabPanel from 'primevue/tabpanel';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import InputSwitch from 'primevue/inputswitch';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import { fetchSlotsRegras, upsertSlotRegra } from '@/services/agendaConfigService';
|
||||
@@ -142,7 +142,7 @@ onMounted(load);
|
||||
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-12 flex items-center gap-3">
|
||||
<InputSwitch v-model="model[d.value].ativo" />
|
||||
<ToggleSwitch v-model="model[d.value].ativo" />
|
||||
<div>
|
||||
<div class="text-900 font-medium">Ativo</div>
|
||||
<div class="text-600 text-sm">Se desligado, o online não oferece horários nesse dia.</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,372 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — GlobalInboundNotifier
|
||||
|--------------------------------------------------------------------------
|
||||
| Componente global que escuta INSERTs de conversation_messages (direction
|
||||
| inbound) e mostra card flutuante no canto inferior direito, além de tocar
|
||||
| som opcional. Mesmo estando em outra tela, o usuário é avisado da nova
|
||||
| mensagem e pode clicar "Abrir" pra ir direto à conversa.
|
||||
|
|
||||
| Monta uma vez em AppLayout.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import { logEvent, logError } from '@/support/supportLogger';
|
||||
|
||||
const LOG_SRC = 'GlobalInboundNotifier';
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const drawerStore = useConversationDrawerStore();
|
||||
|
||||
const activeNotifs = ref([]); // array de popups ativos
|
||||
let channel = null;
|
||||
|
||||
const SOUND_KEY = 'agenciapsi.inbound_sound_enabled';
|
||||
const soundEnabled = ref(true);
|
||||
|
||||
function loadSoundPref() {
|
||||
try {
|
||||
soundEnabled.value = localStorage.getItem(SOUND_KEY) !== 'false';
|
||||
} catch { soundEnabled.value = true; }
|
||||
}
|
||||
|
||||
function toggleSound() {
|
||||
soundEnabled.value = !soundEnabled.value;
|
||||
try {
|
||||
localStorage.setItem(SOUND_KEY, soundEnabled.value ? 'true' : 'false');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function playBeep() {
|
||||
if (!soundEnabled.value) return;
|
||||
try {
|
||||
// WebAudio API — beep sintético de 2 tons curtos
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const now = ctx.currentTime;
|
||||
function tone(freq, start, dur, vol = 0.15) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(0, now + start);
|
||||
gain.gain.linearRampToValueAtTime(vol, now + start + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + start + dur);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start(now + start);
|
||||
osc.stop(now + start + dur);
|
||||
}
|
||||
tone(880, 0, 0.12);
|
||||
tone(1320, 0.14, 0.12);
|
||||
// Fecha o context após som
|
||||
setTimeout(() => { try { ctx.close(); } catch {} }, 500);
|
||||
} catch {
|
||||
// se WebAudio falhar (sem interação prévia, etc), apenas ignora
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(s, n = 80) {
|
||||
if (!s) return '';
|
||||
const str = String(s).replace(/\s+/g, ' ').trim();
|
||||
return str.length > n ? str.slice(0, n - 1) + '…' : str;
|
||||
}
|
||||
|
||||
async function showNotif(msg) {
|
||||
logEvent(LOG_SRC, 'showNotif chamado', { id: msg.id, from: msg.from_number });
|
||||
// Se o drawer dessa thread já está aberto, não notifica (o user já vê)
|
||||
if (drawerStore.isOpen && drawerStore.messageBelongsToCurrentThread(msg)) {
|
||||
logEvent(LOG_SRC, 'suprimido: drawer aberto na thread', { id: msg.id });
|
||||
return;
|
||||
}
|
||||
// Se está na rota /conversas com aba visível, também não notifica (já tá no Kanban)
|
||||
const onConversasRoute = typeof window !== 'undefined' && String(window.location.pathname).includes('/conversas');
|
||||
if (onConversasRoute && document.visibilityState === 'visible') {
|
||||
logEvent(LOG_SRC, 'suprimido: na rota /conversas visível', { id: msg.id });
|
||||
return;
|
||||
}
|
||||
|
||||
let name = msg.from_number || 'Desconhecido';
|
||||
if (msg.patient_id) {
|
||||
const { data } = await supabase.from('patients').select('nome_completo').eq('id', msg.patient_id).maybeSingle();
|
||||
if (data?.nome_completo) name = data.nome_completo;
|
||||
}
|
||||
|
||||
const notif = {
|
||||
id: msg.id,
|
||||
name,
|
||||
body: msg.body || (msg.media_url ? '[mídia]' : ''),
|
||||
patient_id: msg.patient_id,
|
||||
from_number: msg.from_number,
|
||||
channel: msg.channel
|
||||
};
|
||||
|
||||
activeNotifs.value.push(notif);
|
||||
logEvent(LOG_SRC, 'popup push', { total: activeNotifs.value.length });
|
||||
playBeep();
|
||||
|
||||
setTimeout(() => dismiss(notif.id), 10000);
|
||||
}
|
||||
|
||||
function dismiss(id) {
|
||||
activeNotifs.value = activeNotifs.value.filter(n => n.id !== id);
|
||||
}
|
||||
|
||||
async function openNotif(notif) {
|
||||
dismiss(notif.id);
|
||||
if (notif.patient_id) {
|
||||
await drawerStore.openForPatient(notif.patient_id);
|
||||
} else {
|
||||
// thread anônima (número não vinculado a paciente)
|
||||
await drawerStore.openForThread({
|
||||
thread_key: `anon:${notif.from_number}`,
|
||||
tenant_id: tenantStore.activeTenantId,
|
||||
patient_id: null,
|
||||
patient_name: null,
|
||||
contact_number: notif.from_number,
|
||||
channel: notif.channel,
|
||||
message_count: 1,
|
||||
unread_count: 1,
|
||||
kanban_status: 'awaiting_us',
|
||||
last_message_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function channelIcon(ch) {
|
||||
const map = { whatsapp: 'pi-whatsapp', sms: 'pi-comment', email: 'pi-envelope' };
|
||||
return map[ch] || 'pi-bell';
|
||||
}
|
||||
|
||||
function subscribe() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
logEvent(LOG_SRC, 'subscribe skipped — sem tenant');
|
||||
return;
|
||||
}
|
||||
if (channel) supabase.removeChannel(channel);
|
||||
logEvent(LOG_SRC, 'subscribing', { tenantId });
|
||||
channel = supabase
|
||||
.channel(`global_inbound_${tenantId}_${Date.now()}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'conversation_messages',
|
||||
filter: `tenant_id=eq.${tenantId}`
|
||||
},
|
||||
(payload) => {
|
||||
const m = payload.new;
|
||||
logEvent(LOG_SRC, 'INSERT recebido', {
|
||||
id: m.id,
|
||||
direction: m.direction,
|
||||
from: m.from_number,
|
||||
preview: m.body?.slice(0, 40)
|
||||
});
|
||||
if (m.direction !== 'inbound') {
|
||||
logEvent(LOG_SRC, 'ignorando (não é inbound)', { direction: m.direction });
|
||||
return;
|
||||
}
|
||||
showNotif(m);
|
||||
}
|
||||
)
|
||||
.subscribe((status) => {
|
||||
logEvent(LOG_SRC, 'channel status', { status });
|
||||
});
|
||||
}
|
||||
|
||||
function unsubscribe() {
|
||||
if (channel) {
|
||||
supabase.removeChannel(channel);
|
||||
channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSoundPref();
|
||||
subscribe();
|
||||
});
|
||||
|
||||
onUnmounted(() => unsubscribe());
|
||||
|
||||
watch(() => tenantStore.activeTenantId, () => subscribe());
|
||||
|
||||
// expõe toggle pra quem quiser usar (ex: na aside do CRM)
|
||||
defineExpose({ soundEnabled, toggleSound });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="global-notif-container" aria-live="polite">
|
||||
<TransitionGroup name="notif-slide" tag="div" class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="n in activeNotifs"
|
||||
:key="n.id"
|
||||
class="global-notif-card"
|
||||
role="alert"
|
||||
>
|
||||
<div class="notif-icon" :class="n.channel === 'whatsapp' ? 'text-emerald-600 bg-emerald-500/10' : 'text-blue-600 bg-blue-500/10'">
|
||||
<i :class="['pi', channelIcon(n.channel)]" />
|
||||
</div>
|
||||
<div class="notif-body">
|
||||
<div class="notif-header">
|
||||
<span class="notif-name">{{ n.name }}</span>
|
||||
<span class="notif-channel">{{ n.channel === 'whatsapp' ? 'WhatsApp' : n.channel }}</span>
|
||||
</div>
|
||||
<div class="notif-preview">{{ truncate(n.body, 80) }}</div>
|
||||
</div>
|
||||
<div class="notif-actions">
|
||||
<Button label="Abrir" size="small" severity="success" class="!text-xs" @click="openNotif(n)" />
|
||||
<button class="notif-close" title="Dispensar" @click="dismiss(n.id)">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Toggle de som (mini, aparece se tem notificação ou pode ficar sempre visível) -->
|
||||
<button
|
||||
v-if="activeNotifs.length > 0"
|
||||
class="sound-toggle"
|
||||
:title="soundEnabled ? 'Som ativado (clique pra desativar)' : 'Som desativado'"
|
||||
@click="toggleSound"
|
||||
>
|
||||
<i :class="soundEnabled ? 'pi pi-volume-up' : 'pi pi-volume-off'" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.global-notif-container {
|
||||
position: fixed;
|
||||
bottom: 1.25rem;
|
||||
right: 1.25rem;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
width: min(380px, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.global-notif-card {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e5e7eb);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15), 0 3px 10px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.notif-icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.notif-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.notif-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color, #111827);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.notif-channel {
|
||||
font-size: 0.65rem;
|
||||
padding: 0 0.35rem;
|
||||
background: var(--surface-ground, #f3f4f6);
|
||||
color: var(--text-color-secondary, #6b7280);
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-preview {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-secondary, #6b7280);
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notif-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notif-close {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary, #6b7280);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.notif-close:hover {
|
||||
background: var(--surface-ground, #f3f4f6);
|
||||
}
|
||||
|
||||
.sound-toggle {
|
||||
pointer-events: auto;
|
||||
margin-top: 0.5rem;
|
||||
align-self: flex-end;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--surface-border, #e5e7eb);
|
||||
background: var(--surface-card, #fff);
|
||||
color: var(--text-color-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.sound-toggle:hover {
|
||||
background: var(--surface-ground, #f3f4f6);
|
||||
color: var(--text-color, #111827);
|
||||
}
|
||||
|
||||
/* Animação de entrada/saída */
|
||||
.notif-slide-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.notif-slide-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
.notif-slide-enter-from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
.notif-slide-leave-to {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -15,15 +15,50 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useNotificationStore } from '@/stores/notificationStore';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import {
|
||||
useNotificationStore,
|
||||
requestBrowserNotificationPermission,
|
||||
setBrowserNotificationEnabled,
|
||||
getBrowserNotificationEnabled
|
||||
} from '@/stores/notificationStore';
|
||||
import NotificationItem from './NotificationItem.vue';
|
||||
|
||||
const store = useNotificationStore();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const filter = ref('unread'); // 'unread' | 'all'
|
||||
const browserNotifOn = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
browserNotifOn.value = getBrowserNotificationEnabled();
|
||||
});
|
||||
|
||||
async function toggleBrowserNotif() {
|
||||
if (browserNotifOn.value) {
|
||||
// desliga
|
||||
setBrowserNotificationEnabled(false);
|
||||
browserNotifOn.value = false;
|
||||
toast.add({ severity: 'info', summary: 'Notificações do browser desligadas', life: 2500 });
|
||||
return;
|
||||
}
|
||||
const granted = await requestBrowserNotificationPermission();
|
||||
if (granted) {
|
||||
setBrowserNotificationEnabled(true);
|
||||
browserNotifOn.value = true;
|
||||
toast.add({ severity: 'success', summary: 'Notificações do browser ativadas', life: 2500 });
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Permissão negada',
|
||||
detail: 'Habilite nas configurações do browser se mudar de ideia.',
|
||||
life: 4000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => store.drawerOpen,
|
||||
@@ -57,6 +92,16 @@ function goToHistory() {
|
||||
<div class="notification-drawer__header-content">
|
||||
<span class="notification-drawer__title">Notificações</span>
|
||||
<Badge v-if="store.unreadCount > 0" :value="store.unreadCount > 99 ? '99+' : store.unreadCount" severity="danger" />
|
||||
<Button
|
||||
:icon="browserNotifOn ? 'pi pi-bell' : 'pi pi-bell-slash'"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="ml-auto"
|
||||
:title="browserNotifOn ? 'Desativar notificações do browser' : 'Ativar notificações do browser'"
|
||||
@click="toggleBrowserNotif"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useRouter } from 'vue-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { useNotificationStore } from '@/stores/notificationStore';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true }
|
||||
@@ -29,12 +31,15 @@ const emit = defineEmits(['read', 'archive']);
|
||||
|
||||
const router = useRouter();
|
||||
const store = useNotificationStore();
|
||||
const conversationDrawer = useConversationDrawerStore();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const typeMap = {
|
||||
new_scheduling: { icon: 'pi-inbox', border: 'border-red-500' },
|
||||
new_patient: { icon: 'pi-user-plus', border: 'border-sky-500' },
|
||||
recurrence_alert: { icon: 'pi-refresh', border: 'border-amber-500' },
|
||||
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500' }
|
||||
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500' },
|
||||
inbound_message: { icon: 'pi-whatsapp', border: 'border-emerald-500' }
|
||||
};
|
||||
|
||||
const meta = computed(() => typeMap[props.item.type] || { icon: 'pi-bell', border: 'border-gray-300' });
|
||||
@@ -45,6 +50,31 @@ const timeAgo = computed(() => formatDistanceToNow(new Date(props.item.created_a
|
||||
const initials = computed(() => props.item.payload?.avatar_initials || '?');
|
||||
|
||||
function handleRowClick() {
|
||||
// Inbound message → abre drawer global (evita 403 em rota admin/therapist)
|
||||
if (props.item.type === 'inbound_message') {
|
||||
const payload = props.item.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()
|
||||
});
|
||||
}
|
||||
store.drawerOpen = false;
|
||||
emit('read', props.item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Outros tipos: segue o deeplink normal
|
||||
const deeplink = props.item.payload?.deeplink;
|
||||
if (deeplink) {
|
||||
router.push(deeplink);
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — GlobalSearch (topbar)
|
||||
|--------------------------------------------------------------------------
|
||||
| Busca global com atalho Ctrl+K / ⌘+K. Consulta a RPC `search_global` e
|
||||
| mostra resultados agrupados por entidade + ações rápidas client-side.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { searchPages } from './pagesIndex';
|
||||
|
||||
const router = useRouter();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const rootEl = ref(null);
|
||||
const inputEl = ref(null);
|
||||
const query = ref('');
|
||||
const results = ref({ patients: [], appointments: [], documents: [], services: [], intakes: [] });
|
||||
const loading = ref(false);
|
||||
const showPanel = ref(false);
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Ações estáticas (client-side, respondem na hora)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const STATIC_ACTIONS = [
|
||||
{ id: 'act_new_patient', label: 'Novo paciente', icon: 'pi pi-user-plus', sublabel: 'Cadastrar paciente', to: '/therapist/patients/cadastro', keywords: ['novo','paciente','cadastrar','criar','add'] },
|
||||
{ id: 'act_agenda', label: 'Agenda', icon: 'pi pi-calendar', sublabel: 'Ver calendário', to: '/therapist/agenda', keywords: ['agenda','calendario','sessoes','hoje'] },
|
||||
{ id: 'act_patients', label: 'Pacientes', icon: 'pi pi-users', sublabel: 'Lista de pacientes', to: '/therapist/patients', keywords: ['pacientes','lista','todos'] },
|
||||
{ id: 'act_financial', label: 'Financeiro', icon: 'pi pi-dollar', sublabel: 'Dashboard financeiro', to: '/therapist/financeiro', keywords: ['financeiro','cobrancas','pagamentos','dinheiro'] },
|
||||
{ id: 'act_documents', label: 'Documentos', icon: 'pi pi-file', sublabel: 'Gestão de documentos', to: '/therapist/documents', keywords: ['documentos','arquivos','anexos'] },
|
||||
{ id: 'act_services', label: 'Serviços', icon: 'pi pi-briefcase', sublabel: 'Precificação de serviços', to: '/configuracoes/precificacao', keywords: ['servicos','precificacao','precos','modalidade'] },
|
||||
{ id: 'act_settings', label: 'Configurações', icon: 'pi pi-cog', sublabel: 'Preferências', to: '/configuracoes', keywords: ['configuracoes','settings','preferencias','ajustes'] }
|
||||
];
|
||||
|
||||
function normalize(s) {
|
||||
return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
const filteredActions = computed(() => {
|
||||
const q = normalize(query.value);
|
||||
if (!q) return STATIC_ACTIONS.slice(0, 4); // quick defaults quando vazio
|
||||
return STATIC_ACTIONS.filter((a) => {
|
||||
const hay = normalize(a.label + ' ' + (a.keywords || []).join(' '));
|
||||
return hay.includes(q);
|
||||
}).slice(0, 4);
|
||||
});
|
||||
|
||||
// Páginas do app (client-side, filtrado por papel ativo)
|
||||
const filteredPages = computed(() => {
|
||||
const q = query.value.trim();
|
||||
const role = tenantStore?.activeRole || null;
|
||||
if (!q) return []; // só mostra páginas quando o usuário busca algo
|
||||
return searchPages(q, role, 5);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Flat list pra navegação por teclado
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const flatList = computed(() => {
|
||||
const out = [];
|
||||
filteredActions.value.forEach((a, i) => out.push({ group: 'actions', item: a, idx: i }));
|
||||
results.value.patients.forEach((p, i) => out.push({ group: 'patients', item: p, idx: i }));
|
||||
results.value.intakes.forEach((r, i) => out.push({ group: 'intakes', item: r, idx: i }));
|
||||
results.value.appointments.forEach((a, i) => out.push({ group: 'appointments', item: a, idx: i }));
|
||||
results.value.documents.forEach((d, i) => out.push({ group: 'documents', item: d, idx: i }));
|
||||
results.value.services.forEach((s, i) => out.push({ group: 'services', item: s, idx: i }));
|
||||
filteredPages.value.forEach((p, i) => out.push({ group: 'pages', item: p, idx: i }));
|
||||
return out;
|
||||
});
|
||||
|
||||
function findFlatIndex(group, idx) {
|
||||
return flatList.value.findIndex((e) => e.group === group && e.idx === idx);
|
||||
}
|
||||
|
||||
const hasAnyResult = computed(() =>
|
||||
filteredActions.value.length
|
||||
|| results.value.patients.length
|
||||
|| results.value.intakes.length
|
||||
|| results.value.appointments.length
|
||||
|| results.value.documents.length
|
||||
|| results.value.services.length
|
||||
|| filteredPages.value.length
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Fetch (debounced, com controle de ordem)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
let debounceT = null;
|
||||
let searchSeq = 0;
|
||||
|
||||
function resetResults() {
|
||||
results.value = { patients: [], appointments: [], documents: [], services: [], intakes: [] };
|
||||
}
|
||||
|
||||
watch(query, (v) => {
|
||||
if (debounceT) clearTimeout(debounceT);
|
||||
const q = String(v || '').trim();
|
||||
if (q.length < 2) {
|
||||
resetResults();
|
||||
activeIndex.value = flatList.value.length ? 0 : -1;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
const mySeq = ++searchSeq;
|
||||
debounceT = setTimeout(async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 });
|
||||
if (mySeq !== searchSeq) return; // resposta antiga, descarta
|
||||
if (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[search_global] erro:', error);
|
||||
resetResults();
|
||||
} else {
|
||||
results.value = {
|
||||
patients: Array.isArray(data?.patients) ? data.patients : [],
|
||||
appointments: Array.isArray(data?.appointments) ? data.appointments : [],
|
||||
documents: Array.isArray(data?.documents) ? data.documents : [],
|
||||
services: Array.isArray(data?.services) ? data.services : [],
|
||||
intakes: Array.isArray(data?.intakes) ? data.intakes : []
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
if (mySeq !== searchSeq) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[search_global] exceção:', e);
|
||||
resetResults();
|
||||
} finally {
|
||||
if (mySeq === searchSeq) loading.value = false;
|
||||
}
|
||||
activeIndex.value = flatList.value.length ? 0 : -1;
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Teclado
|
||||
// ────────────────────────────────────────────────────────────
|
||||
function focusInput() {
|
||||
nextTick(() => {
|
||||
const inst = inputEl.value;
|
||||
const el = inst?.$el?.tagName === 'INPUT' ? inst.$el : inst?.$el?.querySelector?.('input');
|
||||
el?.focus?.();
|
||||
try { el?.select?.(); } catch { /* ignore */ }
|
||||
});
|
||||
}
|
||||
|
||||
function onGlobalKeydown(e) {
|
||||
const isK = e.key?.toLowerCase() === 'k';
|
||||
const cmd = e.ctrlKey || e.metaKey;
|
||||
if (cmd && isK) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showPanel.value = true;
|
||||
focusInput();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && showPanel.value) {
|
||||
showPanel.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onInputKeydown(e) {
|
||||
const n = flatList.value.length;
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (!n) return;
|
||||
e.preventDefault();
|
||||
activeIndex.value = (activeIndex.value + 1 + n) % n;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (!n) return;
|
||||
e.preventDefault();
|
||||
activeIndex.value = (activeIndex.value - 1 + n) % n;
|
||||
} else if (e.key === 'Enter') {
|
||||
const entry = flatList.value[activeIndex.value];
|
||||
if (entry) {
|
||||
e.preventDefault();
|
||||
goTo(entry);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
showPanel.value = false;
|
||||
const inst = inputEl.value;
|
||||
const el = inst?.$el?.tagName === 'INPUT' ? inst.$el : inst?.$el?.querySelector?.('input');
|
||||
el?.blur?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function goTo(entry) {
|
||||
const target = entry?.item?.to || entry?.item?.deeplink || entry?.item?.path;
|
||||
if (!target) return;
|
||||
showPanel.value = false;
|
||||
query.value = '';
|
||||
resetResults();
|
||||
activeIndex.value = -1;
|
||||
await router.push(target);
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
showPanel.value = true;
|
||||
if (flatList.value.length && activeIndex.value < 0) activeIndex.value = 0;
|
||||
}
|
||||
|
||||
function onDocMouseDown(e) {
|
||||
if (!showPanel.value) return;
|
||||
const el = rootEl.value;
|
||||
if (!el) return;
|
||||
if (!el.contains(e.target)) showPanel.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onGlobalKeydown, true);
|
||||
document.addEventListener('mousedown', onDocMouseDown);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceT) clearTimeout(debounceT);
|
||||
window.removeEventListener('keydown', onGlobalKeydown, true);
|
||||
document.removeEventListener('mousedown', onDocMouseDown);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// UI helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const kbdIsMac = typeof navigator !== 'undefined' && /Mac|iP(ad|od|hone)/i.test(navigator.platform || '');
|
||||
const kbdModifier = kbdIsMac ? '⌘' : 'Ctrl';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="gs-root">
|
||||
<div class="gs-field">
|
||||
<IconField class="gs-field__wrap">
|
||||
<InputIcon class="pi pi-search gs-field__icon" />
|
||||
<InputText
|
||||
ref="inputEl"
|
||||
v-model="query"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Buscar pacientes, agenda, documentos…"
|
||||
class="gs-field__input"
|
||||
@focus="onFocus"
|
||||
@keydown="onInputKeydown"
|
||||
/>
|
||||
</IconField>
|
||||
<span class="gs-field__kbd" aria-hidden="true">
|
||||
<kbd>{{ kbdModifier }}</kbd>
|
||||
<kbd>K</kbd>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="showPanel" class="gs-panel" role="listbox">
|
||||
<div v-if="loading" class="gs-panel__state">
|
||||
<i class="pi pi-spin pi-spinner" /> buscando…
|
||||
</div>
|
||||
|
||||
<div v-else-if="query.trim().length >= 2 && !hasAnyResult" class="gs-panel__state">
|
||||
Nada encontrado pra <b>{{ query }}</b>.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Ações -->
|
||||
<div v-if="filteredActions.length" class="gs-group">
|
||||
<div class="gs-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
|
||||
<button
|
||||
v-for="(a, i) in filteredActions"
|
||||
:key="a.id"
|
||||
type="button"
|
||||
class="gs-item"
|
||||
:class="{ 'is-active': findFlatIndex('actions', i) === activeIndex }"
|
||||
@mouseenter="activeIndex = findFlatIndex('actions', i)"
|
||||
@click="goTo({ group: 'actions', item: a, idx: i })"
|
||||
>
|
||||
<span class="gs-item__icon"><i :class="a.icon" /></span>
|
||||
<span class="gs-item__main">
|
||||
<span class="gs-item__label">{{ a.label }}</span>
|
||||
<span class="gs-item__sub">{{ a.sublabel }}</span>
|
||||
</span>
|
||||
<i class="gs-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pacientes -->
|
||||
<div v-if="results.patients.length" class="gs-group">
|
||||
<div class="gs-group__title">Pacientes</div>
|
||||
<button
|
||||
v-for="(p, i) in results.patients"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="gs-item"
|
||||
:class="{ 'is-active': findFlatIndex('patients', i) === activeIndex }"
|
||||
@mouseenter="activeIndex = findFlatIndex('patients', i)"
|
||||
@click="goTo({ group: 'patients', item: p, idx: i })"
|
||||
>
|
||||
<span class="gs-item__avatar">
|
||||
<img v-if="p.avatar_url" :src="p.avatar_url" :alt="p.label" />
|
||||
<i v-else class="pi pi-user" />
|
||||
</span>
|
||||
<span class="gs-item__main">
|
||||
<span class="gs-item__label">{{ p.label }}</span>
|
||||
<span class="gs-item__sub">{{ p.sublabel }}</span>
|
||||
</span>
|
||||
<i class="gs-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cadastros pendentes (intakes) -->
|
||||
<div v-if="results.intakes.length" class="gs-group">
|
||||
<div class="gs-group__title">Cadastros pendentes</div>
|
||||
<button
|
||||
v-for="(r, i) in results.intakes"
|
||||
:key="r.id"
|
||||
type="button"
|
||||
class="gs-item"
|
||||
:class="{ 'is-active': findFlatIndex('intakes', i) === activeIndex }"
|
||||
@mouseenter="activeIndex = findFlatIndex('intakes', i)"
|
||||
@click="goTo({ group: 'intakes', item: r, idx: i })"
|
||||
>
|
||||
<span class="gs-item__icon gs-item__icon--amber"><i class="pi pi-inbox" /></span>
|
||||
<span class="gs-item__main">
|
||||
<span class="gs-item__label">{{ r.label }}</span>
|
||||
<span class="gs-item__sub">{{ r.sublabel }}</span>
|
||||
</span>
|
||||
<i class="gs-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Agendamentos -->
|
||||
<div v-if="results.appointments.length" class="gs-group">
|
||||
<div class="gs-group__title">Agendamentos</div>
|
||||
<button
|
||||
v-for="(a, i) in results.appointments"
|
||||
:key="a.id"
|
||||
type="button"
|
||||
class="gs-item"
|
||||
:class="{ 'is-active': findFlatIndex('appointments', i) === activeIndex }"
|
||||
@mouseenter="activeIndex = findFlatIndex('appointments', i)"
|
||||
@click="goTo({ group: 'appointments', item: a, idx: i })"
|
||||
>
|
||||
<span class="gs-item__icon gs-item__icon--blue"><i class="pi pi-calendar" /></span>
|
||||
<span class="gs-item__main">
|
||||
<span class="gs-item__label">{{ a.label }}</span>
|
||||
<span class="gs-item__sub">{{ a.sublabel }}</span>
|
||||
</span>
|
||||
<i class="gs-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Documentos -->
|
||||
<div v-if="results.documents.length" class="gs-group">
|
||||
<div class="gs-group__title">Documentos</div>
|
||||
<button
|
||||
v-for="(d, i) in results.documents"
|
||||
:key="d.id"
|
||||
type="button"
|
||||
class="gs-item"
|
||||
:class="{ 'is-active': findFlatIndex('documents', i) === activeIndex }"
|
||||
@mouseenter="activeIndex = findFlatIndex('documents', i)"
|
||||
@click="goTo({ group: 'documents', item: d, idx: i })"
|
||||
>
|
||||
<span class="gs-item__icon gs-item__icon--purple"><i class="pi pi-file" /></span>
|
||||
<span class="gs-item__main">
|
||||
<span class="gs-item__label">{{ d.label }}</span>
|
||||
<span class="gs-item__sub">{{ d.sublabel }}</span>
|
||||
</span>
|
||||
<i class="gs-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Serviços -->
|
||||
<div v-if="results.services.length" class="gs-group">
|
||||
<div class="gs-group__title">Serviços</div>
|
||||
<button
|
||||
v-for="(s, i) in results.services"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="gs-item"
|
||||
:class="{ 'is-active': findFlatIndex('services', i) === activeIndex }"
|
||||
@mouseenter="activeIndex = findFlatIndex('services', i)"
|
||||
@click="goTo({ group: 'services', item: s, idx: i })"
|
||||
>
|
||||
<span class="gs-item__icon gs-item__icon--orange"><i class="pi pi-briefcase" /></span>
|
||||
<span class="gs-item__main">
|
||||
<span class="gs-item__label">{{ s.label }}</span>
|
||||
<span class="gs-item__sub">{{ s.sublabel }}</span>
|
||||
</span>
|
||||
<i class="gs-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Páginas do app -->
|
||||
<div v-if="filteredPages.length" class="gs-group">
|
||||
<div class="gs-group__title">Páginas</div>
|
||||
<button
|
||||
v-for="(p, i) in filteredPages"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="gs-item"
|
||||
:class="{ 'is-active': findFlatIndex('pages', i) === activeIndex }"
|
||||
@mouseenter="activeIndex = findFlatIndex('pages', i)"
|
||||
@click="goTo({ group: 'pages', item: p, idx: i })"
|
||||
>
|
||||
<span class="gs-item__icon gs-item__icon--slate"><i :class="p.icon" /></span>
|
||||
<span class="gs-item__main">
|
||||
<span class="gs-item__label">{{ p.label }}</span>
|
||||
<span class="gs-item__sub">{{ p.sublabel }}</span>
|
||||
</span>
|
||||
<i class="gs-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hint -->
|
||||
<div v-if="!query.trim()" class="gs-panel__hint">
|
||||
<i class="pi pi-info-circle" />
|
||||
Digite ao menos 2 caracteres para buscar em pacientes, agenda, documentos e serviços.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gs-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
min-width: 220px;
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
|
||||
.gs-field { position: relative; display: flex; align-items: center; }
|
||||
.gs-field__wrap { flex: 1; min-width: 0; }
|
||||
.gs-field__icon { font-size: 0.82rem; opacity: 0.6; }
|
||||
|
||||
.gs-field__wrap :deep(.p-inputtext) {
|
||||
width: 100%;
|
||||
padding: 8px 56px 8px 34px;
|
||||
font-size: 0.82rem;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: var(--p-content-background, var(--surface-card));
|
||||
border: 1px solid var(--p-content-border-color, var(--surface-border));
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
.gs-field__wrap :deep(.p-inputtext:hover) {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--surface-border));
|
||||
}
|
||||
.gs-field__wrap :deep(.p-inputtext:focus) {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gs-field__kbd {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gs-field__kbd kbd {
|
||||
font-family: inherit;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--text-color) 7%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--text-color) 10%, transparent);
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gs-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 3100;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px -14px rgba(0, 0, 0, 0.3);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.gs-panel__state {
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.gs-panel__hint {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.gs-group + .gs-group {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.gs-group__title {
|
||||
padding: 8px 10px 4px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.gs-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.gs-item.is-active {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 24%, transparent);
|
||||
}
|
||||
|
||||
.gs-item__icon,
|
||||
.gs-item__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.gs-item__icon {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.gs-item__icon--blue {
|
||||
background: color-mix(in srgb, #60a5fa 14%, transparent);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.gs-item__icon--purple {
|
||||
background: color-mix(in srgb, #c084fc 14%, transparent);
|
||||
color: #c084fc;
|
||||
}
|
||||
.gs-item__icon--orange {
|
||||
background: color-mix(in srgb, #fb923c 14%, transparent);
|
||||
color: #fb923c;
|
||||
}
|
||||
.gs-item__icon--amber {
|
||||
background: color-mix(in srgb, #f59e0b 14%, transparent);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.gs-item__icon--slate {
|
||||
background: color-mix(in srgb, var(--text-color) 8%, transparent);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.gs-item__avatar {
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--text-color) 6%, transparent);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.gs-item__avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.gs-item__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.gs-item__label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gs-item__sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gs-item__go {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.gs-item.is-active .gs-item__go { opacity: 1; color: var(--primary-color); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 820px) { .gs-root { max-width: 260px; flex-basis: 220px; } }
|
||||
|
||||
/* Compact: ≤ 640px → só ícone + kbd visíveis (input textual colapsado) */
|
||||
@media (max-width: 640px) {
|
||||
.gs-root {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.gs-field__wrap :deep(.p-inputtext) {
|
||||
width: 92px;
|
||||
padding: 8px 42px 8px 30px;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gs-field__wrap :deep(.p-inputtext::placeholder) {
|
||||
color: transparent;
|
||||
}
|
||||
/* Mantém kbd visível em compact (dica visual do atalho) */
|
||||
.gs-field__kbd {
|
||||
display: inline-flex;
|
||||
right: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gs-field__kbd kbd {
|
||||
font-size: 0.55rem;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* Focado: expande como overlay sobre o topbar */
|
||||
.gs-root:focus-within {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
transform: translateY(-50%);
|
||||
z-index: 110;
|
||||
}
|
||||
.gs-root:focus-within .gs-field__wrap :deep(.p-inputtext) {
|
||||
width: 100%;
|
||||
padding: 8px 56px 8px 34px;
|
||||
color: inherit;
|
||||
caret-color: auto;
|
||||
cursor: text;
|
||||
background: var(--p-content-background, var(--surface-card));
|
||||
}
|
||||
.gs-root:focus-within .gs-field__wrap :deep(.p-inputtext::placeholder) {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — pagesIndex.js
|
||||
|--------------------------------------------------------------------------
|
||||
| Índice curado das páginas navegáveis do app. Consumido pelo GlobalSearch
|
||||
| pra dar "ir pra tal página" como resultado da busca.
|
||||
|
|
||||
| Regras:
|
||||
| • `roles` vazio ou ausente → todos logados veem.
|
||||
| • `roles` com valores → apenas esses papéis veem.
|
||||
| • Roles reconhecidos: 'therapist', 'clinic_admin', 'tenant_admin',
|
||||
| 'supervisor', 'saas_admin', 'portal_user'.
|
||||
|
|
||||
| Não duplique aqui o que já está nas STATIC_ACTIONS do GlobalSearch
|
||||
| (Agenda, Pacientes, Financeiro, Documentos, Configurações, etc.).
|
||||
| O foco aqui são *sub-páginas* e telas menos frequentes, que o usuário
|
||||
| raramente lembra onde ficam.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Helpers opcionais pra keywords mais ricas
|
||||
const kw = (...arr) => arr.flat().filter(Boolean);
|
||||
|
||||
export const PAGES = [
|
||||
// ═══════════════════ THERAPIST (/therapist/*) ═══════════════════
|
||||
{ id: 'p_t_dashboard', label: 'Dashboard', icon: 'pi pi-home', sublabel: 'Visão geral', path: '/therapist', roles: ['therapist'], keywords: kw('home','inicio','painel','dashboard') },
|
||||
{ id: 'p_t_conversas', label: 'Conversas', icon: 'pi pi-comments', sublabel: 'Mensagens com pacientes', path: '/therapist/conversas', roles: ['therapist'], keywords: kw('conversas','chat','mensagens','whatsapp') },
|
||||
{ id: 'p_t_agenda_rec', label: 'Recorrências da agenda', icon: 'pi pi-refresh', sublabel: 'Sessões recorrentes', path: '/therapist/agenda/recorrencias', roles: ['therapist'], keywords: kw('recorrencia','repeticao','semanal','sessao fixa') },
|
||||
{ id: 'p_t_agenda_comp', label: 'Compromissos determinados', icon: 'pi pi-bookmark', sublabel: 'Sessões fixas', path: '/therapist/agenda/compromissos', roles: ['therapist'], keywords: kw('compromissos','determinado','fixo') },
|
||||
{ id: 'p_t_plano', label: 'Meu plano', icon: 'pi pi-crown', sublabel: 'Assinatura e billing', path: '/therapist/meu-plano', roles: ['therapist'], keywords: kw('plano','assinatura','pagamento','mensalidade','billing') },
|
||||
{ id: 'p_t_upgrade', label: 'Upgrade de plano', icon: 'pi pi-arrow-up', sublabel: 'Mudar de plano', path: '/therapist/upgrade', roles: ['therapist'], keywords: kw('upgrade','trocar','mudar plano') },
|
||||
{ id: 'p_t_grupos', label: 'Grupos de pacientes', icon: 'pi pi-users', sublabel: 'Organizar pacientes em grupos', path: '/therapist/patients/grupos', roles: ['therapist'], keywords: kw('grupos','segmentacao','categorias') },
|
||||
{ id: 'p_t_tags', label: 'Tags de pacientes', icon: 'pi pi-tags', sublabel: 'Etiquetas', path: '/therapist/patients/tags', roles: ['therapist'], keywords: kw('tags','etiquetas','marcadores','labels') },
|
||||
{ id: 'p_t_medicos', label: 'Médicos referenciadores', icon: 'pi pi-user-edit', sublabel: 'Médicos que encaminham pacientes', path: '/therapist/patients/medicos', roles: ['therapist'], keywords: kw('medicos','encaminhadores','referenciadores','indicacao') },
|
||||
{ id: 'p_t_link_externo', label: 'Link de cadastro externo', icon: 'pi pi-link', sublabel: 'Link público pra pacientes', path: '/therapist/patients/link-externo', roles: ['therapist'], keywords: kw('link','externo','publico','cadastro paciente','convite') },
|
||||
{ id: 'p_t_cad_recebidos', label: 'Cadastros recebidos', icon: 'pi pi-inbox', sublabel: 'Pacientes aguardando aceite', path: '/therapist/patients/cadastro/recebidos', roles: ['therapist'], keywords: kw('recebidos','pendentes','aceitar','intake','novos') },
|
||||
{ id: 'p_t_doc_templates', label: 'Templates de documentos', icon: 'pi pi-file-edit', sublabel: 'Modelos reutilizáveis', path: '/therapist/documents/templates', roles: ['therapist'], keywords: kw('templates','modelos','contratos','documentos') },
|
||||
{ id: 'p_t_online_sched', label: 'Agendamento online', icon: 'pi pi-globe', sublabel: 'Página pública de agendamento', path: '/therapist/online-scheduling', roles: ['therapist'], keywords: kw('online','publico','agendar','landing','pagina','site') },
|
||||
{ id: 'p_t_ag_recebidos', label: 'Agendamentos recebidos', icon: 'pi pi-calendar-plus',sublabel: 'Solicitações da agenda pública', path: '/therapist/agendamentos-recebidos', roles: ['therapist'], keywords: kw('solicitacoes','recebidos','publico','pedidos') },
|
||||
{ id: 'p_t_fin_lanc', label: 'Lançamentos financeiros', icon: 'pi pi-list', sublabel: 'Entradas e saídas', path: '/therapist/financeiro/lancamentos', roles: ['therapist'], keywords: kw('lancamentos','entradas','saidas','fluxo de caixa','receitas','despesas') },
|
||||
{ id: 'p_t_relatorios', label: 'Relatórios', icon: 'pi pi-chart-bar', sublabel: 'Métricas e análises', path: '/therapist/relatorios', roles: ['therapist'], keywords: kw('relatorios','metricas','dashboards','analytics','indicadores') },
|
||||
{ id: 'p_t_security', label: 'Segurança da conta', icon: 'pi pi-shield', sublabel: 'Senha, 2FA, sessões', path: '/therapist/settings/security', roles: ['therapist'], keywords: kw('seguranca','senha','2fa','autenticacao','mfa') },
|
||||
{ id: 'p_t_notificacoes', label: 'Notificações', icon: 'pi pi-bell', sublabel: 'Histórico completo', path: '/therapist/notificacoes', roles: ['therapist'], keywords: kw('notificacoes','historico','alertas','avisos','inbox') },
|
||||
|
||||
// ═══════════════════ CLINIC ADMIN (/admin/*) ═══════════════════
|
||||
{ id: 'p_c_dashboard', label: 'Dashboard (clínica)', icon: 'pi pi-home', sublabel: 'Visão geral da clínica', path: '/admin', roles: ['clinic_admin','tenant_admin'], keywords: kw('dashboard','home','clinica') },
|
||||
{ id: 'p_c_features', label: 'Features da clínica', icon: 'pi pi-th-large', sublabel: 'Recursos habilitados', path: '/admin/clinic/features', roles: ['clinic_admin','tenant_admin'], keywords: kw('features','recursos','modulos','addons') },
|
||||
{ id: 'p_c_professionals', label: 'Profissionais', icon: 'pi pi-user', sublabel: 'Terapeutas da clínica', path: '/admin/clinic/professionals', roles: ['clinic_admin','tenant_admin'], keywords: kw('profissionais','terapeutas','equipe','time') },
|
||||
{ id: 'p_c_agenda_clinica', label: 'Agenda da clínica', icon: 'pi pi-calendar', sublabel: 'Agenda consolidada', path: '/admin/agenda/clinica', roles: ['clinic_admin','tenant_admin'], keywords: kw('agenda','clinica','global','consolidada') },
|
||||
{ id: 'p_c_plano', label: 'Plano da clínica', icon: 'pi pi-crown', sublabel: 'Assinatura', path: '/admin/meu-plano', roles: ['clinic_admin','tenant_admin'], keywords: kw('plano','assinatura','billing','clinica') },
|
||||
|
||||
// ═══════════════════ CONFIGURAÇÕES (/configuracoes/*) ═══════════════════
|
||||
{ id: 'p_cfg_agenda', label: 'Config: Agenda', icon: 'pi pi-calendar', sublabel: 'Horário de atendimento, durações', path: '/configuracoes/agenda', keywords: kw('config','agenda','horario','duracao','expediente') },
|
||||
{ id: 'p_cfg_bloqueios', label: 'Config: Bloqueios', icon: 'pi pi-ban', sublabel: 'Feriados e indisponibilidades', path: '/configuracoes/bloqueios', keywords: kw('bloqueios','feriados','ferias','indisponivel','folga') },
|
||||
{ id: 'p_cfg_agendador', label: 'Config: Agendador público', icon: 'pi pi-globe', sublabel: 'Configurações da página pública', path: '/configuracoes/agendador', keywords: kw('agendador','publico','online','booking','configurar') },
|
||||
{ id: 'p_cfg_pagamento', label: 'Config: Formas de pagamento', icon: 'pi pi-wallet', sublabel: 'Métodos aceitos', path: '/configuracoes/pagamento', keywords: kw('pagamento','metodos','pix','cartao','boleto','dinheiro') },
|
||||
{ id: 'p_cfg_descontos', label: 'Config: Descontos', icon: 'pi pi-percentage', sublabel: 'Regras de desconto', path: '/configuracoes/descontos', keywords: kw('descontos','promocao','reducao','abatimento') },
|
||||
{ id: 'p_cfg_excecoes', label: 'Config: Exceções financeiras', icon: 'pi pi-exclamation-triangle', sublabel: 'Casos especiais', path: '/configuracoes/excecoes-financeiras', keywords: kw('excecoes','financeiras','casos especiais','ajustes','manuais') },
|
||||
{ id: 'p_cfg_convenios', label: 'Config: Convênios', icon: 'pi pi-id-card', sublabel: 'Planos de saúde', path: '/configuracoes/convenios', keywords: kw('convenios','planos','saude','insurance','seguro') },
|
||||
{ id: 'p_cfg_email', label: 'Config: Templates de e-mail', icon: 'pi pi-envelope', sublabel: 'Mensagens automáticas', path: '/configuracoes/email-templates', keywords: kw('email','templates','modelos','notificacoes','mensagens') },
|
||||
{ id: 'p_cfg_empresa', label: 'Config: Empresa', icon: 'pi pi-building', sublabel: 'Dados da sua empresa', path: '/configuracoes/empresa', keywords: kw('empresa','dados','cnpj','razao social','fantasia','endereco') },
|
||||
{ id: 'p_cfg_conv_tags', label: 'Config: Tags de Conversa', icon: 'pi pi-tag', sublabel: 'Etiquetas pro CRM de conversas', path: '/configuracoes/conversas-tags', keywords: kw('tags','etiquetas','conversas','crm','whatsapp','kanban') },
|
||||
{ id: 'p_cfg_conv_autoreply', label: 'Config: Auto-reply WhatsApp', icon: 'pi pi-reply', sublabel: 'Resposta fora do horário', path: '/configuracoes/conversas-autoreply', keywords: kw('auto-reply','autoresposta','fora do horario','whatsapp','automatico','bot') },
|
||||
{ id: 'p_cfg_conv_optouts', label: 'Config: Opt-outs (LGPD)', icon: 'pi pi-ban', sublabel: 'Números bloqueados de envios auto', path: '/configuracoes/conversas-optouts', keywords: kw('opt-out','optout','lgpd','parar','bloqueio','cancelar','compliance','anpd') },
|
||||
{ id: 'p_cfg_lembretes', label: 'Config: Lembretes de Sessão', icon: 'pi pi-bell', sublabel: 'WhatsApp 24h e 2h antes', path: '/configuracoes/lembretes-sessao', keywords: kw('lembretes','sessao','whatsapp','automatico','reduzir faltas','avisar') },
|
||||
{ id: 'p_cfg_creditos_wa', label: 'Config: Créditos WhatsApp', icon: 'pi pi-credit-card', sublabel: 'Saldo, pacotes, extrato', path: '/configuracoes/creditos-whatsapp', keywords: kw('creditos','whatsapp','saldo','pacote','pagamento','pix','comprar','asaas') },
|
||||
{ id: 'p_cfg_canais', label: 'Config: Canais de notificação', icon: 'pi pi-bell', sublabel: 'WhatsApp, SMS, e-mail', path: '/configuracoes/canais', keywords: kw('canais','notificacoes','lembretes','whatsapp','sms','email') },
|
||||
{ id: 'p_cfg_whatsapp', label: 'Config: WhatsApp', icon: 'pi pi-whatsapp', sublabel: 'Canal WhatsApp', path: '/configuracoes/whatsapp', keywords: kw('whatsapp','wa','mensageria') },
|
||||
{ id: 'p_cfg_whatsapp_ofc', label: 'Config: WhatsApp Oficial AgenciaPSI', icon: 'pi pi-verified', sublabel: 'Número oficial gerenciado', path: '/configuracoes/whatsapp-oficial', keywords: kw('whatsapp','oficial','agenciapsi','business','api','meta','creditos') },
|
||||
{ id: 'p_cfg_whatsapp_pes', label: 'Config: WhatsApp Pessoal', icon: 'pi pi-mobile', sublabel: 'Conectar via QR code', path: '/configuracoes/whatsapp-pessoal', keywords: kw('whatsapp','pessoal','qr','gratis','evolution','baileys') },
|
||||
{ id: 'p_cfg_sms', label: 'Config: SMS', icon: 'pi pi-mobile', sublabel: 'Canal SMS', path: '/configuracoes/sms', keywords: kw('sms','torpedo','texto') },
|
||||
{ id: 'p_cfg_sms_canal', label: 'Config: Canal SMS', icon: 'pi pi-mobile', sublabel: 'Provedor e número', path: '/configuracoes/sms-canal', keywords: kw('sms','canal','provedor') },
|
||||
{ id: 'p_cfg_extras', label: 'Config: Recursos extras', icon: 'pi pi-plus-circle', sublabel: 'Addons e créditos', path: '/configuracoes/recursos-extras', keywords: kw('recursos','extras','addons','creditos','saldo') },
|
||||
{ id: 'p_cfg_auditoria', label: 'Config: Auditoria', icon: 'pi pi-history', sublabel: 'Log de ações', path: '/configuracoes/auditoria', keywords: kw('auditoria','log','historico','acoes') },
|
||||
|
||||
// ═══════════════════ CONTA (/account/*) ═══════════════════
|
||||
{ id: 'p_acc_profile', label: 'Meu perfil', icon: 'pi pi-user', sublabel: 'Dados pessoais', path: '/account/profile', keywords: kw('perfil','profile','meus dados','nome','foto','bio') },
|
||||
{ id: 'p_acc_negocio', label: 'Meu negócio', icon: 'pi pi-briefcase', sublabel: 'Dados profissionais', path: '/account/negocio', keywords: kw('negocio','profissional','crp','atuacao','especialidade') },
|
||||
{ id: 'p_acc_security', label: 'Segurança (conta)', icon: 'pi pi-shield', sublabel: 'Senha e autenticação', path: '/account/security', keywords: kw('seguranca','senha','password','2fa') }
|
||||
];
|
||||
|
||||
/**
|
||||
* Filtra páginas pelo papel ativo do usuário.
|
||||
* @param {string|null} role - 'therapist'|'clinic_admin'|'tenant_admin'|'supervisor'|'saas_admin'|'portal_user'
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function filterPagesForRole(role) {
|
||||
if (!role) return PAGES.filter((p) => !p.roles || p.roles.length === 0);
|
||||
return PAGES.filter((p) => !p.roles || p.roles.length === 0 || p.roles.includes(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza string pra busca fuzzy simples (remove acento, minúsculo, trim).
|
||||
*/
|
||||
function _norm(s) {
|
||||
return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca fuzzy em páginas filtradas pelo papel.
|
||||
* @param {string} q
|
||||
* @param {string|null} role
|
||||
* @param {number} limit
|
||||
*/
|
||||
export function searchPages(q, role, limit = 6) {
|
||||
const query = _norm(q);
|
||||
const pool = filterPagesForRole(role);
|
||||
if (!query) return pool.slice(0, limit); // top N quando vazio
|
||||
return pool
|
||||
.filter((p) => {
|
||||
const hay = _norm(p.label + ' ' + p.sublabel + ' ' + (p.keywords || []).join(' '));
|
||||
return hay.includes(query);
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Editor de emails polimórfico
|
||||
|--------------------------------------------------------------------------
|
||||
| Uso:
|
||||
| <ContactEmailsEditor entity-type="patient" :entity-id="patient.id" />
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, reactive } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useContactEmails } from '@/composables/useContactEmails';
|
||||
|
||||
const props = defineProps({
|
||||
entityType: { type: String, required: true },
|
||||
entityId: { type: String, required: false, default: null },
|
||||
readonly: { type: Boolean, default: false },
|
||||
confirmGroup: { type: String, default: '' }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const api = useContactEmails();
|
||||
|
||||
const showAddForm = ref(false);
|
||||
const newForm = reactive({ contact_email_type_id: null, email: '', notes: '' });
|
||||
|
||||
function openAddForm() {
|
||||
const defaultType = api.typeBySlug('principal') || api.types.value[0];
|
||||
newForm.contact_email_type_id = defaultType?.id || null;
|
||||
newForm.email = '';
|
||||
newForm.notes = '';
|
||||
showAddForm.value = true;
|
||||
}
|
||||
|
||||
function cancelAddForm() {
|
||||
showAddForm.value = false;
|
||||
}
|
||||
|
||||
async function submitAddForm() {
|
||||
if (!newForm.contact_email_type_id || !newForm.email.trim()) return;
|
||||
const res = await api.addEmail(props.entityType, props.entityId, {
|
||||
contact_email_type_id: newForm.contact_email_type_id,
|
||||
email: newForm.email,
|
||||
is_primary: api.emails.value.length === 0,
|
||||
notes: newForm.notes || null
|
||||
});
|
||||
if (res.ok) {
|
||||
showAddForm.value = false;
|
||||
toast.add({ severity: 'success', summary: 'Email adicionado', life: 1800 });
|
||||
emit('change', api.emails.value);
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
const editingId = ref(null);
|
||||
const editForm = reactive({ contact_email_type_id: null, email: '', notes: '' });
|
||||
|
||||
function startEdit(email) {
|
||||
editingId.value = email.id;
|
||||
editForm.contact_email_type_id = email.contact_email_type_id;
|
||||
editForm.email = email.email;
|
||||
editForm.notes = email.notes || '';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
}
|
||||
|
||||
async function saveEdit(email) {
|
||||
const res = await api.updateEmail(props.entityType, props.entityId, email.id, {
|
||||
contact_email_type_id: editForm.contact_email_type_id,
|
||||
email: editForm.email,
|
||||
notes: editForm.notes.trim() || null
|
||||
});
|
||||
if (res.ok) {
|
||||
editingId.value = null;
|
||||
toast.add({ severity: 'success', summary: 'Email atualizado', life: 1800 });
|
||||
emit('change', api.emails.value);
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(email) {
|
||||
if (email.is_primary) return;
|
||||
const res = await api.setPrimary(props.entityType, props.entityId, email.id);
|
||||
if (res.ok) {
|
||||
toast.add({ severity: 'success', summary: 'Email principal atualizado', life: 1800 });
|
||||
emit('change', api.emails.value);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(email) {
|
||||
const typeName = api.typeById(email.contact_email_type_id)?.name || 'email';
|
||||
confirm.require({
|
||||
group: props.confirmGroup || undefined,
|
||||
message: `Remover este ${typeName}${email.is_primary ? ' (principal)' : ''}?`,
|
||||
header: 'Remover email',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Remover',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
const res = await api.removeEmail(props.entityType, props.entityId, email.id);
|
||||
if (res.ok) {
|
||||
toast.add({ severity: 'success', summary: 'Removido', life: 1800 });
|
||||
emit('change', api.emails.value);
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await api.loadTypes();
|
||||
if (props.entityId) await api.loadEmails(props.entityType, props.entityId);
|
||||
});
|
||||
|
||||
watch(() => props.entityId, async (v) => {
|
||||
if (v) await api.loadEmails(props.entityType, v);
|
||||
else api.emails.value = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] flex items-start gap-1.5 px-1">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Marque um email como <strong>principal</strong> — ele é usado pra
|
||||
<strong>envio de faturas, templates e notificações por email</strong>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="api.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner mr-1" /> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else-if="!api.emails.value.length && !showAddForm" class="text-xs text-center py-4 italic text-[var(--text-color-secondary)] border border-dashed border-[var(--surface-border)] rounded-md">
|
||||
Nenhum email cadastrado.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="email in api.emails.value"
|
||||
:key="email.id"
|
||||
class="flex items-center gap-2 p-2 rounded-md border border-[var(--surface-border)]"
|
||||
:class="email.is_primary ? 'bg-[var(--primary-color)]/5 border-[var(--primary-color)]/30' : 'bg-[var(--surface-card)]'"
|
||||
>
|
||||
<template v-if="editingId !== email.id">
|
||||
<div class="w-8 h-8 rounded grid place-items-center shrink-0 bg-[var(--surface-ground)]">
|
||||
<i :class="api.typeById(email.contact_email_type_id)?.icon || 'pi pi-envelope'" class="text-sm text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-xs font-semibold text-[var(--text-color-secondary)]">{{ api.typeById(email.contact_email_type_id)?.name || 'Email' }}</span>
|
||||
<span v-if="email.is_primary" class="inline-flex items-center px-1.5 py-px rounded-full text-[0.6rem] font-bold uppercase tracking-wide bg-[var(--primary-color)] text-white">Principal</span>
|
||||
</div>
|
||||
<a :href="`mailto:${email.email}`" class="text-sm font-mono truncate text-[var(--primary-color)] hover:underline" @click.stop>{{ email.email }}</a>
|
||||
<span v-if="email.notes" class="text-[0.7rem] italic text-[var(--text-color-secondary)]">{{ email.notes }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!readonly" class="flex items-center gap-0.5 shrink-0">
|
||||
<Button v-if="!email.is_primary" icon="pi pi-star" text size="small" class="h-7 w-7" v-tooltip.top="'Marcar como principal'" :disabled="api.saving.value" @click="setPrimary(email)" />
|
||||
<Button icon="pi pi-pencil" text size="small" class="h-7 w-7" v-tooltip.top="'Editar'" :disabled="api.saving.value" @click="startEdit(email)" />
|
||||
<Button icon="pi pi-trash" text severity="danger" size="small" class="h-7 w-7" v-tooltip.top="'Remover'" :disabled="api.saving.value" @click="confirmRemove(email)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Select
|
||||
v-model="editForm.contact_email_type_id"
|
||||
:options="api.types.value"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
class="text-xs"
|
||||
style="width: 140px"
|
||||
appendTo="body"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i :class="slotProps.option.icon || 'pi pi-envelope'" class="text-xs" />
|
||||
<span>{{ slotProps.option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
<InputText v-model="editForm.email" type="email" class="flex-1 text-sm font-mono" style="min-width: 200px" />
|
||||
</div>
|
||||
<InputText v-model="editForm.notes" placeholder="Observação (opcional)" class="w-full text-xs" :maxlength="200" />
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 shrink-0">
|
||||
<Button icon="pi pi-check" severity="primary" size="small" class="h-7 w-7" v-tooltip.top="'Salvar'" :loading="api.saving.value" @click="saveEdit(email)" />
|
||||
<Button icon="pi pi-times" severity="secondary" text size="small" class="h-7 w-7" v-tooltip.top="'Cancelar'" :disabled="api.saving.value" @click="cancelEdit" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAddForm" class="flex flex-col gap-2 p-2 rounded-md border border-dashed border-[var(--primary-color)]/50 bg-[var(--primary-color)]/5">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Select
|
||||
v-model="newForm.contact_email_type_id"
|
||||
:options="api.types.value"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
class="text-xs"
|
||||
style="width: 140px"
|
||||
appendTo="body"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i :class="slotProps.option.icon || 'pi pi-envelope'" class="text-xs" />
|
||||
<span>{{ slotProps.option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
<InputText v-model="newForm.email" type="email" placeholder="nome@exemplo.com" class="flex-1 text-sm font-mono" style="min-width: 200px" autofocus />
|
||||
</div>
|
||||
<InputText v-model="newForm.notes" placeholder="Observação (opcional)" class="w-full text-xs" :maxlength="200" />
|
||||
<div class="flex items-center gap-1 justify-end">
|
||||
<Button label="Cancelar" severity="secondary" text size="small" :disabled="api.saving.value" @click="cancelAddForm" />
|
||||
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="api.saving.value" :disabled="!newForm.contact_email_type_id || !newForm.email.trim()" @click="submitAddForm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="!readonly && !showAddForm"
|
||||
label="Adicionar email"
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="self-start rounded-full"
|
||||
:disabled="!props.entityId"
|
||||
v-tooltip.right="!props.entityId ? 'Salve o cadastro primeiro pra adicionar emails' : null"
|
||||
@click="openAddForm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,342 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Editor de telefones polimórfico
|
||||
|--------------------------------------------------------------------------
|
||||
| Uso:
|
||||
| <ContactPhonesEditor entity-type="patient" :entity-id="patient.id" />
|
||||
|
|
||||
| Auto-salva mudanças no banco (contact_phones) via useContactPhones.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, reactive } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useContactPhones } from '@/composables/useContactPhones';
|
||||
|
||||
const props = defineProps({
|
||||
entityType: { type: String, required: true }, // 'patient' | 'medico'
|
||||
entityId: { type: String, required: false, default: null }, // null = modo pendente (antes de criar entidade)
|
||||
readonly: { type: Boolean, default: false },
|
||||
confirmGroup: { type: String, default: '' } // grupo do ConfirmDialog (pra isolar de outros na página)
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const api = useContactPhones();
|
||||
|
||||
// ── Mascaras ─────────────────────────────────────────────────
|
||||
const MASK_MOBILE = '(99) 99999-9999';
|
||||
const MASK_FIXED = '(99) 9999-9999';
|
||||
|
||||
function maskForType(typeId) {
|
||||
const t = api.typeById(typeId);
|
||||
return t?.is_mobile ? MASK_MOBILE : MASK_FIXED;
|
||||
}
|
||||
|
||||
// ── Formulário de nova linha ─────────────────────────────────
|
||||
const showAddForm = ref(false);
|
||||
const newForm = reactive({ contact_type_id: null, number: '', notes: '' });
|
||||
|
||||
function openAddForm() {
|
||||
// Default: primeiro tipo system (celular)
|
||||
const defaultType = api.typeBySlug('celular') || api.types.value[0];
|
||||
newForm.contact_type_id = defaultType?.id || null;
|
||||
newForm.number = '';
|
||||
newForm.notes = '';
|
||||
showAddForm.value = true;
|
||||
}
|
||||
|
||||
function cancelAddForm() {
|
||||
showAddForm.value = false;
|
||||
}
|
||||
|
||||
async function submitAddForm() {
|
||||
if (!newForm.contact_type_id || !newForm.number.trim()) return;
|
||||
const res = await api.addPhone(props.entityType, props.entityId, {
|
||||
contact_type_id: newForm.contact_type_id,
|
||||
number: newForm.number,
|
||||
is_primary: api.phones.value.length === 0, // primeiro vira primary
|
||||
notes: newForm.notes || null
|
||||
});
|
||||
if (res.ok) {
|
||||
showAddForm.value = false;
|
||||
toast.add({ severity: 'success', summary: 'Telefone adicionado', life: 1800 });
|
||||
emit('change', api.phones.value);
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edição inline ────────────────────────────────────────────
|
||||
const editingId = ref(null);
|
||||
const editForm = reactive({ contact_type_id: null, number: '', notes: '' });
|
||||
|
||||
function startEdit(phone) {
|
||||
editingId.value = phone.id;
|
||||
editForm.contact_type_id = phone.contact_type_id;
|
||||
editForm.number = phone.number;
|
||||
editForm.notes = phone.notes || '';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
}
|
||||
|
||||
async function saveEdit(phone) {
|
||||
const res = await api.updatePhone(props.entityType, props.entityId, phone.id, {
|
||||
contact_type_id: editForm.contact_type_id,
|
||||
number: editForm.number,
|
||||
notes: editForm.notes.trim() || null
|
||||
});
|
||||
if (res.ok) {
|
||||
editingId.value = null;
|
||||
toast.add({ severity: 'success', summary: 'Telefone atualizado', life: 1800 });
|
||||
emit('change', api.phones.value);
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Principal ────────────────────────────────────────────────
|
||||
async function setPrimary(phone) {
|
||||
if (phone.is_primary) return;
|
||||
const res = await api.setPrimary(props.entityType, props.entityId, phone.id);
|
||||
if (res.ok) {
|
||||
toast.add({ severity: 'success', summary: 'Telefone principal atualizado', life: 1800 });
|
||||
emit('change', api.phones.value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remover ──────────────────────────────────────────────────
|
||||
function confirmRemove(phone) {
|
||||
const typeName = api.typeById(phone.contact_type_id)?.name || 'telefone';
|
||||
confirm.require({
|
||||
group: props.confirmGroup || undefined,
|
||||
message: `Remover este ${typeName}${phone.is_primary ? ' (principal)' : ''}?`,
|
||||
header: 'Remover telefone',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Remover',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
const res = await api.removePhone(props.entityType, props.entityId, phone.id);
|
||||
if (res.ok) {
|
||||
toast.add({ severity: 'success', summary: 'Removido', life: 1800 });
|
||||
emit('change', api.phones.value);
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
function formatDisplay(number) {
|
||||
const s = String(number || '').replace(/\D/g, '');
|
||||
if (s.length === 11) return `(${s.slice(0, 2)}) ${s.slice(2, 7)}-${s.slice(7)}`;
|
||||
if (s.length === 10) return `(${s.slice(0, 2)}) ${s.slice(2, 6)}-${s.slice(6)}`;
|
||||
if (s.length === 13 && s.startsWith('55')) return `+55 (${s.slice(2, 4)}) ${s.slice(4, 9)}-${s.slice(9)}`;
|
||||
if (s.length === 12 && s.startsWith('55')) return `+55 (${s.slice(2, 4)}) ${s.slice(4, 8)}-${s.slice(8)}`;
|
||||
return number;
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await api.loadTypes();
|
||||
if (props.entityId) await api.loadPhones(props.entityType, props.entityId);
|
||||
});
|
||||
|
||||
watch(() => props.entityId, async (v) => {
|
||||
if (v) await api.loadPhones(props.entityType, v);
|
||||
else api.phones.value = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Aviso sobre telefone principal -->
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] flex items-start gap-1.5 px-1">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Marque um telefone como <strong>principal</strong> — ele é usado pra
|
||||
<strong>cobranças, lembretes automáticos e contato padrão</strong>.
|
||||
Número vindo do CRM WhatsApp recebe a etiqueta <strong>"vinculado"</strong>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Lista de telefones -->
|
||||
<div v-if="api.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner mr-1" /> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else-if="!api.phones.value.length && !showAddForm" class="text-xs text-center py-4 italic text-[var(--text-color-secondary)] border border-dashed border-[var(--surface-border)] rounded-md">
|
||||
Nenhum telefone cadastrado.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="phone in api.phones.value"
|
||||
:key="phone.id"
|
||||
class="flex items-center gap-2 p-2 rounded-md border border-[var(--surface-border)]"
|
||||
:class="phone.is_primary ? 'bg-[var(--primary-color)]/5 border-[var(--primary-color)]/30' : 'bg-[var(--surface-card)]'"
|
||||
>
|
||||
<!-- Modo leitura -->
|
||||
<template v-if="editingId !== phone.id">
|
||||
<!-- Ícone do tipo -->
|
||||
<div class="w-8 h-8 rounded grid place-items-center shrink-0 bg-[var(--surface-ground)]">
|
||||
<i :class="api.typeById(phone.contact_type_id)?.icon || 'pi pi-phone'" class="text-sm text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<!-- Tipo + número + badges -->
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-xs font-semibold text-[var(--text-color-secondary)]">{{ api.typeById(phone.contact_type_id)?.name || 'Telefone' }}</span>
|
||||
<span v-if="phone.is_primary" class="inline-flex items-center px-1.5 py-px rounded-full text-[0.6rem] font-bold uppercase tracking-wide bg-[var(--primary-color)] text-white">Principal</span>
|
||||
<span v-if="phone.whatsapp_linked_at" class="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full text-[0.6rem] font-bold uppercase tracking-wide bg-emerald-500/15 text-emerald-600">
|
||||
<i class="pi pi-link text-[0.55rem]" /> Vinculado
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm font-mono font-semibold">{{ formatDisplay(phone.number) }}</span>
|
||||
<span v-if="phone.notes" class="text-[0.7rem] italic text-[var(--text-color-secondary)]">{{ phone.notes }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div v-if="!readonly" class="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
v-if="!phone.is_primary"
|
||||
icon="pi pi-star"
|
||||
text
|
||||
size="small"
|
||||
class="h-7 w-7"
|
||||
v-tooltip.top="'Marcar como principal'"
|
||||
:disabled="api.saving.value"
|
||||
@click="setPrimary(phone)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
size="small"
|
||||
class="h-7 w-7"
|
||||
v-tooltip.top="'Editar'"
|
||||
:disabled="api.saving.value"
|
||||
@click="startEdit(phone)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
size="small"
|
||||
class="h-7 w-7"
|
||||
v-tooltip.top="'Remover'"
|
||||
:disabled="api.saving.value"
|
||||
@click="confirmRemove(phone)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Modo edição -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Select
|
||||
v-model="editForm.contact_type_id"
|
||||
:options="api.types.value"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
class="text-xs"
|
||||
style="width: 140px"
|
||||
appendTo="body"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i :class="slotProps.option.icon || 'pi pi-phone'" class="text-xs" />
|
||||
<span>{{ slotProps.option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
<InputMask
|
||||
v-model="editForm.number"
|
||||
:mask="maskForType(editForm.contact_type_id)"
|
||||
class="flex-1 text-sm font-mono"
|
||||
style="min-width: 140px"
|
||||
/>
|
||||
</div>
|
||||
<InputText
|
||||
v-model="editForm.notes"
|
||||
placeholder="Observação (opcional)"
|
||||
class="w-full text-xs"
|
||||
:maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 shrink-0">
|
||||
<Button icon="pi pi-check" severity="primary" size="small" class="h-7 w-7" v-tooltip.top="'Salvar'" :loading="api.saving.value" @click="saveEdit(phone)" />
|
||||
<Button icon="pi pi-times" severity="secondary" text size="small" class="h-7 w-7" v-tooltip.top="'Cancelar'" :disabled="api.saving.value" @click="cancelEdit" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulário de nova linha -->
|
||||
<div v-if="showAddForm" class="flex flex-col gap-2 p-2 rounded-md border border-dashed border-[var(--primary-color)]/50 bg-[var(--primary-color)]/5">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Select
|
||||
v-model="newForm.contact_type_id"
|
||||
:options="api.types.value"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
class="text-xs"
|
||||
style="width: 140px"
|
||||
appendTo="body"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i :class="slotProps.option.icon || 'pi pi-phone'" class="text-xs" />
|
||||
<span>{{ slotProps.option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
<InputMask
|
||||
v-model="newForm.number"
|
||||
:mask="maskForType(newForm.contact_type_id)"
|
||||
class="flex-1 text-sm font-mono"
|
||||
style="min-width: 140px"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<InputText
|
||||
v-model="newForm.notes"
|
||||
placeholder="Observação (opcional)"
|
||||
class="w-full text-xs"
|
||||
:maxlength="200"
|
||||
/>
|
||||
<div class="flex items-center gap-1 justify-end">
|
||||
<Button label="Cancelar" severity="secondary" text size="small" :disabled="api.saving.value" @click="cancelAddForm" />
|
||||
<Button
|
||||
label="Adicionar"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
:loading="api.saving.value"
|
||||
:disabled="!newForm.contact_type_id || !newForm.number.trim()"
|
||||
@click="submitAddForm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão + -->
|
||||
<Button
|
||||
v-if="!readonly && !showAddForm"
|
||||
label="Adicionar telefone"
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="self-start rounded-full"
|
||||
:disabled="!props.entityId"
|
||||
v-tooltip.right="!props.entityId ? 'Salve o cadastro primeiro pra adicionar telefones' : null"
|
||||
@click="openAddForm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -116,6 +116,32 @@ async function onCreated(data) {
|
||||
@click="pageRef?.fillRandomPatient?.()"
|
||||
/>
|
||||
|
||||
<!-- Conversar no WhatsApp (só em edição) -->
|
||||
<Button
|
||||
v-if="patientId"
|
||||
icon="pi pi-whatsapp"
|
||||
severity="success"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
|
||||
title="Conversar no WhatsApp"
|
||||
@click="pageRef?.goToConversation?.(); isOpen = false;"
|
||||
/>
|
||||
|
||||
<!-- Exportar LGPD (só em edição) -->
|
||||
<Button
|
||||
v-if="patientId"
|
||||
icon="pi pi-shield"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
|
||||
title="Exportar dados do paciente (LGPD)"
|
||||
@click="pageRef?.openLgpdDialog?.()"
|
||||
/>
|
||||
|
||||
<!-- Excluir (só em edição) -->
|
||||
<Button
|
||||
v-if="patientId"
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/composables/useAddonExtrato.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── helpers de período ─────────────────────────────────────────────────────
|
||||
|
||||
function startOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function endOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x;
|
||||
}
|
||||
|
||||
function resolveDateRange(preset, customRange) {
|
||||
const now = new Date();
|
||||
if (preset === 'thisMonth') {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return { from: startOfDay(start), to: endOfDay(now) };
|
||||
}
|
||||
if (preset === 'lastMonth') {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
return { from: startOfDay(start), to: endOfDay(end) };
|
||||
}
|
||||
if (preset === 'last90') {
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 90);
|
||||
return { from: startOfDay(start), to: endOfDay(now) };
|
||||
}
|
||||
if (preset === 'custom' && Array.isArray(customRange) && customRange[0] && customRange[1]) {
|
||||
return { from: startOfDay(customRange[0]), to: endOfDay(customRange[1]) };
|
||||
}
|
||||
// fallback: thisMonth
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return { from: startOfDay(start), to: endOfDay(now) };
|
||||
}
|
||||
|
||||
// ─── sanitização de busca ───────────────────────────────────────────────────
|
||||
|
||||
function sanitizeSearch(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
// limite defensivo para não enviar termos absurdos pro client-side filter
|
||||
return trimmed.slice(0, 120).toLowerCase();
|
||||
}
|
||||
|
||||
// ─── composable ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAddonExtrato() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const transactions = ref([]);
|
||||
const balances = ref({}); // { sms: {balance, total_purchased, total_consumed}, ... }
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const filters = ref({
|
||||
periodPreset: 'thisMonth',
|
||||
customRange: null, // [Date, Date]
|
||||
addonTypes: [], // [] = todos
|
||||
movementTypes: [], // [] = todos
|
||||
search: ''
|
||||
});
|
||||
|
||||
const dateRange = computed(() => resolveDateRange(filters.value.periodPreset, filters.value.customRange));
|
||||
|
||||
async function load() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
transactions.value = [];
|
||||
error.value = new Error('Tenant ativo inválido.');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const { from, to } = dateRange.value;
|
||||
|
||||
let query = supabase
|
||||
.from('addon_transactions')
|
||||
.select('id, created_at, addon_type, type, amount, balance_before, balance_after, description, payment_method, payment_reference, price_cents, currency, queue_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.gte('created_at', from.toISOString())
|
||||
.lte('created_at', to.toISOString())
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5000);
|
||||
|
||||
if (filters.value.addonTypes.length > 0) {
|
||||
query = query.in('addon_type', filters.value.addonTypes);
|
||||
}
|
||||
if (filters.value.movementTypes.length > 0) {
|
||||
query = query.in('type', filters.value.movementTypes);
|
||||
}
|
||||
|
||||
const { data, error: qErr } = await query;
|
||||
if (qErr) throw qErr;
|
||||
transactions.value = data ?? [];
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
transactions.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBalances() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
balances.value = {};
|
||||
return;
|
||||
}
|
||||
const { data } = await supabase
|
||||
.from('addon_credits')
|
||||
.select('addon_type, balance, total_purchased, total_consumed, low_balance_threshold, expires_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('is_active', true);
|
||||
const map = {};
|
||||
for (const c of data ?? []) map[c.addon_type] = c;
|
||||
balances.value = map;
|
||||
}
|
||||
|
||||
// filtro client-side de busca textual (sobre server-side result)
|
||||
const rows = computed(() => {
|
||||
const q = sanitizeSearch(filters.value.search);
|
||||
if (!q) return transactions.value;
|
||||
return transactions.value.filter((r) => {
|
||||
const ref = (r.payment_reference || '').toLowerCase();
|
||||
const desc = (r.description || '').toLowerCase();
|
||||
const method = (r.payment_method || '').toLowerCase();
|
||||
return ref.includes(q) || desc.includes(q) || method.includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const summary = computed(() => {
|
||||
let purchasedCredits = 0;
|
||||
let purchasedCents = 0;
|
||||
let consumedCredits = 0;
|
||||
let adjustedCredits = 0;
|
||||
let refundedCredits = 0;
|
||||
|
||||
for (const r of rows.value) {
|
||||
const amt = Number(r.amount) || 0;
|
||||
switch (r.type) {
|
||||
case 'purchase':
|
||||
purchasedCredits += Math.abs(amt);
|
||||
purchasedCents += Number(r.price_cents) || 0;
|
||||
break;
|
||||
case 'consumption':
|
||||
consumedCredits += Math.abs(amt);
|
||||
break;
|
||||
case 'adjustment':
|
||||
adjustedCredits += amt; // mantém sinal
|
||||
break;
|
||||
case 'refund':
|
||||
refundedCredits += Math.abs(amt);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
purchasedCredits,
|
||||
purchasedCents,
|
||||
consumedCredits,
|
||||
adjustedCredits,
|
||||
refundedCredits,
|
||||
totalRows: rows.value.length
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
transactions,
|
||||
rows,
|
||||
balances,
|
||||
filters,
|
||||
dateRange,
|
||||
loading,
|
||||
error,
|
||||
summary,
|
||||
load,
|
||||
loadBalances
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/composables/useAuditoria.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function startOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function endOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x;
|
||||
}
|
||||
|
||||
function resolveDateRange(preset, customRange) {
|
||||
const now = new Date();
|
||||
if (preset === 'today') {
|
||||
return { from: startOfDay(now), to: endOfDay(now) };
|
||||
}
|
||||
if (preset === 'last7') {
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 6);
|
||||
return { from: startOfDay(start), to: endOfDay(now) };
|
||||
}
|
||||
if (preset === 'last30') {
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 29);
|
||||
return { from: startOfDay(start), to: endOfDay(now) };
|
||||
}
|
||||
if (preset === 'last90') {
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 89);
|
||||
return { from: startOfDay(start), to: endOfDay(now) };
|
||||
}
|
||||
if (preset === 'custom' && Array.isArray(customRange) && customRange[0] && customRange[1]) {
|
||||
return { from: startOfDay(customRange[0]), to: endOfDay(customRange[1]) };
|
||||
}
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 6);
|
||||
return { from: startOfDay(start), to: endOfDay(now) };
|
||||
}
|
||||
|
||||
function sanitizeSearch(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.trim().slice(0, 120).toLowerCase();
|
||||
}
|
||||
|
||||
// ─── composable ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAuditoria() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const events = ref([]);
|
||||
const usersMap = ref({}); // { uid: { email, display_name } }
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const filters = ref({
|
||||
periodPreset: 'last7',
|
||||
customRange: null,
|
||||
sources: [], // [] = todas
|
||||
entityTypes: [], // [] = todos
|
||||
actions: [], // [] = todas
|
||||
userId: null,
|
||||
search: ''
|
||||
});
|
||||
|
||||
const dateRange = computed(() => resolveDateRange(filters.value.periodPreset, filters.value.customRange));
|
||||
|
||||
async function load() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
events.value = [];
|
||||
error.value = new Error('Tenant ativo inválido.');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const { from, to } = dateRange.value;
|
||||
|
||||
let query = supabase
|
||||
.from('audit_log_unified')
|
||||
.select('uid, tenant_id, user_id, entity_type, entity_id, action, description, occurred_at, source, details')
|
||||
.eq('tenant_id', tenantId)
|
||||
.gte('occurred_at', from.toISOString())
|
||||
.lte('occurred_at', to.toISOString())
|
||||
.order('occurred_at', { ascending: false })
|
||||
.limit(5000);
|
||||
|
||||
if (filters.value.sources.length > 0) {
|
||||
query = query.in('source', filters.value.sources);
|
||||
}
|
||||
if (filters.value.entityTypes.length > 0) {
|
||||
query = query.in('entity_type', filters.value.entityTypes);
|
||||
}
|
||||
if (filters.value.actions.length > 0) {
|
||||
query = query.in('action', filters.value.actions);
|
||||
}
|
||||
if (filters.value.userId) {
|
||||
query = query.eq('user_id', filters.value.userId);
|
||||
}
|
||||
|
||||
const { data, error: qErr } = await query;
|
||||
if (qErr) throw qErr;
|
||||
events.value = data ?? [];
|
||||
|
||||
await resolveUserNames();
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
events.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveUserNames() {
|
||||
const uids = [...new Set(events.value.map((e) => e.user_id).filter(Boolean))];
|
||||
const unknown = uids.filter((u) => !usersMap.value[u]);
|
||||
if (!unknown.length) return;
|
||||
|
||||
const { data } = await supabase.from('profiles').select('id, full_name, nickname').in('id', unknown);
|
||||
|
||||
const next = { ...usersMap.value };
|
||||
for (const u of unknown) next[u] = { email: '', display_name: '' };
|
||||
for (const p of data ?? []) {
|
||||
next[p.id] = { email: '', display_name: p.full_name || p.nickname || '' };
|
||||
}
|
||||
usersMap.value = next;
|
||||
}
|
||||
|
||||
function userDisplay(userId) {
|
||||
if (!userId) return 'Sistema';
|
||||
const u = usersMap.value[userId];
|
||||
if (!u) return 'Usuário desconhecido';
|
||||
return u.display_name || u.email || userId.slice(0, 8);
|
||||
}
|
||||
|
||||
// filtro client-side: busca textual
|
||||
const rows = computed(() => {
|
||||
const q = sanitizeSearch(filters.value.search);
|
||||
if (!q) return events.value;
|
||||
return events.value.filter((e) => {
|
||||
const desc = (e.description || '').toLowerCase();
|
||||
const entity = (e.entity_type || '').toLowerCase();
|
||||
const action = (e.action || '').toLowerCase();
|
||||
const src = (e.source || '').toLowerCase();
|
||||
const user = userDisplay(e.user_id).toLowerCase();
|
||||
return desc.includes(q) || entity.includes(q) || action.includes(q) || src.includes(q) || user.includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const summary = computed(() => {
|
||||
const bySource = {};
|
||||
const byAction = {};
|
||||
const byUser = new Set();
|
||||
|
||||
for (const e of rows.value) {
|
||||
bySource[e.source] = (bySource[e.source] || 0) + 1;
|
||||
byAction[e.action] = (byAction[e.action] || 0) + 1;
|
||||
if (e.user_id) byUser.add(e.user_id);
|
||||
}
|
||||
|
||||
return {
|
||||
totalRows: rows.value.length,
|
||||
bySource,
|
||||
byAction,
|
||||
distinctUsers: byUser.size
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
events,
|
||||
rows,
|
||||
filters,
|
||||
dateRange,
|
||||
loading,
|
||||
error,
|
||||
summary,
|
||||
userDisplay,
|
||||
load
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useAutoReplySettings.js
|
||||
| Data: 2026-04-21
|
||||
|
|
||||
| Settings de auto-reply fora do horário (CRM Grupo 2.3).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
enabled: false,
|
||||
message: 'Olá! Nosso horário de atendimento acabou. Retornaremos sua mensagem assim que possível. Obrigado!',
|
||||
cooldown_minutes: 180,
|
||||
schedule_mode: 'agenda',
|
||||
business_hours: [],
|
||||
custom_window: []
|
||||
};
|
||||
|
||||
export function useAutoReplySettings() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const settings = ref({ ...DEFAULT_SETTINGS });
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref(null);
|
||||
const lastLoadedAt = ref(null);
|
||||
|
||||
async function load() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_autoreply_settings')
|
||||
.select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window')
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
if (err) throw err;
|
||||
if (data) {
|
||||
settings.value = {
|
||||
enabled: !!data.enabled,
|
||||
message: data.message ?? DEFAULT_SETTINGS.message,
|
||||
cooldown_minutes: Number(data.cooldown_minutes ?? DEFAULT_SETTINGS.cooldown_minutes),
|
||||
schedule_mode: data.schedule_mode ?? 'agenda',
|
||||
business_hours: Array.isArray(data.business_hours) ? data.business_hours : [],
|
||||
custom_window: Array.isArray(data.custom_window) ? data.custom_window : []
|
||||
};
|
||||
} else {
|
||||
settings.value = { ...DEFAULT_SETTINGS, business_hours: [], custom_window: [] };
|
||||
}
|
||||
lastLoadedAt.value = new Date().toISOString();
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar settings';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(partial = null) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'no_tenant' };
|
||||
const payload = partial ? { ...settings.value, ...partial } : { ...settings.value };
|
||||
|
||||
// Sanitização básica
|
||||
payload.message = String(payload.message || '').trim().slice(0, 2000);
|
||||
if (!payload.message) return { ok: false, error: 'mensagem vazia' };
|
||||
payload.cooldown_minutes = Math.max(0, Math.min(43200, Number(payload.cooldown_minutes) || 0));
|
||||
if (!['agenda', 'business_hours', 'custom'].includes(payload.schedule_mode)) {
|
||||
payload.schedule_mode = 'agenda';
|
||||
}
|
||||
payload.business_hours = Array.isArray(payload.business_hours) ? payload.business_hours : [];
|
||||
payload.custom_window = Array.isArray(payload.custom_window) ? payload.custom_window : [];
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error: err } = await supabase
|
||||
.from('conversation_autoreply_settings')
|
||||
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
|
||||
if (err) throw err;
|
||||
settings.value = payload;
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'Falha ao salvar' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Busca a agenda_regras_semanais do tenant — pra mostrar preview "seguindo agenda"
|
||||
async function loadAgendaWindows() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return [];
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('agenda_regras_semanais')
|
||||
.select('dia_semana, hora_inicio, hora_fim, ativo')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('ativo', true)
|
||||
.order('dia_semana');
|
||||
return (data || []).map((r) => ({
|
||||
dow: r.dia_semana,
|
||||
start: String(r.hora_inicio).slice(0, 5),
|
||||
end: String(r.hora_fim).slice(0, 5)
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
lastLoadedAt,
|
||||
load,
|
||||
save,
|
||||
loadAgendaWindows
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/composables/useClinicKPIs.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function startOfMonth(d = new Date()) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function endOfMonth(d = new Date()) {
|
||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
function addMonths(d, n) {
|
||||
return new Date(d.getFullYear(), d.getMonth() + n, 1);
|
||||
}
|
||||
|
||||
function monthLabel(d) {
|
||||
return d.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' });
|
||||
}
|
||||
|
||||
export function useClinicKPIs() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// totais do mês corrente
|
||||
const mrrCurrentCents = ref(0); // receita recebida no mês
|
||||
const overdueCents = ref(0);
|
||||
const overdueCount = ref(0);
|
||||
const pendingCents = ref(0);
|
||||
|
||||
// pacientes
|
||||
const activePatients = ref(0);
|
||||
const inactivePatients = ref(0);
|
||||
const totalPatients = ref(0);
|
||||
|
||||
// sessões
|
||||
const sessionsDone = ref(0);
|
||||
const sessionsCancelled = ref(0);
|
||||
const sessionsNoShow = ref(0);
|
||||
const sessionsScheduled = ref(0);
|
||||
|
||||
// receita últimos 6 meses
|
||||
const revenueSeries = ref([]); // [{ label, received, due }]
|
||||
|
||||
// top 5 pacientes (por valor recebido últimos 6 meses)
|
||||
const topPatients = ref([]); // [{ patient_id, nome_completo, total }]
|
||||
|
||||
async function load() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
error.value = new Error('Tenant ativo inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const now = new Date();
|
||||
const monthStart = startOfMonth(now).toISOString();
|
||||
const monthEnd = endOfMonth(now).toISOString();
|
||||
const sixMonthsAgo = startOfMonth(addMonths(now, -5)).toISOString();
|
||||
|
||||
try {
|
||||
const [finRes, pendRes, patRes, eventRes, finSeriesRes] = await Promise.all([
|
||||
// 1) financial_records PAGO no mês (para MRR)
|
||||
supabase
|
||||
.from('financial_records')
|
||||
.select('final_amount, patient_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('status', 'paid')
|
||||
.gte('paid_at', monthStart)
|
||||
.lte('paid_at', monthEnd),
|
||||
|
||||
// 2) financial_records pending/overdue (qualquer data)
|
||||
supabase
|
||||
.from('financial_records')
|
||||
.select('status, final_amount')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('status', ['pending', 'overdue']),
|
||||
|
||||
// 3) patients por status
|
||||
supabase
|
||||
.from('patients')
|
||||
.select('status')
|
||||
.eq('tenant_id', tenantId),
|
||||
|
||||
// 4) eventos de agenda no mês (para realizado/cancelado/faltou)
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
.select('status, tipo')
|
||||
.eq('tenant_id', tenantId)
|
||||
.gte('inicio_em', monthStart)
|
||||
.lte('inicio_em', monthEnd)
|
||||
.neq('tipo', 'bloqueio'),
|
||||
|
||||
// 5) financial_records pagos últimos 6 meses (série + top pacientes)
|
||||
supabase
|
||||
.from('financial_records')
|
||||
.select('final_amount, paid_at, patient_id, patients!patient_id(nome_completo)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('status', 'paid')
|
||||
.gte('paid_at', sixMonthsAgo)
|
||||
.lte('paid_at', monthEnd)
|
||||
]);
|
||||
|
||||
if (finRes.error) throw finRes.error;
|
||||
if (pendRes.error) throw pendRes.error;
|
||||
if (patRes.error) throw patRes.error;
|
||||
if (eventRes.error) throw eventRes.error;
|
||||
if (finSeriesRes.error) throw finSeriesRes.error;
|
||||
|
||||
// MRR do mês (em centavos ou reais? seguindo financial_records.amount está em number/int; tratar como BRL)
|
||||
mrrCurrentCents.value = 0;
|
||||
for (const r of finRes.data ?? []) {
|
||||
mrrCurrentCents.value += Number(r.final_amount) || 0;
|
||||
}
|
||||
|
||||
// pending / overdue
|
||||
overdueCents.value = 0;
|
||||
overdueCount.value = 0;
|
||||
pendingCents.value = 0;
|
||||
for (const r of pendRes.data ?? []) {
|
||||
const v = Number(r.final_amount) || 0;
|
||||
if (r.status === 'overdue') {
|
||||
overdueCents.value += v;
|
||||
overdueCount.value += 1;
|
||||
} else if (r.status === 'pending') {
|
||||
pendingCents.value += v;
|
||||
}
|
||||
}
|
||||
|
||||
// pacientes
|
||||
totalPatients.value = (patRes.data ?? []).length;
|
||||
activePatients.value = 0;
|
||||
inactivePatients.value = 0;
|
||||
for (const p of patRes.data ?? []) {
|
||||
if (p.status === 'Ativo') activePatients.value += 1;
|
||||
else if (p.status === 'Inativo' || p.status === 'Arquivado') inactivePatients.value += 1;
|
||||
}
|
||||
|
||||
// sessões
|
||||
sessionsDone.value = 0;
|
||||
sessionsCancelled.value = 0;
|
||||
sessionsNoShow.value = 0;
|
||||
sessionsScheduled.value = 0;
|
||||
for (const ev of eventRes.data ?? []) {
|
||||
sessionsScheduled.value += 1;
|
||||
if (ev.status === 'realizado') sessionsDone.value += 1;
|
||||
else if (ev.status === 'cancelado') sessionsCancelled.value += 1;
|
||||
else if (ev.status === 'faltou') sessionsNoShow.value += 1;
|
||||
}
|
||||
|
||||
// série 6 meses + top 5 pacientes
|
||||
const monthBuckets = {};
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = startOfMonth(addMonths(now, -i));
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
monthBuckets[key] = { label: monthLabel(d), received: 0 };
|
||||
}
|
||||
const patientTotals = new Map();
|
||||
|
||||
for (const r of finSeriesRes.data ?? []) {
|
||||
if (!r.paid_at) continue;
|
||||
const d = new Date(r.paid_at);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
const v = Number(r.final_amount) || 0;
|
||||
if (monthBuckets[key]) {
|
||||
monthBuckets[key].received += v;
|
||||
}
|
||||
if (r.patient_id) {
|
||||
const prev = patientTotals.get(r.patient_id) || { nome: r.patients?.nome_completo || '—', total: 0 };
|
||||
patientTotals.set(r.patient_id, { nome: prev.nome, total: prev.total + v });
|
||||
}
|
||||
}
|
||||
|
||||
revenueSeries.value = Object.values(monthBuckets);
|
||||
|
||||
topPatients.value = [...patientTotals.entries()]
|
||||
.map(([patient_id, v]) => ({ patient_id, nome_completo: v.nome, total: v.total }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const avgTicket = computed(() => {
|
||||
if (sessionsDone.value === 0) return 0;
|
||||
return mrrCurrentCents.value / sessionsDone.value;
|
||||
});
|
||||
|
||||
const noShowRate = computed(() => {
|
||||
const closed = sessionsDone.value + sessionsCancelled.value + sessionsNoShow.value;
|
||||
if (closed === 0) return null;
|
||||
return Math.round((sessionsNoShow.value / closed) * 100);
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
mrrCurrentCents,
|
||||
overdueCents,
|
||||
overdueCount,
|
||||
pendingCents,
|
||||
activePatients,
|
||||
inactivePatients,
|
||||
totalPatients,
|
||||
sessionsDone,
|
||||
sessionsCancelled,
|
||||
sessionsNoShow,
|
||||
sessionsScheduled,
|
||||
avgTicket,
|
||||
noShowRate,
|
||||
revenueSeries,
|
||||
topPatients,
|
||||
load
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useContactEmails.js
|
||||
| Data: 2026-04-21
|
||||
|
|
||||
| Gerenciamento polimórfico de emails (patients, medicos, etc).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const EMAIL_RE = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
|
||||
|
||||
function normalizeEmail(raw) {
|
||||
return String(raw || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function useContactEmails() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const types = ref([]);
|
||||
const emails = ref([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
async function loadTypes() {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('contact_email_types')
|
||||
.select('id, tenant_id, name, slug, icon, is_system, position')
|
||||
.order('position', { ascending: true })
|
||||
.order('name', { ascending: true });
|
||||
types.value = data || [];
|
||||
} catch (e) {
|
||||
console.error('[useContactEmails] loadTypes:', e?.message);
|
||||
types.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmails(entityType, entityId) {
|
||||
if (!entityType || !entityId) {
|
||||
emails.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('contact_emails')
|
||||
.select('id, contact_email_type_id, email, is_primary, notes, position, created_at')
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
.order('is_primary', { ascending: false })
|
||||
.order('position', { ascending: true });
|
||||
if (error) throw error;
|
||||
emails.value = data || [];
|
||||
} catch (e) {
|
||||
console.error('[useContactEmails] loadEmails:', e?.message);
|
||||
emails.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
|
||||
const q = supabase
|
||||
.from('contact_emails')
|
||||
.update({ is_primary: false })
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
.eq('is_primary', true);
|
||||
if (exceptId) q.neq('id', exceptId);
|
||||
await q;
|
||||
}
|
||||
|
||||
async function addEmail(entityType, entityId, { contact_email_type_id, email, is_primary = false, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const clean = normalizeEmail(email);
|
||||
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
|
||||
if (!contact_email_type_id) return { ok: false, error: 'Tipo obrigatório' };
|
||||
if (!clean || !EMAIL_RE.test(clean)) return { ok: false, error: 'Email inválido' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (is_primary) {
|
||||
await unsetOtherPrimaries(entityType, entityId);
|
||||
} else if (emails.value.length === 0) {
|
||||
is_primary = true;
|
||||
}
|
||||
|
||||
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
|
||||
const { data, error } = await supabase
|
||||
.from('contact_emails')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_email_type_id,
|
||||
email: clean,
|
||||
is_primary,
|
||||
notes,
|
||||
position: maxPos + 10
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
await loadEmails(entityType, entityId);
|
||||
return { ok: true, email: data };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'add_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEmail(entityType, entityId, id, patch) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.email !== undefined) {
|
||||
sanitized.email = normalizeEmail(sanitized.email);
|
||||
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
|
||||
return { ok: false, error: 'Email inválido' };
|
||||
}
|
||||
}
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
const { error } = await supabase.from('contact_emails').update(sanitized).eq('id', id);
|
||||
if (error) throw error;
|
||||
await loadEmails(entityType, entityId);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'update_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEmail(entityType, entityId, id) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
|
||||
const { error } = await supabase.from('contact_emails').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
if (wasPrimary) {
|
||||
const remaining = emails.value.filter((e) => e.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
if (remaining.length > 0) {
|
||||
await supabase.from('contact_emails').update({ is_primary: true }).eq('id', remaining[0].id);
|
||||
}
|
||||
}
|
||||
await loadEmails(entityType, entityId);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'remove_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(entityType, entityId, id) {
|
||||
return updateEmail(entityType, entityId, id, { is_primary: true });
|
||||
}
|
||||
|
||||
function typeBySlug(slug) { return types.value.find((t) => t.slug === slug); }
|
||||
function typeById(id) { return types.value.find((t) => t.id === id); }
|
||||
|
||||
return {
|
||||
types,
|
||||
emails,
|
||||
loading,
|
||||
saving,
|
||||
loadTypes,
|
||||
loadEmails,
|
||||
addEmail,
|
||||
updateEmail,
|
||||
removeEmail,
|
||||
setPrimary,
|
||||
typeBySlug,
|
||||
typeById
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useContactPhones.js
|
||||
| Data: 2026-04-21
|
||||
|
|
||||
| Gerenciamento polimórfico de telefones (patients, medicos, etc).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function normalizeDigits(raw) {
|
||||
return String(raw || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
export function useContactPhones() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const types = ref([]); // contact_types (system + custom do tenant)
|
||||
const phones = ref([]); // contact_phones da entidade atual
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
async function loadTypes() {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('contact_types')
|
||||
.select('id, tenant_id, name, slug, icon, is_mobile, is_system, position')
|
||||
.order('position', { ascending: true })
|
||||
.order('name', { ascending: true });
|
||||
types.value = data || [];
|
||||
} catch (e) {
|
||||
console.error('[useContactPhones] loadTypes:', e?.message);
|
||||
types.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPhones(entityType, entityId) {
|
||||
if (!entityType || !entityId) {
|
||||
phones.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('contact_phones')
|
||||
.select('id, contact_type_id, number, is_primary, whatsapp_linked_at, notes, position, created_at')
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
.order('is_primary', { ascending: false })
|
||||
.order('position', { ascending: true });
|
||||
if (error) throw error;
|
||||
phones.value = data || [];
|
||||
} catch (e) {
|
||||
console.error('[useContactPhones] loadPhones:', e?.message);
|
||||
phones.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure só 1 primary por entidade — seta outros pra false antes de inserir/atualizar
|
||||
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
|
||||
const q = supabase
|
||||
.from('contact_phones')
|
||||
.update({ is_primary: false })
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
.eq('is_primary', true);
|
||||
if (exceptId) q.neq('id', exceptId);
|
||||
await q;
|
||||
}
|
||||
|
||||
async function addPhone(entityType, entityId, { contact_type_id, number, is_primary = false, whatsapp_linked_at = null, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const digits = normalizeDigits(number);
|
||||
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
|
||||
if (!contact_type_id) return { ok: false, error: 'Tipo de contato obrigatório' };
|
||||
if (!digits || digits.length < 8 || digits.length > 15) return { ok: false, error: 'Telefone inválido' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
// Se marcou como primary, desmarca outros
|
||||
if (is_primary) {
|
||||
await unsetOtherPrimaries(entityType, entityId);
|
||||
} else if (phones.value.length === 0) {
|
||||
// Primeiro telefone → vira primary automaticamente
|
||||
is_primary = true;
|
||||
}
|
||||
|
||||
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
|
||||
const { data, error } = await supabase
|
||||
.from('contact_phones')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
contact_type_id,
|
||||
number: digits,
|
||||
is_primary,
|
||||
whatsapp_linked_at,
|
||||
notes,
|
||||
position: maxPos + 10
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
await loadPhones(entityType, entityId);
|
||||
return { ok: true, phone: data };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'add_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePhone(entityType, entityId, id, patch) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const sanitized = { ...patch };
|
||||
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
|
||||
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
|
||||
return { ok: false, error: 'Telefone inválido' };
|
||||
}
|
||||
|
||||
if (sanitized.is_primary === true) {
|
||||
await unsetOtherPrimaries(entityType, entityId, id);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('contact_phones')
|
||||
.update(sanitized)
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
await loadPhones(entityType, entityId);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'update_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removePhone(entityType, entityId, id) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
|
||||
const { error } = await supabase.from('contact_phones').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
|
||||
// Se removeu o primary, promove o próximo pra primary
|
||||
if (wasPrimary) {
|
||||
const remaining = phones.value.filter((p) => p.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
if (remaining.length > 0) {
|
||||
await supabase
|
||||
.from('contact_phones')
|
||||
.update({ is_primary: true })
|
||||
.eq('id', remaining[0].id);
|
||||
}
|
||||
}
|
||||
await loadPhones(entityType, entityId);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'remove_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(entityType, entityId, id) {
|
||||
return updatePhone(entityType, entityId, id, { is_primary: true });
|
||||
}
|
||||
|
||||
function typeBySlug(slug) {
|
||||
return types.value.find((t) => t.slug === slug);
|
||||
}
|
||||
function typeById(id) {
|
||||
return types.value.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
return {
|
||||
types,
|
||||
phones,
|
||||
loading,
|
||||
saving,
|
||||
loadTypes,
|
||||
loadPhones,
|
||||
addPhone,
|
||||
updatePhone,
|
||||
removePhone,
|
||||
setPrimary,
|
||||
typeBySlug,
|
||||
typeById
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useConversationAssignment.js
|
||||
| Data: 2026-04-21
|
||||
|
|
||||
| Atribuicao de thread de conversa a um terapeuta/membro do tenant.
|
||||
| Uma linha por (tenant_id, thread_key). Reatribuir = UPSERT.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
export function useConversationAssignment() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const assignment = ref(null); // { assigned_to, assigned_by, assigned_at, _assignee_name }
|
||||
const members = ref([]); // lista de membros do tenant (pra Select)
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function loadMembers() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) { members.value = []; return; }
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('v_tenant_members_with_profiles')
|
||||
.select('user_id, full_name, email, role')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('role', ['tenant_admin', 'therapist', 'secretary'])
|
||||
.eq('status', 'active')
|
||||
.order('full_name', { ascending: true });
|
||||
if (err) throw err;
|
||||
members.value = (data || []).map((m) => ({
|
||||
user_id: m.user_id,
|
||||
label: m.full_name || m.email || m.user_id,
|
||||
email: m.email,
|
||||
role: m.role
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar membros';
|
||||
members.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function load(threadKey) {
|
||||
if (!threadKey) { assignment.value = null; return; }
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) { assignment.value = null; return; }
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_assignments')
|
||||
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('thread_key', threadKey)
|
||||
.maybeSingle();
|
||||
if (err) throw err;
|
||||
|
||||
if (!data || !data.assigned_to) {
|
||||
assignment.value = data || null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve nome do assignee via membros (se carregado) ou profiles
|
||||
let assigneeName = null;
|
||||
const hit = members.value.find((m) => m.user_id === data.assigned_to);
|
||||
if (hit) assigneeName = hit.label;
|
||||
if (!assigneeName) {
|
||||
const { data: u } = await supabase.from('profiles').select('full_name').eq('id', data.assigned_to).maybeSingle();
|
||||
assigneeName = u?.full_name || null;
|
||||
}
|
||||
assignment.value = { ...data, _assignee_name: assigneeName };
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar atribuicao';
|
||||
assignment.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assign({ threadKey, patientId = null, contactNumber = null, assignedTo }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId || !threadKey) return { ok: false, error: 'invalid_params' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
const userId = authData?.user?.id;
|
||||
if (!userId) return { ok: false, error: 'not_authenticated' };
|
||||
|
||||
const payload = {
|
||||
tenant_id: tenantId,
|
||||
thread_key: threadKey,
|
||||
patient_id: patientId || null,
|
||||
contact_number: contactNumber || null,
|
||||
assigned_to: assignedTo || null,
|
||||
assigned_by: userId,
|
||||
assigned_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_assignments')
|
||||
.upsert(payload, { onConflict: 'tenant_id,thread_key' })
|
||||
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
|
||||
.single();
|
||||
if (err) throw err;
|
||||
|
||||
let assigneeName = null;
|
||||
if (data.assigned_to) {
|
||||
const hit = members.value.find((m) => m.user_id === data.assigned_to);
|
||||
assigneeName = hit?.label || null;
|
||||
if (!assigneeName) {
|
||||
const { data: u } = await supabase.from('profiles').select('full_name').eq('id', data.assigned_to).maybeSingle();
|
||||
assigneeName = u?.full_name || null;
|
||||
}
|
||||
}
|
||||
assignment.value = { ...data, _assignee_name: assigneeName };
|
||||
return { ok: true, assignment: assignment.value };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'assign_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unassign({ threadKey, patientId = null, contactNumber = null }) {
|
||||
return assign({ threadKey, patientId, contactNumber, assignedTo: null });
|
||||
}
|
||||
|
||||
function clear() {
|
||||
assignment.value = null;
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
assignment,
|
||||
members,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
loadMembers,
|
||||
load,
|
||||
assign,
|
||||
unassign,
|
||||
clear
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useConversationNotes.js
|
||||
| Data: 2026-04-21
|
||||
|
|
||||
| Notas internas por thread de conversa. Carregadas sob demanda quando o
|
||||
| drawer da conversa abre.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function sanitizeBody(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.trim().slice(0, 4000);
|
||||
}
|
||||
|
||||
export function useConversationNotes() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const notes = ref([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const count = computed(() => notes.value.length);
|
||||
|
||||
async function load(threadKey) {
|
||||
if (!threadKey) {
|
||||
notes.value = [];
|
||||
return;
|
||||
}
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
notes.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_notes')
|
||||
.select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('thread_key', threadKey)
|
||||
.is('deleted_at', null)
|
||||
.order('created_at', { ascending: false });
|
||||
if (err) throw err;
|
||||
|
||||
// Busca nomes dos criadores (1 query só)
|
||||
const rows = data || [];
|
||||
const userIds = [...new Set(rows.map((r) => r.created_by).filter(Boolean))];
|
||||
let nameMap = {};
|
||||
if (userIds.length) {
|
||||
const { data: users } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, full_name')
|
||||
.in('id', userIds);
|
||||
nameMap = Object.fromEntries((users || []).map((u) => [u.id, u.full_name || '']));
|
||||
}
|
||||
notes.value = rows.map((r) => ({ ...r, _author_name: nameMap[r.created_by] || null }));
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar notas';
|
||||
notes.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create({ threadKey, patientId = null, contactNumber = null, body }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const clean = sanitizeBody(body);
|
||||
if (!tenantId || !threadKey || !clean) return { ok: false, error: 'invalid_params' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
const userId = authData?.user?.id;
|
||||
if (!userId) return { ok: false, error: 'not_authenticated' };
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('conversation_notes')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
thread_key: threadKey,
|
||||
patient_id: patientId,
|
||||
contact_number: contactNumber,
|
||||
body: clean,
|
||||
created_by: userId
|
||||
})
|
||||
.select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at')
|
||||
.single();
|
||||
|
||||
if (err) throw err;
|
||||
|
||||
// Prepend (mais recente primeiro)
|
||||
notes.value = [{ ...data, _author_name: null }, ...notes.value];
|
||||
// Recarrega o display_name do autor novo
|
||||
const { data: u } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name')
|
||||
.eq('id', data.created_by)
|
||||
.maybeSingle();
|
||||
if (u?.full_name) {
|
||||
const item = notes.value.find((n) => n.id === data.id);
|
||||
if (item) item._author_name = u.full_name;
|
||||
}
|
||||
return { ok: true, note: data };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'insert_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id, body) {
|
||||
const clean = sanitizeBody(body);
|
||||
if (!id || !clean) return { ok: false, error: 'invalid_params' };
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error: err } = await supabase
|
||||
.from('conversation_notes')
|
||||
.update({ body: clean })
|
||||
.eq('id', id);
|
||||
if (err) throw err;
|
||||
const item = notes.value.find((n) => n.id === id);
|
||||
if (item) {
|
||||
item.body = clean;
|
||||
item.updated_at = new Date().toISOString();
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'update_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!id) return { ok: false, error: 'invalid_id' };
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error: err } = await supabase
|
||||
.from('conversation_notes')
|
||||
.update({ deleted_at: new Date().toISOString() })
|
||||
.eq('id', id);
|
||||
if (err) throw err;
|
||||
notes.value = notes.value.filter((n) => n.id !== id);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'delete_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
notes.value = [];
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
notes,
|
||||
count,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
load,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
clear
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useConversationOptouts.js
|
||||
| Data: 2026-04-21
|
||||
|
|
||||
| Gerencia opt-outs do CRM WhatsApp (LGPD Art. 18 Sec.2).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function normalizePhoneBR(raw) {
|
||||
if (!raw) return '';
|
||||
const digits = String(raw).replace(/\D/g, '');
|
||||
// Sem DDI 55 → agrega
|
||||
if (digits.length === 10 || digits.length === 11) return '55' + digits;
|
||||
return digits;
|
||||
}
|
||||
|
||||
export function useConversationOptouts() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const optouts = ref([]);
|
||||
const keywords = ref([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const activeOptouts = computed(() => optouts.value.filter((o) => !o.opted_back_in_at));
|
||||
const historyOptouts = computed(() => optouts.value.filter((o) => o.opted_back_in_at));
|
||||
|
||||
async function load() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [optsRes, kwsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('conversation_optouts')
|
||||
.select('id, phone, patient_id, source, keyword_matched, original_message, notes, opted_out_at, opted_back_in_at, blocked_by')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('opted_out_at', { ascending: false }),
|
||||
supabase
|
||||
.from('conversation_optout_keywords')
|
||||
.select('id, tenant_id, keyword, enabled, is_system')
|
||||
.or(`tenant_id.is.null,tenant_id.eq.${tenantId}`)
|
||||
.order('is_system', { ascending: false })
|
||||
.order('keyword', { ascending: true })
|
||||
]);
|
||||
optouts.value = optsRes.data || [];
|
||||
keywords.value = kwsRes.data || [];
|
||||
|
||||
// Enriquece com nome do paciente
|
||||
const patIds = [...new Set(optouts.value.map((o) => o.patient_id).filter(Boolean))];
|
||||
if (patIds.length) {
|
||||
const { data: pats } = await supabase.from('patients').select('id, nome_completo').in('id', patIds);
|
||||
const patMap = Object.fromEntries((pats || []).map((p) => [p.id, p.nome_completo]));
|
||||
optouts.value = optouts.value.map((o) => ({ ...o, _patient_name: patMap[o.patient_id] || null }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[useConversationOptouts] load:', e?.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addManual({ phone, patientId = null, notes = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const cleanPhone = normalizePhoneBR(phone);
|
||||
if (!tenantId || !cleanPhone) return { ok: false, error: 'invalid_params' };
|
||||
if (!/^\d{6,15}$/.test(cleanPhone)) return { ok: false, error: 'invalid_phone_format' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
const userId = authData?.user?.id;
|
||||
|
||||
// Verifica se já existe ativo
|
||||
const { data: existing } = await supabase
|
||||
.from('conversation_optouts')
|
||||
.select('id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('phone', cleanPhone)
|
||||
.is('opted_back_in_at', null)
|
||||
.maybeSingle();
|
||||
if (existing) return { ok: false, error: 'already_opted_out' };
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_optouts')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
phone: cleanPhone,
|
||||
patient_id: patientId,
|
||||
source: 'manual',
|
||||
notes,
|
||||
blocked_by: userId
|
||||
})
|
||||
.select('id, phone, patient_id, source, notes, opted_out_at, blocked_by')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
optouts.value = [{ ...data, _patient_name: null }, ...optouts.value];
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'add_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(id) {
|
||||
if (!id) return { ok: false, error: 'invalid_id' };
|
||||
saving.value = true;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const { error } = await supabase
|
||||
.from('conversation_optouts')
|
||||
.update({ opted_back_in_at: now })
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
const item = optouts.value.find((o) => o.id === id);
|
||||
if (item) item.opted_back_in_at = now;
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'restore_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addKeyword(keyword) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const clean = String(keyword || '').trim().slice(0, 100);
|
||||
if (!tenantId || !clean) return { ok: false, error: 'invalid_params' };
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_optout_keywords')
|
||||
.insert({ tenant_id: tenantId, keyword: clean, is_system: false, enabled: true })
|
||||
.select('id, tenant_id, keyword, enabled, is_system')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
keywords.value = [...keywords.value, data];
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'add_keyword_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleKeyword(id, enabled) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('conversation_optout_keywords')
|
||||
.update({ enabled })
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
const item = keywords.value.find((k) => k.id === id);
|
||||
if (item) item.enabled = enabled;
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'toggle_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKeyword(id) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('conversation_optout_keywords')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
keywords.value = keywords.value.filter((k) => k.id !== id);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'delete_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
optouts,
|
||||
keywords,
|
||||
activeOptouts,
|
||||
historyOptouts,
|
||||
loading,
|
||||
saving,
|
||||
load,
|
||||
addManual,
|
||||
restore,
|
||||
addKeyword,
|
||||
toggleKeyword,
|
||||
deleteKeyword
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useConversationTags.js
|
||||
| Data: 2026-04-21
|
||||
|
|
||||
| Tags aplicáveis a threads de conversa (urgente, primeira consulta, etc).
|
||||
| Combina tags do sistema (tenant_id NULL) com custom do tenant.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
function sanitizeName(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.trim().slice(0, 40);
|
||||
}
|
||||
|
||||
function toSlug(name) {
|
||||
return sanitizeName(name)
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
export function useConversationTags() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const allTags = ref([]); // todas as tags disponíveis (system + custom)
|
||||
const threadTagIds = ref(new Set()); // tag_ids aplicados na thread atual
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const threadTags = computed(() =>
|
||||
allTags.value.filter((t) => threadTagIds.value.has(t.id))
|
||||
);
|
||||
|
||||
// ── Carrega todas as tags visíveis (system + custom do tenant) ────
|
||||
async function loadAllTags() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
loading.value = true;
|
||||
try {
|
||||
// RLS filtra automaticamente: system (tenant_id IS NULL) + custom do tenant ativo
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_tags')
|
||||
.select('id, tenant_id, name, slug, color, icon, position, is_system')
|
||||
.order('position', { ascending: true })
|
||||
.order('name', { ascending: true });
|
||||
if (error) throw error;
|
||||
allTags.value = data || [];
|
||||
} catch (e) {
|
||||
console.error('[useConversationTags] loadAllTags:', e?.message);
|
||||
allTags.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Carrega tags aplicadas em MÚLTIPLAS threads (batch) ────
|
||||
// Retorna Map<thread_key, tag_id[]> pra renderização em Kanban
|
||||
async function loadForThreads(threadKeys) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId || !Array.isArray(threadKeys) || !threadKeys.length) return new Map();
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_thread_tags')
|
||||
.select('thread_key, tag_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('thread_key', threadKeys);
|
||||
if (error) throw error;
|
||||
const map = new Map();
|
||||
for (const row of data || []) {
|
||||
if (!map.has(row.thread_key)) map.set(row.thread_key, []);
|
||||
map.get(row.thread_key).push(row.tag_id);
|
||||
}
|
||||
return map;
|
||||
} catch (e) {
|
||||
console.error('[useConversationTags] loadForThreads:', e?.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Carrega tags aplicadas em uma thread ────
|
||||
async function loadForThread(threadKey) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId || !threadKey) {
|
||||
threadTagIds.value = new Set();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_thread_tags')
|
||||
.select('tag_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('thread_key', threadKey);
|
||||
if (error) throw error;
|
||||
threadTagIds.value = new Set((data || []).map((r) => r.tag_id));
|
||||
} catch (e) {
|
||||
console.error('[useConversationTags] loadForThread:', e?.message);
|
||||
threadTagIds.value = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle: adiciona ou remove tag da thread ────
|
||||
async function toggleOnThread(threadKey, tagId) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId || !threadKey || !tagId) return { ok: false, error: 'invalid_params' };
|
||||
|
||||
saving.value = true;
|
||||
const hasTag = threadTagIds.value.has(tagId);
|
||||
|
||||
try {
|
||||
if (hasTag) {
|
||||
const { error } = await supabase
|
||||
.from('conversation_thread_tags')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('thread_key', threadKey)
|
||||
.eq('tag_id', tagId);
|
||||
if (error) throw error;
|
||||
const next = new Set(threadTagIds.value);
|
||||
next.delete(tagId);
|
||||
threadTagIds.value = next;
|
||||
} else {
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
const userId = authData?.user?.id;
|
||||
if (!userId) return { ok: false, error: 'not_authenticated' };
|
||||
const { error } = await supabase
|
||||
.from('conversation_thread_tags')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
thread_key: threadKey,
|
||||
tag_id: tagId,
|
||||
tagged_by: userId
|
||||
});
|
||||
if (error) throw error;
|
||||
const next = new Set(threadTagIds.value);
|
||||
next.add(tagId);
|
||||
threadTagIds.value = next;
|
||||
}
|
||||
return { ok: true, added: !hasTag };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'toggle_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cria tag custom ────
|
||||
async function createCustomTag({ name, color = '#6366f1', icon = null }) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const cleanName = sanitizeName(name);
|
||||
if (!tenantId || !cleanName) return { ok: false, error: 'invalid_params' };
|
||||
|
||||
const slug = toSlug(cleanName);
|
||||
if (!slug) return { ok: false, error: 'invalid_slug' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_tags')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
name: cleanName,
|
||||
slug,
|
||||
color,
|
||||
icon,
|
||||
is_system: false
|
||||
})
|
||||
.select('id, tenant_id, name, slug, color, icon, position, is_system')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
allTags.value = [...allTags.value, data].sort((a, b) => (a.position - b.position) || a.name.localeCompare(b.name));
|
||||
return { ok: true, tag: data };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'create_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Atualiza tag custom ────
|
||||
async function updateCustomTag(id, { name, color, icon }) {
|
||||
if (!id) return { ok: false, error: 'invalid_id' };
|
||||
const patch = {};
|
||||
if (name !== undefined) {
|
||||
const clean = sanitizeName(name);
|
||||
if (!clean) return { ok: false, error: 'invalid_name' };
|
||||
patch.name = clean;
|
||||
patch.slug = toSlug(clean);
|
||||
if (!patch.slug) return { ok: false, error: 'invalid_slug' };
|
||||
}
|
||||
if (color !== undefined) patch.color = color;
|
||||
if (icon !== undefined) patch.icon = icon;
|
||||
if (!Object.keys(patch).length) return { ok: false, error: 'nothing_to_update' };
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_tags')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.select('id, tenant_id, name, slug, color, icon, position, is_system')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
allTags.value = allTags.value
|
||||
.map((t) => (t.id === id ? data : t))
|
||||
.sort((a, b) => (a.position - b.position) || a.name.localeCompare(b.name));
|
||||
return { ok: true, tag: data };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'update_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remove tag custom (system bloqueada por RLS) ────
|
||||
async function deleteCustomTag(id) {
|
||||
if (!id) return { ok: false, error: 'invalid_id' };
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error } = await supabase.from('conversation_tags').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
allTags.value = allTags.value.filter((t) => t.id !== id);
|
||||
const next = new Set(threadTagIds.value);
|
||||
next.delete(id);
|
||||
threadTagIds.value = next;
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'delete_failed' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
threadTagIds.value = new Set();
|
||||
}
|
||||
|
||||
return {
|
||||
allTags,
|
||||
threadTags,
|
||||
threadTagIds,
|
||||
loading,
|
||||
saving,
|
||||
loadAllTags,
|
||||
loadForThread,
|
||||
loadForThreads,
|
||||
toggleOnThread,
|
||||
createCustomTag,
|
||||
updateCustomTag,
|
||||
deleteCustomTag,
|
||||
clear
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/composables/useConversations.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed, onUnmounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const KANBAN_ORDER = ['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'];
|
||||
|
||||
function sanitizeSearch(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.trim().slice(0, 120).toLowerCase();
|
||||
}
|
||||
|
||||
export function useConversations() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const threads = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const filters = ref({
|
||||
search: '',
|
||||
channel: null, // null = todos
|
||||
unreadOnly: false,
|
||||
assigned: null // null = todas | 'me' | 'unassigned' | <uuid>
|
||||
});
|
||||
|
||||
const currentUserId = ref(null);
|
||||
supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; });
|
||||
|
||||
let realtimeChannel = null;
|
||||
|
||||
async function load() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
threads.value = [];
|
||||
error.value = new Error('Tenant ativo inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const { data, error: qErr } = await supabase
|
||||
.from('conversation_threads')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('last_message_at', { ascending: false })
|
||||
.limit(500);
|
||||
if (qErr) throw qErr;
|
||||
threads.value = data ?? [];
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
threads.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeRealtime() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
if (realtimeChannel) {
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
}
|
||||
realtimeChannel = supabase
|
||||
.channel(`conv_msg_tenant_${tenantId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'conversation_messages',
|
||||
filter: `tenant_id=eq.${tenantId}`
|
||||
},
|
||||
(payload) => {
|
||||
// refetch da lista (view agrega tudo)
|
||||
load();
|
||||
// se o drawer esta aberto numa thread desta msg, appenda
|
||||
const newMsg = payload.new;
|
||||
if (currentThread.value && messageBelongsToThread(newMsg, currentThread.value)) {
|
||||
const alreadyThere = threadMessages.value.some((m) => m.id === newMsg.id);
|
||||
if (!alreadyThere) threadMessages.value.push(newMsg);
|
||||
}
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'conversation_messages',
|
||||
filter: `tenant_id=eq.${tenantId}`
|
||||
},
|
||||
(payload) => {
|
||||
load();
|
||||
const updated = payload.new;
|
||||
if (currentThread.value && messageBelongsToThread(updated, currentThread.value)) {
|
||||
const idx = threadMessages.value.findIndex((m) => m.id === updated.id);
|
||||
if (idx >= 0) threadMessages.value[idx] = updated;
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
function unsubscribeRealtime() {
|
||||
if (realtimeChannel) {
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
realtimeChannel = null;
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => unsubscribeRealtime());
|
||||
|
||||
const filteredThreads = computed(() => {
|
||||
const q = sanitizeSearch(filters.value.search);
|
||||
const assignFilter = filters.value.assigned;
|
||||
const uid = currentUserId.value;
|
||||
return threads.value.filter((t) => {
|
||||
if (filters.value.channel && t.channel !== filters.value.channel) return false;
|
||||
if (filters.value.unreadOnly && (t.unread_count || 0) === 0) return false;
|
||||
if (assignFilter === 'me') {
|
||||
if (!uid || t.assigned_to !== uid) return false;
|
||||
} else if (assignFilter === 'unassigned') {
|
||||
if (t.assigned_to) return false;
|
||||
} else if (assignFilter && typeof assignFilter === 'string') {
|
||||
if (t.assigned_to !== assignFilter) return false;
|
||||
}
|
||||
if (q) {
|
||||
const name = (t.patient_name || '').toLowerCase();
|
||||
const num = (t.contact_number || '').toLowerCase();
|
||||
const body = (t.last_message_body || '').toLowerCase();
|
||||
if (!name.includes(q) && !num.includes(q) && !body.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const byKanban = computed(() => {
|
||||
const map = { urgent: [], awaiting_us: [], awaiting_patient: [], resolved: [] };
|
||||
for (const t of filteredThreads.value) {
|
||||
const k = KANBAN_ORDER.includes(t.kanban_status) ? t.kanban_status : 'awaiting_us';
|
||||
map[k].push(t);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const summary = computed(() => ({
|
||||
total: threads.value.length,
|
||||
urgent: byKanban.value.urgent.length,
|
||||
awaiting_us: byKanban.value.awaiting_us.length,
|
||||
awaiting_patient: byKanban.value.awaiting_patient.length,
|
||||
resolved: byKanban.value.resolved.length,
|
||||
unreadTotal: threads.value.reduce((s, t) => s + (t.unread_count || 0), 0)
|
||||
}));
|
||||
|
||||
// Mensagens de uma thread especifica (drawer)
|
||||
const threadMessages = ref([]);
|
||||
const threadLoading = ref(false);
|
||||
const currentThread = ref(null);
|
||||
|
||||
function messageBelongsToThread(msg, thread) {
|
||||
if (!thread || !msg) return false;
|
||||
if (thread.patient_id) return msg.patient_id === thread.patient_id;
|
||||
// thread anônima
|
||||
if (msg.patient_id) return false;
|
||||
return (
|
||||
msg.from_number === thread.contact_number ||
|
||||
msg.to_number === thread.contact_number
|
||||
);
|
||||
}
|
||||
|
||||
async function loadThreadMessages(thread) {
|
||||
currentThread.value = thread;
|
||||
if (!thread) {
|
||||
threadMessages.value = [];
|
||||
return;
|
||||
}
|
||||
threadLoading.value = true;
|
||||
try {
|
||||
let q = supabase
|
||||
.from('conversation_messages')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantStore.activeTenantId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(500);
|
||||
|
||||
if (thread.patient_id) {
|
||||
q = q.eq('patient_id', thread.patient_id);
|
||||
} else {
|
||||
// anônimo — filtra por from_number ou to_number
|
||||
q = q.or(`from_number.eq.${thread.contact_number},to_number.eq.${thread.contact_number}`).is('patient_id', null);
|
||||
}
|
||||
|
||||
const { data, error: qErr } = await q;
|
||||
if (qErr) throw qErr;
|
||||
threadMessages.value = data ?? [];
|
||||
} finally {
|
||||
threadLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markThreadRead(thread) {
|
||||
if (!thread) return;
|
||||
// Marca unread do inbound como lido
|
||||
const nowIso = new Date().toISOString();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
let q = supabase
|
||||
.from('conversation_messages')
|
||||
.update({ read_at: nowIso })
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('direction', 'inbound')
|
||||
.is('read_at', null);
|
||||
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
|
||||
else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
|
||||
await q;
|
||||
load();
|
||||
}
|
||||
|
||||
async function setKanbanStatus(thread, newStatus) {
|
||||
if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(newStatus)) return;
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
const patch = { kanban_status: newStatus };
|
||||
if (newStatus === 'resolved') patch.resolved_at = new Date().toISOString();
|
||||
|
||||
let q = supabase.from('conversation_messages').update(patch).eq('tenant_id', tenantId);
|
||||
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
|
||||
else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
|
||||
|
||||
await q;
|
||||
load();
|
||||
}
|
||||
|
||||
return {
|
||||
threads,
|
||||
filteredThreads,
|
||||
byKanban,
|
||||
summary,
|
||||
filters,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
subscribeRealtime,
|
||||
unsubscribeRealtime,
|
||||
threadMessages,
|
||||
threadLoading,
|
||||
loadThreadMessages,
|
||||
markThreadRead,
|
||||
setKanbanStatus
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/composables/useLgpdExport.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { downloadLgpdPDF } from '@/utils/lgpdExportFormats';
|
||||
|
||||
function slugify(s) {
|
||||
if (!s) return 'paciente';
|
||||
return (
|
||||
String(s)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.slice(0, 40) || 'paciente'
|
||||
);
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function useLgpdExport() {
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const lastPayload = ref(null);
|
||||
|
||||
async function fetchExport(patientId) {
|
||||
if (!patientId) {
|
||||
throw new Error('patientId obrigatório');
|
||||
}
|
||||
|
||||
const { data, error: rpcErr } = await supabase.rpc('export_patient_data', { p_patient_id: patientId });
|
||||
if (rpcErr) throw rpcErr;
|
||||
return data;
|
||||
}
|
||||
|
||||
async function exportJSON(patientId, patientName) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const payload = await fetchExport(patientId);
|
||||
lastPayload.value = payload;
|
||||
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
const filename = `lgpd-export-${slugify(patientName)}-${ts}.json`;
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
|
||||
downloadBlob(blob, filename);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPDF(patientId, patientName, tenantName) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const payload = await fetchExport(patientId);
|
||||
lastPayload.value = payload;
|
||||
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
const filename = `lgpd-export-${slugify(patientName)}-${ts}.pdf`;
|
||||
await downloadLgpdPDF(payload, tenantName, filename);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
lastPayload,
|
||||
fetchExport,
|
||||
exportJSON,
|
||||
exportPDF
|
||||
};
|
||||
}
|
||||
@@ -23,9 +23,11 @@ import { useTenantStore } from '@/stores/tenantStore';
|
||||
const agendaHoje = ref(0);
|
||||
const cadastrosRecebidos = ref(0);
|
||||
const agendamentosRecebidos = ref(0);
|
||||
const conversasUnread = ref(0);
|
||||
|
||||
let _timer = null;
|
||||
let _started = false;
|
||||
let _realtimeChannel = null;
|
||||
|
||||
async function _refresh() {
|
||||
try {
|
||||
@@ -69,23 +71,63 @@ async function _refresh() {
|
||||
const { count } = await q;
|
||||
agendamentosRecebidos.value = count || 0;
|
||||
}
|
||||
|
||||
// 4. Conversas não lidas (mensagens inbound sem read_at)
|
||||
if (tenantId) {
|
||||
const { count } = await supabase
|
||||
.from('conversation_messages')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('direction', 'inbound')
|
||||
.is('read_at', null);
|
||||
conversasUnread.value = count || 0;
|
||||
}
|
||||
} catch {
|
||||
// badge falhar não deve quebrar a navegação
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe Realtime pra atualizar badge ao vivo
|
||||
function _subscribeRealtime() {
|
||||
try {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||
if (!tenantId) return;
|
||||
if (_realtimeChannel) {
|
||||
supabase.removeChannel(_realtimeChannel);
|
||||
}
|
||||
_realtimeChannel = supabase
|
||||
.channel(`menu_badges_conv_${tenantId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
|
||||
() => _refresh()
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
|
||||
() => _refresh()
|
||||
)
|
||||
.subscribe();
|
||||
} catch {
|
||||
// Realtime falhar não deve quebrar
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API pública ───────────────────────────────────────────
|
||||
export function useMenuBadges() {
|
||||
if (!_started) {
|
||||
_started = true;
|
||||
_refresh();
|
||||
_timer = setInterval(_refresh, 5 * 60 * 1000); // atualiza a cada 5 min
|
||||
_subscribeRealtime();
|
||||
_timer = setInterval(_refresh, 5 * 60 * 1000); // atualiza a cada 5 min (fallback)
|
||||
}
|
||||
|
||||
return {
|
||||
agendaHoje,
|
||||
cadastrosRecebidos,
|
||||
agendamentosRecebidos,
|
||||
conversasUnread,
|
||||
refresh: _refresh
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useSessionReminders.js
|
||||
| Data: 2026-04-21
|
||||
|
|
||||
| Lembretes automáticos de sessão (CRM Grupo 2.4).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const DEFAULTS = {
|
||||
enabled: false,
|
||||
send_24h: true,
|
||||
send_2h: true,
|
||||
template_24h: 'Oi {{nome_paciente}}! 👋 Lembrando da sua sessão amanhã, {{data_sessao}} às {{hora_sessao}}. Até lá!',
|
||||
template_2h: 'Oi {{nome_paciente}}! Sua sessão começa em 2 horas, às {{hora_sessao}}. Te espero! 😊',
|
||||
quiet_hours_enabled: true,
|
||||
quiet_hours_start: '22:00',
|
||||
quiet_hours_end: '08:00',
|
||||
respect_opt_out: true
|
||||
};
|
||||
|
||||
export function useSessionReminders() {
|
||||
const tenantStore = useTenantStore();
|
||||
const settings = ref({ ...DEFAULTS });
|
||||
const recentLogs = ref([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
async function load() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [settingsRes, logsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('session_reminder_settings')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('session_reminder_logs')
|
||||
.select('id, event_id, reminder_type, sent_at, provider, skip_reason, to_phone')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('sent_at', { ascending: false })
|
||||
.limit(30)
|
||||
]);
|
||||
|
||||
if (settingsRes.data) {
|
||||
settings.value = {
|
||||
enabled: !!settingsRes.data.enabled,
|
||||
send_24h: !!settingsRes.data.send_24h,
|
||||
send_2h: !!settingsRes.data.send_2h,
|
||||
template_24h: settingsRes.data.template_24h || DEFAULTS.template_24h,
|
||||
template_2h: settingsRes.data.template_2h || DEFAULTS.template_2h,
|
||||
quiet_hours_enabled: !!settingsRes.data.quiet_hours_enabled,
|
||||
quiet_hours_start: String(settingsRes.data.quiet_hours_start || DEFAULTS.quiet_hours_start).slice(0, 5),
|
||||
quiet_hours_end: String(settingsRes.data.quiet_hours_end || DEFAULTS.quiet_hours_end).slice(0, 5),
|
||||
respect_opt_out: !!settingsRes.data.respect_opt_out
|
||||
};
|
||||
} else {
|
||||
settings.value = { ...DEFAULTS };
|
||||
}
|
||||
|
||||
recentLogs.value = logsRes.data || [];
|
||||
} catch (e) {
|
||||
console.error('[useSessionReminders] load:', e?.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'no_tenant' };
|
||||
|
||||
const payload = { ...settings.value };
|
||||
payload.template_24h = String(payload.template_24h || '').trim().slice(0, 2000);
|
||||
payload.template_2h = String(payload.template_2h || '').trim().slice(0, 2000);
|
||||
if (!payload.template_24h || !payload.template_2h) {
|
||||
return { ok: false, error: 'Templates não podem ficar vazios' };
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('session_reminder_settings')
|
||||
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
|
||||
if (error) throw error;
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'Falha ao salvar' };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Dispara manualmente (pra teste ou pra catch-up de eventos perdidos)
|
||||
async function runNow() {
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('send-session-reminders', { body: {} });
|
||||
if (error) throw error;
|
||||
return { ok: true, stats: data?.stats || null };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'Falha ao executar' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
recentLogs,
|
||||
loading,
|
||||
saving,
|
||||
load,
|
||||
save,
|
||||
runNow
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useWhatsappCredits.js
|
||||
| Data: 2026-04-21
|
||||
|
|
||||
| Sistema de créditos WhatsApp (Marco B).
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
export function useWhatsappCredits() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const balance = ref(null); // { balance, lifetime_purchased, lifetime_used, low_balance_threshold }
|
||||
const transactions = ref([]); // últimos extratos
|
||||
const packages = ref([]); // pacotes ativos (loja)
|
||||
const purchases = ref([]); // minhas ordens de compra
|
||||
const tenantCpfCnpj = ref(''); // CPF/CNPJ armazenado no tenant (pra prefill)
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
|
||||
const currentBalance = computed(() => balance.value?.balance ?? 0);
|
||||
const isLow = computed(() => {
|
||||
if (!balance.value) return false;
|
||||
return balance.value.balance <= balance.value.low_balance_threshold;
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [balRes, txRes, pkgRes, purRes, tenRes] = await Promise.all([
|
||||
supabase
|
||||
.from('whatsapp_credits_balance')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('whatsapp_credits_transactions')
|
||||
.select('id, kind, amount, balance_after, note, created_at, purchase_id, admin_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50),
|
||||
supabase
|
||||
.from('whatsapp_credit_packages')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('position', { ascending: true })
|
||||
.order('price_brl', { ascending: true }),
|
||||
supabase
|
||||
.from('whatsapp_credit_purchases')
|
||||
.select('id, package_name, credits, amount_brl, status, paid_at, expires_at, created_at, asaas_pix_qrcode, asaas_pix_copy_paste, asaas_payment_link')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20),
|
||||
supabase
|
||||
.from('tenants')
|
||||
.select('cpf_cnpj')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle()
|
||||
]);
|
||||
|
||||
balance.value = balRes.data || { balance: 0, lifetime_purchased: 0, lifetime_used: 0, low_balance_threshold: 20 };
|
||||
transactions.value = txRes.data || [];
|
||||
packages.value = pkgRes.data || [];
|
||||
purchases.value = purRes.data || [];
|
||||
tenantCpfCnpj.value = tenRes.data?.cpf_cnpj || '';
|
||||
} catch (e) {
|
||||
console.error('[useWhatsappCredits] loadAll:', e?.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createPurchase(packageId, cpfCnpj = null) {
|
||||
if (!packageId) return { ok: false, error: 'package_missing' };
|
||||
creating.value = true;
|
||||
try {
|
||||
const cleanDoc = (cpfCnpj || '').replace(/\D/g, '') || null;
|
||||
const body = cleanDoc ? { package_id: packageId, cpf_cnpj: cleanDoc } : { package_id: packageId };
|
||||
const { data, error } = await supabase.functions.invoke('create-whatsapp-credit-charge', { body });
|
||||
if (error) {
|
||||
// Edge function errors (non-2xx) vêm em error.context.json() no SDK novo
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = typeof error.context?.json === 'function'
|
||||
? await error.context.json()
|
||||
: null;
|
||||
} catch (_) { /* swallow */ }
|
||||
return {
|
||||
ok: false,
|
||||
error: parsed?.error || error.message || 'invoke_failed',
|
||||
message: parsed?.message || null
|
||||
};
|
||||
}
|
||||
if (!data?.ok) return { ok: false, error: data?.error || 'unknown', message: data?.message || null };
|
||||
// Atualiza CPF armazenado se a compra foi com um novo
|
||||
if (cleanDoc) tenantCpfCnpj.value = cleanDoc;
|
||||
await loadAll();
|
||||
return { ok: true, purchase: data.purchase };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'create_failed' };
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLowBalanceThreshold(newThreshold) {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'no_tenant' };
|
||||
const v = Math.max(0, Math.min(10000, Number(newThreshold) || 0));
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('whatsapp_credits_balance')
|
||||
.upsert({ tenant_id: tenantId, low_balance_threshold: v }, { onConflict: 'tenant_id' });
|
||||
if (error) throw error;
|
||||
if (balance.value) balance.value.low_balance_threshold = v;
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e?.message || 'update_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
balance,
|
||||
transactions,
|
||||
packages,
|
||||
purchases,
|
||||
tenantCpfCnpj,
|
||||
currentBalance,
|
||||
isLow,
|
||||
loading,
|
||||
creating,
|
||||
loadAll,
|
||||
createPurchase,
|
||||
updateLowBalanceThreshold
|
||||
};
|
||||
}
|
||||
@@ -95,8 +95,9 @@ const loadingBloqueios = ref(false);
|
||||
async function loadBloqueiosMes() {
|
||||
if (!_ownerId.value) return;
|
||||
const ano = new Date().getFullYear();
|
||||
const lastDay = new Date(ano, mesAtual, 0).getDate();
|
||||
const start = `${ano}-${String(mesAtual).padStart(2, '0')}-01`;
|
||||
const end = `${ano}-${String(mesAtual).padStart(2, '0')}-31`;
|
||||
const end = `${ano}-${String(mesAtual).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
loadingBloqueios.value = true;
|
||||
try {
|
||||
const { data } = await supabase.from('agenda_bloqueios').select('data_inicio').eq('owner_id', _ownerId.value).in('origem', ['agenda_feriado', 'agenda_dia']).gte('data_inicio', start).lte('data_inicio', end);
|
||||
|
||||
@@ -2075,7 +2075,7 @@ function goRecorrencias() {
|
||||
<div ref="headerSentinelRef" class="ag-sentinel" />
|
||||
|
||||
<!-- Topbar compacta sticky -->
|
||||
<div ref="headerEl" class="ag-topbar mx-3 md:mx-4 mb-3" :class="{ 'ag-topbar--stuck': headerStuck }">
|
||||
<div ref="headerEl" class="ag-topbar my-3 md:mx-4" :class="{ 'ag-topbar--stuck': headerStuck }">
|
||||
<div class="ag-topbar__blobs" aria-hidden="true">
|
||||
<div class="ag-topbar__blob ag-topbar__blob--1" />
|
||||
<div class="ag-topbar__blob ag-topbar__blob--2" />
|
||||
@@ -2141,9 +2141,9 @@ function goRecorrencias() {
|
||||
<div class="hidden xl:flex items-center gap-1">
|
||||
<Button label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
|
||||
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="refetch" />
|
||||
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recorrências" @click="goRecorrencias" />
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="goSettings" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recarregar'" @click="refetch" />
|
||||
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recorrências'" @click="goRecorrencias" />
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Configurações'" @click="goSettings" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2152,7 +2152,7 @@ function goRecorrencias() {
|
||||
<!-- Aviso: fora da jornada -->
|
||||
<div
|
||||
v-if="hasEventsOutsideWorkHours"
|
||||
class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3"
|
||||
class="my-3 md:mx-4 rounded-[6px] p-3"
|
||||
style="background: color-mix(in srgb, var(--yellow-400, #facc15) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--yellow-400, #facc15) 35%, transparent)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
@@ -31,6 +31,15 @@ const mode = computed(() => route.meta?.mode || 'therapist');
|
||||
const isClinic = computed(() => mode.value === 'clinic');
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId);
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null);
|
||||
const headerSentinelRef = ref(null);
|
||||
const headerStuck = ref(false);
|
||||
let _observer = null;
|
||||
|
||||
// ── Mobile ────────────────────────────────────────────────
|
||||
const filtersDlgOpen = ref(false);
|
||||
|
||||
// ── state ──────────────────────────────────────────────────────────────────────
|
||||
const loading = ref(false);
|
||||
const userId = ref(null);
|
||||
@@ -306,25 +315,59 @@ function goBack() {
|
||||
else router.push({ name: 'therapist-agenda' });
|
||||
}
|
||||
|
||||
onMounted(init);
|
||||
onMounted(async () => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
headerStuck.value = !entry.isIntersecting;
|
||||
},
|
||||
{ threshold: 0, rootMargin }
|
||||
);
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
|
||||
await init();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
_observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ─── Header ─────────────────────────────────────────────────── -->
|
||||
<div class="rr-page mx-3 md:mx-5">
|
||||
<div class="rr-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
Hero sticky
|
||||
═══════════════════════════════════════ -->
|
||||
<div
|
||||
ref="headerEl"
|
||||
class="sticky my-3 mx-3 md:mx-4 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/9" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Voltar + Brand -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar à agenda'" @click="goBack" />
|
||||
<div>
|
||||
<div class="text-xl font-bold leading-tight">Recorrências</div>
|
||||
<div class="text-sm opacity-55">
|
||||
<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-refresh text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Recorrências</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">
|
||||
{{ isClinic ? 'Todas as séries da clínica' : 'Suas séries de sessões recorrentes' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<!-- Status filter -->
|
||||
<!-- Filtros desktop -->
|
||||
<div class="hidden xl:flex items-center gap-2 flex-1 min-w-0 mx-2">
|
||||
<SelectButton
|
||||
v-model="filterStatus"
|
||||
:options="[
|
||||
@@ -335,10 +378,9 @@ onMounted(init);
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
size="small"
|
||||
@change="load"
|
||||
/>
|
||||
|
||||
<!-- Therapist filter (clinic only) -->
|
||||
<Select
|
||||
v-if="isClinic && staffOptions.length"
|
||||
v-model="filterOwner"
|
||||
@@ -349,28 +391,75 @@ onMounted(init);
|
||||
class="w-[220px]"
|
||||
@change="load"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-filter" severity="secondary" outlined class="xl:hidden h-9 w-9 rounded-full" v-tooltip.bottom="'Filtros'" @click="filtersDlgOpen = true" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Dialog filtros mobile -->
|
||||
<Dialog v-model:visible="filtersDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Filtros" class="w-[94vw] max-w-sm">
|
||||
<div class="flex flex-col gap-3 pt-1">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5">Status</label>
|
||||
<SelectButton
|
||||
v-model="filterStatus"
|
||||
:options="[
|
||||
{ label: 'Ativas', value: 'ativo' },
|
||||
{ label: 'Encerradas', value: 'cancelado' },
|
||||
{ label: 'Todas', value: 'all' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
class="w-full"
|
||||
@change="load"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isClinic && staffOptions.length">
|
||||
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5">Terapeuta</label>
|
||||
<Select v-model="filterOwner" :options="[{ label: 'Todos os terapeutas', value: null }, ...staffOptions]" optionLabel="label" optionValue="value" placeholder="Todos os terapeutas" class="w-full" @change="load" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="filtersDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
Conteúdo principal
|
||||
═══════════════════════════════════════ -->
|
||||
<div class="px-3 md:px-4 pb-8">
|
||||
<!-- ─── Loading ──────────────────────────────────────────────── -->
|
||||
<div v-if="loading" class="flex flex-col gap-3 mt-4">
|
||||
<div v-if="loading" class="flex flex-col gap-3">
|
||||
<Skeleton v-for="i in 4" :key="i" height="130px" class="rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<!-- ─── Empty ────────────────────────────────────────────────── -->
|
||||
<div v-else-if="!rules.length" class="rr-empty">
|
||||
<i class="pi pi-calendar-times text-5xl opacity-25" />
|
||||
<div class="text-lg font-semibold opacity-50">Nenhuma série encontrada</div>
|
||||
<div class="text-sm opacity-35">
|
||||
{{ filterStatus === 'ativo' ? 'Crie sessões recorrentes na agenda para vê-las aqui.' : 'Altere o filtro de status.' }}
|
||||
<div v-else-if="!rules.length" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-list text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Séries cadastradas</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--surface-200,#e5e7eb)] text-[var(--text-color-secondary)] text-[0.72rem] font-bold">0</span>
|
||||
</div>
|
||||
<div class="py-10 px-6 text-center">
|
||||
<i class="pi pi-calendar-times text-2xl opacity-20 mb-2 block" />
|
||||
<div class="font-semibold text-sm">Nenhuma série encontrada</div>
|
||||
<div class="text-xs opacity-60 mt-1">
|
||||
{{ filterStatus === 'ativo' ? 'Crie sessões recorrentes na agenda para vê-las aqui.' : 'Altere o filtro de status.' }}
|
||||
</div>
|
||||
<Button label="Voltar à agenda" icon="pi pi-calendar" outlined severity="secondary" size="small" class="rounded-full mt-3" @click="goBack" />
|
||||
</div>
|
||||
<Button label="Voltar à agenda" icon="pi pi-calendar" outlined severity="secondary" class="rounded-full mt-2" @click="goBack" />
|
||||
</div>
|
||||
|
||||
<!-- ─── Rule cards ───────────────────────────────────────────── -->
|
||||
<div v-else class="flex flex-col gap-4 mt-4 pb-8">
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div v-for="rule in rules" :key="rule.id" class="rr-card">
|
||||
<!-- Card head: patient info + status badge -->
|
||||
<div class="rr-card__head">
|
||||
@@ -434,33 +523,6 @@ onMounted(init);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Page ─────────────────────────────────────────────────────────── */
|
||||
.rr-page {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────────── */
|
||||
.rr-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 20px 0 16px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── Empty ────────────────────────────────────────────────────────── */
|
||||
.rr-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Card ─────────────────────────────────────────────────────────── */
|
||||
.rr-card {
|
||||
border-radius: 1.25rem;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
@@ -223,6 +223,12 @@ const headerMenuRef = ref(null);
|
||||
const agPanelOpen = ref(false);
|
||||
const blockMenuRef = ref(null);
|
||||
|
||||
// Fecha o drawer mobile ao cruzar para desktop (≥ xl / 1280px)
|
||||
const mqDesktop = typeof window !== 'undefined' ? window.matchMedia('(min-width: 1280px)') : null;
|
||||
const onMqDesktopChange = (e) => {
|
||||
if (e.matches) agPanelOpen.value = false;
|
||||
};
|
||||
|
||||
// ── Prontuário ────────────────────────────────────────────────
|
||||
const prontuarioOpen = ref(false);
|
||||
const selectedPatient = ref(null);
|
||||
@@ -2305,6 +2311,12 @@ onMounted(async () => {
|
||||
);
|
||||
io.observe(headerSentinelRef.value);
|
||||
}
|
||||
|
||||
mqDesktop?.addEventListener('change', onMqDesktopChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mqDesktop?.removeEventListener('change', onMqDesktopChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2319,7 +2331,7 @@ onMounted(async () => {
|
||||
<!-- Hero compacto — padrão Compromissos -->
|
||||
<div
|
||||
ref="headerEl"
|
||||
class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 mx-3 md:mx-4 mb-3 sticky top-[var(--layout-sticky-top,56px)] z-20"
|
||||
class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 my-3 mx-3 md:mx-4 sticky top-[var(--layout-sticky-top,56px)] z-20"
|
||||
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
||||
>
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
@@ -2337,7 +2349,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Nav + filtros (desktop) -->
|
||||
<div class="hidden xl:flex items-center gap-2 flex-1 min-w-0 mx-2">
|
||||
<div class="hidden min-[1500px]:flex items-center gap-2 flex-1 min-w-0 mx-2">
|
||||
<!-- Navegação
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full" @click="goToday" />
|
||||
@@ -2357,7 +2369,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<div class="hidden min-[1500px]:flex items-center gap-1 shrink-0">
|
||||
<div class="w-44">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
@@ -2376,23 +2388,13 @@ onMounted(async () => {
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="onCreateFromButton" />
|
||||
<Button label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
|
||||
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="refetch" />
|
||||
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="goRecorrencias" />
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="goSettings" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recarregar'" @click="refetch" />
|
||||
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recorrências'" @click="goRecorrencias" />
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Configurações'" @click="goSettings" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<!-- Nav mobile -->
|
||||
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-sm font-semibold text-[var(--text-color)] cursor-pointer whitespace-nowrap transition-colors duration-150 hover:border-[var(--p-primary-400)]"
|
||||
@click="toggleMonthPicker"
|
||||
>
|
||||
<i class="pi pi-calendar text-xs opacity-60" />
|
||||
{{ subtitleText }}
|
||||
</span>
|
||||
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
|
||||
<div class="flex min-[1500px]:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<div v-if="feriadosTodosProximos.length" class="relative">
|
||||
<Button icon="pi pi-bell" :severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'" outlined class="h-9 w-9 rounded-full" @click="feriadosAlertaOpen = true" />
|
||||
<span v-if="feriadosSemBloqueio.length" class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[0.65rem] font-bold flex items-center justify-center pointer-events-none">{{
|
||||
@@ -2411,7 +2413,7 @@ onMounted(async () => {
|
||||
<div
|
||||
ref="foraJornadaBannerRef"
|
||||
v-if="hasEventsOutsideWorkHours"
|
||||
class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3"
|
||||
class="my-3 mx-3 md:mx-4 rounded-[6px] p-3"
|
||||
style="background: color-mix(in srgb, var(--yellow-400, #facc15) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--yellow-400, #facc15) 35%, transparent)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -2423,11 +2425,15 @@ onMounted(async () => {
|
||||
|
||||
<!-- ════ GRID 3 COLUNAS ════ -->
|
||||
<!-- Overlay mobile -->
|
||||
<div v-if="agPanelOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-[39] xl:hidden" @click="agPanelOpen = false" />
|
||||
<div
|
||||
v-if="agPanelOpen"
|
||||
class="fixed left-0 right-0 bottom-0 top-[calc(var(--notice-banner-height,0px)+56px)] bg-black/40 backdrop-blur-sm z-[39] xl:hidden"
|
||||
@click="agPanelOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Drawer mobile: col esquerda + col direita empilhadas -->
|
||||
<aside
|
||||
class="panel-drawer fixed top-0 left-0 h-[100dvh] w-[min(340px,88vw)] z-40 bg-[var(--surface-card)] border-r border-[var(--surface-border)] shadow-[4px_0_24px_rgba(0,0,0,0.12)] transition-[transform,visibility] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
class="panel-drawer fixed top-[calc(var(--notice-banner-height,0px)+56px)] left-0 h-[calc(100dvh-var(--notice-banner-height,0px)-56px)] w-[min(340px,88vw)] z-40 bg-[var(--surface-card)] border-r border-[var(--surface-border)] shadow-[4px_0_24px_rgba(0,0,0,0.12)] transition-[transform,visibility] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
:class="agPanelOpen ? 'translate-x-0 visible' : '-translate-x-full invisible'"
|
||||
>
|
||||
<div class="flex flex-col gap-3 p-4 h-full overflow-y-auto">
|
||||
@@ -2668,22 +2674,23 @@ onMounted(async () => {
|
||||
<!-- COL 1: Mini calendário + Jornada + Feriados -->
|
||||
<div class="hidden xl:flex flex-col gap-3 w-full xl:w-[25%] shrink-0">
|
||||
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between min-w-0">
|
||||
<!--<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-calendar" />{{ visibleTitle }}</span>-->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="flex items-center gap-2 mb-2 min-w-0 flex-1">
|
||||
<!--<Button icon="pi pi-home" severity="secondary" text class="h-7 w-7 rounded-full" v-tooltip.top="'Hoje'" @click="miniGoToday" />
|
||||
<Button icon="pi pi-chevron-left" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniPrevMonth" />
|
||||
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" />-->
|
||||
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full" @click="goToday" />
|
||||
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
|
||||
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full shrink-0" @click="goToday" />
|
||||
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full shrink-0" @click="goPrev" />
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-sm font-semibold text-[var(--text-color)] cursor-pointer whitespace-nowrap transition-colors duration-150 hover:border-[var(--p-primary-400)]"
|
||||
v-tooltip.top="subtitleText"
|
||||
class="inline-flex flex-1 min-w-0 items-center gap-1.5 px-3 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-sm font-semibold text-[var(--text-color)] cursor-pointer transition-colors duration-150 hover:border-[var(--p-primary-400)]"
|
||||
@click="toggleMonthPicker"
|
||||
>
|
||||
<i class="pi pi-calendar text-xs opacity-60" />
|
||||
{{ subtitleText }}
|
||||
<i class="pi pi-calendar text-xs opacity-60 shrink-0" />
|
||||
<span class="truncate">{{ subtitleText }}</span>
|
||||
</span>
|
||||
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
|
||||
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full shrink-0" @click="goNext" />
|
||||
</div>
|
||||
</div>
|
||||
<DatePicker v-model="miniDate" inline class="w-full" @update:modelValue="onMiniPick" :pt="{ day: ({ context }) => ({ class: miniDayClass(context.date) }) }">
|
||||
|
||||
@@ -350,7 +350,7 @@ const emptySub = computed(() => {
|
||||
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 my-3 md:mx-4 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 -->
|
||||
@@ -421,7 +421,7 @@ const emptySub = computed(() => {
|
||||
<Transition name="ar-banner">
|
||||
<div
|
||||
v-if="totalAutorizados > 0 && filtroStatus !== 'autorizado' && !loading"
|
||||
class="mx-3 md:mx-4 mb-3 flex items-center gap-3 px-4 py-3 rounded-md border border-amber-300/60 bg-amber-50 cursor-pointer hover:bg-amber-100/70 transition-colors duration-150"
|
||||
class="my-3 md:mx-4 flex items-center gap-3 px-4 py-3 rounded-md border border-amber-300/60 bg-amber-50 cursor-pointer hover:bg-amber-100/70 transition-colors duration-150"
|
||||
@click="filtroStatus = 'autorizado'"
|
||||
>
|
||||
<!-- Ícone pulsante -->
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
-->
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import InputSwitch from 'primevue/inputswitch';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Menu from 'primevue/menu';
|
||||
|
||||
import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue';
|
||||
@@ -29,6 +30,14 @@ import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
function goBack() {
|
||||
const isClinic = (route.name || '').toString().startsWith('admin-');
|
||||
if (isClinic) router.push({ name: 'admin-agenda-clinica' });
|
||||
else router.push({ name: 'therapist-agenda' });
|
||||
}
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null);
|
||||
@@ -382,7 +391,7 @@ function isRecent(row) {
|
||||
═══════════════════════════════════════ -->
|
||||
<div
|
||||
ref="headerEl"
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-(--surface-border,#e2e8f0) bg-(--surface-card,#fff) px-3 py-2.5 transition-[border-radius] duration-200"
|
||||
class="sticky my-3 mx-3 md:mx-4 z-20 overflow-hidden rounded-md border border-(--surface-border,#e2e8f0) bg-(--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)' }"
|
||||
>
|
||||
@@ -393,8 +402,9 @@ function isRecent(row) {
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<!-- Voltar + Brand -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar à agenda'" @click="goBack" />
|
||||
<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-list text-base" />
|
||||
</div>
|
||||
@@ -453,7 +463,7 @@ function isRecent(row) {
|
||||
═══════════════════════════════════════ -->
|
||||
<div class="px-3 md:px-4 pb-5 flex flex-col xl:flex-row gap-3 xl:gap-4 items-start">
|
||||
<!-- ── Coluna principal ── -->
|
||||
<div class="w-full xl:flex-1 xl:min-w-0">
|
||||
<div class="w-full xl:flex-1 xl:min-w-0 flex flex-col gap-3 xl:gap-4">
|
||||
<!-- Stats row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2">
|
||||
<template v-if="loading">
|
||||
@@ -471,7 +481,7 @@ function isRecent(row) {
|
||||
>
|
||||
{{ 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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -533,7 +543,7 @@ function isRecent(row) {
|
||||
|
||||
<Column field="active" header="Ativo" style="width: 7rem">
|
||||
<template #body="{ data }">
|
||||
<InputSwitch v-model="data.active" :disabled="isActiveLocked(data) || saving" @change="onToggleActive(data)" />
|
||||
<ToggleSwitch v-model="data.active" :disabled="isActiveLocked(data) || saving" @change="onToggleActive(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/conversations/CRMConversasPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useConversations } from '@/composables/useConversations';
|
||||
import { useConversationTags } from '@/composables/useConversationTags';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const tenantStore = useTenantStore();
|
||||
const drawerStore = useConversationDrawerStore();
|
||||
|
||||
function goSettings() {
|
||||
router.push('/configuracoes/whatsapp');
|
||||
}
|
||||
|
||||
const { threads, filteredThreads, byKanban, summary, filters, loading, load, subscribeRealtime, unsubscribeRealtime } = useConversations();
|
||||
|
||||
// Tags — pra renderizar pills nos cards
|
||||
const tagsApi = useConversationTags();
|
||||
const threadTagsMap = ref(new Map()); // Map<thread_key, tag_id[]>
|
||||
const tagById = computed(() => {
|
||||
const m = {};
|
||||
for (const t of tagsApi.allTags.value) m[t.id] = t;
|
||||
return m;
|
||||
});
|
||||
|
||||
function tagsForThread(threadKey) {
|
||||
const ids = threadTagsMap.value.get(threadKey) || [];
|
||||
return ids.map((id) => tagById.value[id]).filter(Boolean);
|
||||
}
|
||||
|
||||
async function reloadThreadTags() {
|
||||
const keys = filteredThreads.value.map((t) => t.thread_key);
|
||||
threadTagsMap.value = await tagsApi.loadForThreads(keys);
|
||||
}
|
||||
|
||||
// Layout
|
||||
const { effectiveVariant, layoutState, layoutConfig, isMobile, railPanelPushesLayout } = useLayout();
|
||||
const isMobileLayout = computed(() => isMobile.value);
|
||||
const asideLeft = computed(() => {
|
||||
if (isMobileLayout.value) return undefined;
|
||||
if (effectiveVariant.value !== 'rail') {
|
||||
const isStaticActive = layoutConfig.menuMode === 'static' && !layoutState.staticMenuInactive;
|
||||
return isStaticActive ? '20rem' : '0';
|
||||
}
|
||||
return railPanelPushesLayout.value ? 'calc(60px + 260px)' : '60px';
|
||||
});
|
||||
const asideOpen = ref(false);
|
||||
|
||||
const KANBAN_COLUMNS = [
|
||||
{ key: 'urgent', label: 'Urgente', icon: 'pi pi-exclamation-triangle', color: 'red' },
|
||||
{ key: 'awaiting_us', label: 'Aguardando resposta', icon: 'pi pi-inbox', color: 'amber' },
|
||||
{ key: 'awaiting_patient', label: 'Aguardando paciente', icon: 'pi pi-hourglass', color: 'blue' },
|
||||
{ key: 'resolved', label: 'Resolvido', icon: 'pi pi-check', color: 'emerald' }
|
||||
];
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ label: 'Todos', value: null },
|
||||
{ label: 'WhatsApp', value: 'whatsapp' },
|
||||
{ label: 'SMS', value: 'sms' },
|
||||
{ label: 'E-mail', value: 'email' }
|
||||
];
|
||||
|
||||
function fmtRelative(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - d) / 1000);
|
||||
if (diff < 60) return 'agora';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||
return d.toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
function channelIcon(ch) {
|
||||
const map = { whatsapp: 'pi-whatsapp', sms: 'pi-comment', email: 'pi-envelope' };
|
||||
return map[ch] || 'pi-comment';
|
||||
}
|
||||
|
||||
function truncate(s, n = 80) {
|
||||
if (!s) return '';
|
||||
const str = String(s).replace(/\s+/g, ' ').trim();
|
||||
return str.length > n ? str.slice(0, n - 1) + '…' : str;
|
||||
}
|
||||
|
||||
function contactLabel(thread) {
|
||||
return thread.patient_name || thread.contact_number || 'Desconhecido';
|
||||
}
|
||||
|
||||
function onCardClick(thread) {
|
||||
drawerStore.openForThread(thread);
|
||||
if (isMobileLayout.value) asideOpen.value = false;
|
||||
}
|
||||
|
||||
// Top 10 pacientes com atividade recente — aside
|
||||
const recentPatients = computed(() => {
|
||||
return filteredThreads.value
|
||||
.filter((t) => t.patient_id)
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
const unlinkedCount = computed(() => filteredThreads.value.filter((t) => !t.patient_id).length);
|
||||
|
||||
// Atribuição — contadores sobre threads (antes do filtro de atribuição)
|
||||
const currentUserId = ref(null);
|
||||
supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; });
|
||||
const mineCount = computed(() => {
|
||||
const uid = currentUserId.value;
|
||||
if (!uid) return 0;
|
||||
return threads.value.filter((t) => t.assigned_to === uid).length;
|
||||
});
|
||||
const unassignedCount = computed(() => threads.value.filter((t) => !t.assigned_to).length);
|
||||
|
||||
// Map user_id → nome curto pra chip no card
|
||||
const memberNameMap = ref({});
|
||||
async function loadMemberNames() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
const { data } = await supabase
|
||||
.from('v_tenant_members_with_profiles')
|
||||
.select('user_id, full_name, email')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('status', 'active');
|
||||
const map = {};
|
||||
for (const m of (data || [])) {
|
||||
const full = m.full_name || m.email || '';
|
||||
map[m.user_id] = full;
|
||||
}
|
||||
memberNameMap.value = map;
|
||||
}
|
||||
function assigneeLabel(userId) {
|
||||
if (!userId) return '';
|
||||
const full = memberNameMap.value[userId];
|
||||
if (!full) return 'Atribuída';
|
||||
const parts = full.trim().split(/\s+/);
|
||||
if (parts.length === 1) return parts[0].slice(0, 14);
|
||||
return `${parts[0]} ${parts[parts.length - 1][0]}.`;
|
||||
}
|
||||
|
||||
// Abre drawer automaticamente se query ?patient=<uuid>
|
||||
async function openThreadByPatientId(patientId) {
|
||||
if (!patientId) return;
|
||||
await drawerStore.openForPatient(patientId);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
subscribeRealtime();
|
||||
|
||||
// Carrega definições de tags e tags por thread (em paralelo) + nomes de membros
|
||||
await Promise.all([tagsApi.loadAllTags(), reloadThreadTags(), loadMemberNames()]);
|
||||
|
||||
// Se vier com query ?patient=<uuid>, abre o drawer direto
|
||||
const patientFromQuery = route.query?.patient;
|
||||
if (patientFromQuery) {
|
||||
await openThreadByPatientId(String(patientFromQuery));
|
||||
}
|
||||
});
|
||||
|
||||
// Recarrega tags + threads quando o drawer fecha (tags/atribuição podem ter mudado)
|
||||
watch(() => drawerStore.isOpen, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
reloadThreadTags();
|
||||
load();
|
||||
}
|
||||
});
|
||||
|
||||
// Recarrega tags quando a lista de threads muda (nova mensagem cria nova thread)
|
||||
watch(() => filteredThreads.value.length, () => { reloadThreadTags(); });
|
||||
|
||||
// Reage a mudanças de rota (ex: clicou outro paciente)
|
||||
watch(
|
||||
() => route.query?.patient,
|
||||
async (pid) => { if (pid) await openThreadByPatientId(String(pid)); }
|
||||
);
|
||||
|
||||
watch(() => tenantStore.activeTenantId, async () => {
|
||||
unsubscribeRealtime();
|
||||
await load();
|
||||
subscribeRealtime();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[calc(100vh-4.5rem)] bg-[var(--surface-ground,#f5f7fa)]">
|
||||
<!-- Overlay mobile -->
|
||||
<div v-if="asideOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-[39] xl:hidden" @click="asideOpen = false" />
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
ASIDE — filtros rápidos + pacientes ativos
|
||||
══════════════════════════════════════════ -->
|
||||
<aside
|
||||
class="aside-drawer flex flex-col overflow-y-auto shrink-0 bg-[var(--surface-card,#fff)] border-r border-[var(--surface-border,#e2e8f0)]"
|
||||
:class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'"
|
||||
:style="{ left: asideLeft }"
|
||||
>
|
||||
<!-- Cabeçalho -->
|
||||
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
|
||||
<i class="pi pi-filter" /><span>Filtros rápidos</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<button
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
|
||||
:class="{ 'ring-2 ring-blue-500/40': !filters.unreadOnly && !filters.channel }"
|
||||
@click="filters.unreadOnly = false; filters.channel = null; filters.search = ''"
|
||||
>
|
||||
<span class="flex items-center gap-2"><i class="pi pi-list text-xs" /> Todas</span>
|
||||
<Badge :value="summary.total" severity="secondary" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-red-500/5"
|
||||
:class="{ 'ring-2 ring-red-500/40 bg-red-500/5': filters.unreadOnly }"
|
||||
@click="filters.unreadOnly = !filters.unreadOnly"
|
||||
>
|
||||
<span class="flex items-center gap-2"><i class="pi pi-bell text-xs text-red-500" /> Não lidas</span>
|
||||
<Badge :value="summary.unreadTotal" :severity="summary.unreadTotal > 0 ? 'danger' : 'secondary'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Atribuição -->
|
||||
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
|
||||
<i class="pi pi-user" /><span>Atribuição</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<button
|
||||
class="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
|
||||
:class="{ 'ring-2 ring-blue-500/40 bg-blue-500/5': !filters.assigned }"
|
||||
@click="filters.assigned = null"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-xs"><i class="pi pi-list text-xs" /> Todas</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
|
||||
:class="{ 'ring-2 ring-blue-500/40 bg-blue-500/5': filters.assigned === 'me' }"
|
||||
@click="filters.assigned = 'me'"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-xs"><i class="pi pi-user text-xs text-blue-500" /> Minhas</span>
|
||||
<Badge :value="mineCount" severity="secondary" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
|
||||
:class="{ 'ring-2 ring-amber-500/40 bg-amber-500/5': filters.assigned === 'unassigned' }"
|
||||
@click="filters.assigned = 'unassigned'"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-xs"><i class="pi pi-user-minus text-xs text-amber-500" /> Não atribuídas</span>
|
||||
<Badge :value="unassignedCount" :severity="unassignedCount > 0 ? 'warn' : 'secondary'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Kanban (resumo) -->
|
||||
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
|
||||
<i class="pi pi-chart-bar" /><span>Por status</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="col in KANBAN_COLUMNS"
|
||||
:key="col.key"
|
||||
class="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm border border-[var(--surface-border)]"
|
||||
:class="{
|
||||
'border-red-500/30 bg-red-500/5': col.color === 'red' && summary[col.key] > 0,
|
||||
'border-amber-500/30 bg-amber-500/5': col.color === 'amber' && summary[col.key] > 0,
|
||||
'border-blue-500/30 bg-blue-500/5': col.color === 'blue' && summary[col.key] > 0,
|
||||
'border-emerald-500/30 bg-emerald-500/5': col.color === 'emerald' && summary[col.key] > 0
|
||||
}"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
<i :class="col.icon" />
|
||||
{{ col.label }}
|
||||
</span>
|
||||
<Badge :value="summary[col.key] || 0" severity="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canais -->
|
||||
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
|
||||
<i class="pi pi-send" /><span>Canais</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<button
|
||||
v-for="opt in CHANNEL_OPTIONS"
|
||||
:key="String(opt.value)"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
|
||||
:class="{ 'ring-2 ring-blue-500/40 bg-blue-500/5': filters.channel === opt.value }"
|
||||
@click="filters.channel = opt.value"
|
||||
>
|
||||
<i v-if="opt.value" :class="['pi text-xs', channelIcon(opt.value)]" />
|
||||
<i v-else class="pi pi-list text-xs" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pacientes com atividade recente -->
|
||||
<div v-if="recentPatients.length" class="p-3.5 pb-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
|
||||
<i class="pi pi-users" /><span>Recentes</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="t in recentPatients"
|
||||
:key="t.thread_key"
|
||||
class="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md text-xs border-none bg-transparent cursor-pointer text-left hover:bg-[var(--surface-hover)] transition-colors"
|
||||
@click="onCardClick(t)"
|
||||
>
|
||||
<span class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<i :class="['pi', channelIcon(t.channel), 'text-[0.65rem] opacity-60']" />
|
||||
<span class="truncate text-[var(--text-color)]">{{ contactLabel(t) }}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1 shrink-0">
|
||||
<Badge v-if="t.unread_count > 0" :value="t.unread_count" severity="danger" />
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-75">{{ fmtRelative(t.last_message_at) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerta: não vinculados -->
|
||||
<div v-if="unlinkedCount > 0" class="p-3.5">
|
||||
<div class="flex items-start gap-2 p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-xs">
|
||||
<i class="pi pi-exclamation-circle text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<div class="font-semibold text-amber-700">{{ unlinkedCount }} conversa(s) sem paciente vinculado</div>
|
||||
<div class="opacity-75">Números de telefone que não batem com pacientes cadastrados.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
ÁREA PRINCIPAL
|
||||
══════════════════════════════════════════ -->
|
||||
<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' }">
|
||||
<!-- Header -->
|
||||
<section class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||
<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-blue-500/10" />
|
||||
<div class="absolute w-80 h-80 top-2 -left-20 rounded-full blur-[70px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center 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">
|
||||
<i class="pi pi-comments text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1.1rem] font-bold tracking-tight text-[var(--text-color)]">Conversas</div>
|
||||
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">
|
||||
CRM de WhatsApp · {{ summary.total }} conversa(s)
|
||||
<span v-if="summary.unreadTotal > 0" class="ml-2 text-red-500 font-semibold">· {{ summary.unreadTotal }} não lida(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="filters.search" placeholder="Buscar paciente, número ou mensagem" class="w-64" maxlength="120" />
|
||||
</IconField>
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Configurações'" @click="goSettings" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toggle aside — mobile -->
|
||||
<button
|
||||
class="xl:hidden flex w-full items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] rounded-md text-sm font-semibold text-[var(--text-color)] cursor-pointer"
|
||||
@click="asideOpen = !asideOpen"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-filter text-[var(--primary-color,#6366f1)]" />
|
||||
<span>Filtros & recentes</span>
|
||||
</div>
|
||||
<i class="pi transition-transform duration-200" :class="asideOpen ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||
</button>
|
||||
|
||||
<!-- Kanban -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<div
|
||||
v-for="col in KANBAN_COLUMNS"
|
||||
:key="col.key"
|
||||
class="flex flex-col rounded-lg border bg-[var(--surface-card)] min-h-[400px]"
|
||||
:class="{
|
||||
'border-red-500/40': col.color === 'red',
|
||||
'border-amber-500/40': col.color === 'amber',
|
||||
'border-blue-500/40': col.color === 'blue',
|
||||
'border-emerald-500/40': col.color === 'emerald'
|
||||
}"
|
||||
>
|
||||
<!-- Column header -->
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 border-b"
|
||||
:class="{
|
||||
'border-red-500/30 bg-red-500/5': col.color === 'red',
|
||||
'border-amber-500/30 bg-amber-500/5': col.color === 'amber',
|
||||
'border-blue-500/30 bg-blue-500/5': col.color === 'blue',
|
||||
'border-emerald-500/30 bg-emerald-500/5': col.color === 'emerald'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm font-semibold">
|
||||
<i :class="col.icon" />
|
||||
{{ col.label }}
|
||||
</div>
|
||||
<Badge :value="byKanban[col.key].length" severity="secondary" />
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class="flex flex-col gap-2 p-2 overflow-y-auto max-h-[calc(100vh-320px)]">
|
||||
<div v-if="loading && !byKanban[col.key].length" class="text-xs text-[var(--text-color-secondary)] p-3 text-center">
|
||||
<i class="pi pi-spin pi-spinner mr-2" />Carregando...
|
||||
</div>
|
||||
<div v-else-if="!byKanban[col.key].length" class="text-xs text-[var(--text-color-secondary)] p-6 text-center italic">
|
||||
Nenhuma conversa.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="t in byKanban[col.key]"
|
||||
:key="t.thread_key"
|
||||
class="flex flex-col gap-1.5 p-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] cursor-pointer hover:shadow-sm hover:border-blue-500/40 transition-all"
|
||||
@click="onCardClick(t)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<i :class="['pi', channelIcon(t.channel), 'text-[var(--text-color-secondary)]']" />
|
||||
<span class="text-sm font-semibold truncate text-[var(--text-color)]">{{ contactLabel(t) }}</span>
|
||||
</div>
|
||||
<Badge v-if="t.unread_count > 0" :value="t.unread_count" severity="danger" />
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-[var(--text-color-secondary)] truncate">
|
||||
<i v-if="t.last_message_direction === 'outbound'" class="pi pi-arrow-right text-[0.6rem] mr-1 opacity-60" />
|
||||
{{ truncate(t.last_message_body) }}
|
||||
</div>
|
||||
|
||||
<!-- Tags pills -->
|
||||
<div v-if="tagsForThread(t.thread_key).length" class="flex items-center gap-1 flex-wrap">
|
||||
<span
|
||||
v-for="tag in tagsForThread(t.thread_key)"
|
||||
:key="tag.id"
|
||||
class="inline-flex items-center gap-1 px-1.5 py-px rounded-full text-[0.62rem] font-semibold leading-tight"
|
||||
:style="{
|
||||
background: tag.color + '20',
|
||||
color: tag.color,
|
||||
border: `1px solid ${tag.color}40`
|
||||
}"
|
||||
>
|
||||
<i v-if="tag.icon" :class="tag.icon" class="text-[0.55rem]" />
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
|
||||
<span>{{ fmtRelative(t.last_message_at) }}</span>
|
||||
<span
|
||||
v-if="t.assigned_to"
|
||||
class="inline-flex items-center gap-1 px-1.5 py-px rounded-full text-[0.62rem] font-semibold"
|
||||
:class="t.assigned_to === currentUserId
|
||||
? 'bg-blue-500/15 text-blue-600 border border-blue-500/30'
|
||||
: 'bg-[var(--surface-hover)] text-[var(--text-color)] border border-[var(--surface-border)]'"
|
||||
v-tooltip.top="t.assigned_to === currentUserId ? 'Atribuída a mim' : 'Atribuída a ' + (memberNameMap[t.assigned_to] || '')"
|
||||
>
|
||||
<i class="pi pi-user text-[0.55rem]" />
|
||||
{{ t.assigned_to === currentUserId ? 'Eu' : assigneeLabel(t.assigned_to) }}
|
||||
</span>
|
||||
<span v-else-if="!t.patient_name" class="italic">não vinculado</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aside-drawer {
|
||||
position: fixed;
|
||||
top: calc(56px + var(--notice-banner-height, 0px));
|
||||
left: 0;
|
||||
height: calc(100dvh - 56px - var(--notice-banner-height, 0px));
|
||||
width: min(300px, 85vw);
|
||||
z-index: 40;
|
||||
overflow-y: auto;
|
||||
transition:
|
||||
transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
visibility 0.25s;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.aside-drawer {
|
||||
position: fixed;
|
||||
top: calc(56px + var(--notice-banner-height, 0px));
|
||||
height: calc(100vh - 56px - var(--notice-banner-height, 0px));
|
||||
width: 272px;
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
box-shadow: none;
|
||||
z-index: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import Menu from 'primevue/menu'
|
||||
@@ -39,6 +39,13 @@ const view = ref('list') // list | create | edit
|
||||
const editingTemplate = ref({})
|
||||
const editingId = ref(null)
|
||||
|
||||
// ── Hero sticky ─────────────────────────────────────────────
|
||||
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ─────────────────────────────────────────────
|
||||
|
||||
const mobileMenuRef = ref(null)
|
||||
@@ -50,7 +57,19 @@ const mobileMenuItems = [
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────
|
||||
|
||||
onMounted(() => fetchTemplates(true))
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
fetchTemplates(true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
_observer?.disconnect()
|
||||
})
|
||||
|
||||
// ── Acoes ───────────────────────────────────────────────────
|
||||
|
||||
@@ -148,111 +167,155 @@ function getCardMenuItems(tpl) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-6 max-w-[1200px] mx-auto">
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="view !== 'list'"
|
||||
icon="pi pi-arrow-left"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="view = 'list'"
|
||||
/>
|
||||
<h1 class="text-xl font-bold">
|
||||
<!-- Hero sticky -->
|
||||
<div
|
||||
ref="headerEl"
|
||||
class="sticky my-3 md:mx-4 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/9" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Voltar (create/edit) + Brand -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button v-if="view !== 'list'" icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar à lista'" @click="view = 'list'" />
|
||||
<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-file-edit text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">
|
||||
<template v-if="view === 'list'">Templates de documentos</template>
|
||||
<template v-else-if="view === 'create'">Novo template</template>
|
||||
<template v-else>Editar template</template>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Modelos para declarações, atestados, recibos e outros documentos</div>
|
||||
</div>
|
||||
<p v-if="view === 'list'" class="text-sm text-[var(--text-color-secondary)]">
|
||||
Modelos para declarações, atestados, recibos e outros documentos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="view === 'list'" class="hidden sm:flex items-center gap-2">
|
||||
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openCreate" />
|
||||
<!-- Ações LIST (desktop) -->
|
||||
<div v-if="view === 'list'" class="hidden xl:flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Atualizar'" @click="fetchTemplates(true)" />
|
||||
<Button label="Novo template" icon="pi pi-plus" size="small" class="rounded-full" @click="openCreate" />
|
||||
</div>
|
||||
<div v-if="view === 'list'" class="sm:hidden">
|
||||
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
|
||||
|
||||
<!-- Ações LIST (mobile) -->
|
||||
<div v-if="view === 'list'" class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<!-- Ações CREATE/EDIT (desktop) -->
|
||||
<div v-if="view !== 'list'" class="hidden sm:flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button label="Cancelar" severity="secondary" outlined size="small" class="rounded-full" @click="view = 'list'" />
|
||||
<Button :label="view === 'create' ? 'Criar template' : 'Salvar'" icon="pi pi-check" size="small" class="rounded-full" @click="onSave(editingTemplate)" />
|
||||
</div>
|
||||
|
||||
<!-- Ações CREATE/EDIT (mobile — só ícones) -->
|
||||
<div v-if="view !== 'list'" class="flex sm:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-times" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Cancelar'" @click="view = 'list'" />
|
||||
<Button icon="pi pi-check" class="h-9 w-9 rounded-full" v-tooltip.bottom="view === 'create' ? 'Criar template' : 'Salvar'" @click="onSave(editingTemplate)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3 xl:gap-4">
|
||||
<!-- ══ LIST VIEW ══════════════════════════════════════════ -->
|
||||
<template v-if="view === 'list'">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div v-if="loading" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] flex items-center justify-center py-16">
|
||||
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!templates.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-file-edit text-4xl opacity-30 mb-3" />
|
||||
<div class="text-sm mb-1">Nenhum template encontrado.</div>
|
||||
<Button label="Criar primeiro template" icon="pi pi-plus" text size="small" class="mt-2" @click="openCreate" />
|
||||
<!-- Empty (nenhum template) -->
|
||||
<div v-else-if="!templates.length" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-file-edit text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Templates cadastrados</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--surface-200,#e5e7eb)] text-[var(--text-color-secondary)] text-[0.72rem] font-bold">0</span>
|
||||
</div>
|
||||
<div class="py-10 px-6 text-center">
|
||||
<i class="pi pi-file-edit text-2xl opacity-20 mb-2 block" />
|
||||
<div class="font-semibold text-sm">Nenhum template encontrado</div>
|
||||
<div class="text-xs opacity-60 mt-1">Crie seu primeiro template personalizado</div>
|
||||
<Button label="Criar primeiro template" icon="pi pi-plus" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="openCreate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Templates globais (padrao) -->
|
||||
<div v-if="globalTemplates.length" class="mb-6">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
|
||||
Templates padrão do sistema
|
||||
<!-- Templates globais (padrão) -->
|
||||
<div v-if="globalTemplates.length" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-shield text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Templates padrão do sistema</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-blue-500/10 text-blue-600 text-[0.72rem] font-bold">{{ globalTemplates.length }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 p-3">
|
||||
<div
|
||||
v-for="tpl in globalTemplates"
|
||||
:key="tpl.id"
|
||||
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-[var(--surface-400)] hover:bg-[var(--surface-hover)] transition-all cursor-pointer"
|
||||
class="group relative flex flex-col p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] hover:border-[var(--primary-color,#6366f1)] hover:bg-[var(--surface-hover,#f1f5f9)] transition-all duration-150 cursor-pointer"
|
||||
@click="onDuplicate(tpl)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<span class="shrink-0 w-9 h-9 rounded-md bg-blue-500/10 grid place-items-center">
|
||||
<i class="pi pi-file text-blue-500" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
|
||||
<div class="text-sm font-semibold text-[var(--text-color)] line-clamp-1">{{ tpl.nome_template }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
|
||||
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
|
||||
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2 opacity-75">{{ tpl.descricao }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="absolute top-2 right-2 text-[0.6rem] px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600">
|
||||
padrão
|
||||
</span>
|
||||
<div class="mt-2 text-[0.65rem] text-[var(--text-color-secondary)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Clique para duplicar e personalizar
|
||||
<span class="absolute top-2 right-2 text-[0.6rem] font-semibold px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600">padrão</span>
|
||||
<div class="mt-2 text-[0.7rem] text-[var(--text-color-secondary)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<i class="pi pi-copy text-[0.7rem] mr-1" />Clique para duplicar e personalizar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates do tenant -->
|
||||
<div v-if="tenantTemplates.length">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
|
||||
Meus templates
|
||||
<div v-if="tenantTemplates.length" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-user-edit text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Meus templates</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.72rem] font-bold">{{ tenantTemplates.length }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 p-3">
|
||||
<div
|
||||
v-for="tpl in tenantTemplates"
|
||||
:key="tpl.id"
|
||||
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-primary hover:bg-primary/5 transition-all cursor-pointer"
|
||||
class="group relative flex flex-col p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] hover:border-[var(--primary-color,#6366f1)] hover:bg-[var(--primary-color,#6366f1)]/5 transition-all duration-150 cursor-pointer"
|
||||
@click="openEdit(tpl)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<i class="pi pi-file-edit text-primary" />
|
||||
<span class="shrink-0 w-9 h-9 rounded-md bg-indigo-500/10 grid place-items-center">
|
||||
<i class="pi pi-file-edit text-indigo-500" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
|
||||
<div class="text-sm font-semibold text-[var(--text-color)] line-clamp-1">{{ tpl.nome_template }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
|
||||
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
|
||||
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2 opacity-75">{{ tpl.descricao }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu de acoes -->
|
||||
<!-- Menu de ações -->
|
||||
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
@@ -266,14 +329,9 @@ function getCardMenuItems(tpl) {
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
v-if="!tpl.ativo"
|
||||
class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-500"
|
||||
>
|
||||
inativo
|
||||
</span>
|
||||
<span class="text-[0.6rem] text-[var(--text-color-secondary)]">
|
||||
{{ tpl.variaveis?.length || 0 }} variáveis
|
||||
<span v-if="!tpl.ativo" class="text-[0.6rem] font-semibold px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-500">inativo</span>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-code text-[0.6rem] mr-0.5" />{{ tpl.variaveis?.length || 0 }} variáveis
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,7 +340,7 @@ function getCardMenuItems(tpl) {
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Create / Edit view -->
|
||||
<!-- ══ CREATE / EDIT VIEW ═════════════════════════════════ -->
|
||||
<template v-if="view === 'create' || view === 'edit'">
|
||||
<DocumentTemplateEditor
|
||||
v-model="editingTemplate"
|
||||
@@ -291,7 +349,7 @@ function getCardMenuItems(tpl) {
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
@@ -75,14 +75,32 @@ const mobileMenuItems = computed(() => [
|
||||
// ── Hero sticky ─────────────────────────────────────────────
|
||||
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile dialogs ──────────────────────────────────────────
|
||||
|
||||
const filtersDlgOpen = ref(false)
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.embedded) {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
}
|
||||
await Promise.all([fetchDocuments(), fetchUsedTags()])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
_observer?.disconnect()
|
||||
})
|
||||
|
||||
// ── Acoes ───────────────────────────────────────────────────
|
||||
|
||||
async function onUploaded({ file, meta }) {
|
||||
@@ -158,220 +176,204 @@ watch(filters, () => fetchDocuments(), { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'px-4 py-6 max-w-[1200px] mx-auto'">
|
||||
|
||||
<!-- Hero header -->
|
||||
<div
|
||||
v-if="!embedded"
|
||||
ref="headerEl"
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6"
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">Documentos</h1>
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">
|
||||
{{ resolvedPatientId ? patientName || 'Paciente' : 'Todos os pacientes' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Desktop actions -->
|
||||
<div class="hidden sm:flex items-center gap-2">
|
||||
<Button
|
||||
label="Gerar documento"
|
||||
icon="pi pi-file-pdf"
|
||||
outlined
|
||||
size="small"
|
||||
@click="generateDlg = true"
|
||||
:disabled="!resolvedPatientId"
|
||||
/>
|
||||
<Button
|
||||
label="Upload"
|
||||
icon="pi pi-upload"
|
||||
size="small"
|
||||
@click="uploadDlg = true"
|
||||
:disabled="!resolvedPatientId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div class="sm:hidden">
|
||||
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedded header (dentro do prontuario) -->
|
||||
<div v-else class="flex items-center justify-between gap-2 mb-4">
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
EMBEDDED MODE — dentro do prontuário (sem hero, layout compacto)
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
<div v-if="embedded">
|
||||
<!-- Header compacto -->
|
||||
<div class="flex items-center justify-between gap-2 mb-4">
|
||||
<span class="text-sm font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider">Documentos</span>
|
||||
<div class="flex gap-1.5">
|
||||
<Button
|
||||
icon="pi pi-file-pdf"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
v-tooltip.top="'Gerar documento'"
|
||||
@click="generateDlg = true"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-upload"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
v-tooltip.top="'Upload'"
|
||||
@click="uploadDlg = true"
|
||||
/>
|
||||
<Button icon="pi pi-file-pdf" text rounded size="small" v-tooltip.top="'Gerar documento'" @click="generateDlg = true" />
|
||||
<Button icon="pi pi-upload" text rounded size="small" v-tooltip.top="'Upload'" @click="uploadDlg = true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div v-if="!embedded && documents.length" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<span class="text-lg font-bold">{{ stats.total }}</span>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Total</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<span class="text-lg font-bold">{{ formatSize(stats.tamanhoTotal) }}</span>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tamanho</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
|
||||
<span class="text-lg font-bold">{{ Object.keys(stats.porTipo).length }}</span>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tipos</span>
|
||||
</div>
|
||||
<div v-if="stats.pendentesRevisao" class="flex flex-col items-center p-3 rounded-lg bg-amber-500/5 border border-amber-500/20">
|
||||
<span class="text-lg font-bold text-amber-600">{{ stats.pendentesRevisao }}</span>
|
||||
<span class="text-[0.65rem] text-amber-600 uppercase tracking-wider">Pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="filters.search"
|
||||
placeholder="Buscar..."
|
||||
class="!w-[200px]"
|
||||
size="small"
|
||||
/>
|
||||
</IconField>
|
||||
|
||||
<Select
|
||||
v-model="filters.tipo_documento"
|
||||
:options="TIPOS_DOCUMENTO"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Tipo"
|
||||
showClear
|
||||
class="!w-[160px]"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Select
|
||||
v-if="usedTags.length"
|
||||
v-model="filters.tag"
|
||||
:options="usedTags.map(t => ({ label: t, value: t }))"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Tag"
|
||||
showClear
|
||||
class="!w-[140px]"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="hasActiveFilter"
|
||||
icon="pi pi-filter-slash"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
v-tooltip.top="'Limpar filtros'"
|
||||
@click="clearFilters(); fetchDocuments()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!documents.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-inbox text-4xl opacity-30 mb-3" />
|
||||
<div class="text-sm mb-1">
|
||||
{{ hasActiveFilter ? 'Nenhum documento encontrado com esses filtros.' : 'Nenhum documento ainda.' }}
|
||||
</div>
|
||||
<Button
|
||||
v-if="resolvedPatientId && !hasActiveFilter"
|
||||
label="Enviar primeiro documento"
|
||||
icon="pi pi-upload"
|
||||
text
|
||||
size="small"
|
||||
class="mt-2"
|
||||
@click="uploadDlg = true"
|
||||
/>
|
||||
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
|
||||
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
|
||||
<div class="font-semibold text-sm">Nenhum documento ainda</div>
|
||||
<div class="text-xs opacity-60 mt-1">Faça upload do primeiro documento deste paciente</div>
|
||||
<Button v-if="resolvedPatientId" label="Enviar documento" icon="pi pi-upload" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="uploadDlg = true" />
|
||||
</div>
|
||||
|
||||
<!-- Lista de documentos -->
|
||||
<!-- Lista -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<DocumentCard
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
:doc="doc"
|
||||
@preview="onPreview"
|
||||
@download="onDownload"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@share="onShare"
|
||||
@sign="onSign"
|
||||
/>
|
||||
<DocumentCard v-for="doc in documents" :key="doc.id" :doc="doc" @preview="onPreview" @download="onDownload" @edit="onEdit" @delete="onDelete" @share="onShare" @sign="onSign" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="mt-4 p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-sm text-red-500">
|
||||
<div v-if="error" class="mt-4 p-3 rounded-md bg-red-500/5 border border-red-500/20 text-sm text-red-500">
|
||||
<i class="pi pi-exclamation-circle mr-1" /> {{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<DocumentUploadDialog
|
||||
:visible="uploadDlg"
|
||||
@update:visible="uploadDlg = $event"
|
||||
:patientId="resolvedPatientId"
|
||||
:patientName="patientName"
|
||||
:usedTags="usedTags"
|
||||
@uploaded="onUploaded"
|
||||
/>
|
||||
|
||||
<DocumentPreviewDialog
|
||||
:visible="previewDlg"
|
||||
@update:visible="previewDlg = $event"
|
||||
:doc="selectedDoc"
|
||||
:previewUrl="previewUrl"
|
||||
@download="onDownload"
|
||||
@edit="onEdit"
|
||||
@delete="d => { previewDlg = false; onDelete(d) }"
|
||||
@share="d => { previewDlg = false; onShare(d) }"
|
||||
@sign="d => { previewDlg = false; onSign(d) }"
|
||||
/>
|
||||
|
||||
<DocumentGenerateDialog
|
||||
:visible="generateDlg"
|
||||
@update:visible="generateDlg = $event"
|
||||
:patientId="resolvedPatientId"
|
||||
:patientName="patientName"
|
||||
@generated="onGenerated"
|
||||
/>
|
||||
|
||||
<DocumentSignatureDialog
|
||||
:visible="signatureDlg"
|
||||
@update:visible="signatureDlg = $event"
|
||||
:doc="selectedDoc"
|
||||
/>
|
||||
|
||||
<DocumentShareDialog
|
||||
:visible="shareDlg"
|
||||
@update:visible="shareDlg = $event"
|
||||
:doc="selectedDoc"
|
||||
/>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
PÁGINA FULL — hero sticky + stats + lista em card
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
<template v-else>
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div
|
||||
ref="headerEl"
|
||||
class="sticky my-3 md:mx-4 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/9" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Voltar + Brand -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button v-if="resolvedPatientId" icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar'" @click="router.back()" />
|
||||
<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-file text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Documentos</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)] truncate">
|
||||
{{ resolvedPatientId ? (patientName || 'Paciente') : 'Todos os pacientes' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Atualizar'" @click="fetchDocuments" />
|
||||
<Button label="Gerar" icon="pi pi-file-pdf" severity="secondary" outlined size="small" class="rounded-full" :disabled="!resolvedPatientId" @click="generateDlg = true" />
|
||||
<Button label="Upload" icon="pi pi-upload" size="small" class="rounded-full" :disabled="!resolvedPatientId" @click="uploadDlg = true" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-filter" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Filtros'" @click="filtersDlgOpen = true" />
|
||||
<Button icon="pi pi-upload" class="h-9 w-9 rounded-full" :disabled="!resolvedPatientId" @click="uploadDlg = true" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog filtros mobile -->
|
||||
<Dialog v-model:visible="filtersDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Filtros" class="w-[94vw] max-w-sm">
|
||||
<div class="flex flex-col gap-3 pt-1">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="filters.search" placeholder="Buscar..." class="w-full" />
|
||||
</IconField>
|
||||
<Select v-model="filters.tipo_documento" :options="TIPOS_DOCUMENTO" optionLabel="label" optionValue="value" placeholder="Tipo" showClear class="w-full" />
|
||||
<Select v-if="usedTags.length" v-model="filters.tag" :options="usedTags.map(t => ({ label: t, value: t }))" optionLabel="label" optionValue="value" placeholder="Tag" showClear class="w-full" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="hasActiveFilter" label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined class="rounded-full mr-2" @click="clearFilters(); fetchDocuments()" />
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="filtersDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3 xl:gap-4">
|
||||
<!-- Stats -->
|
||||
<div v-if="documents.length" class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ stats.total }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Total</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ formatSize(stats.tamanhoTotal) }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Tamanho</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ Object.keys(stats.porTipo).length }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Tipos</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border" :class="stats.pendentesRevisao ? 'border-amber-500/30 bg-amber-500/5' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'">
|
||||
<div class="text-[1.35rem] font-bold leading-none" :class="stats.pendentesRevisao ? 'text-amber-600' : 'text-[var(--text-color)]'">{{ stats.pendentesRevisao || 0 }}</div>
|
||||
<div class="text-[1rem] opacity-75 truncate" :class="stats.pendentesRevisao ? 'text-amber-600' : 'text-[var(--text-color-secondary)]'">Pendentes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela (card) -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Header da tabela -->
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-list text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Documentos</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.72rem] font-bold">{{ documents.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Filtros desktop -->
|
||||
<div class="hidden md:flex flex-wrap items-center gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)]/50">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="filters.search" placeholder="Buscar..." class="!w-[200px]" size="small" />
|
||||
</IconField>
|
||||
<Select v-model="filters.tipo_documento" :options="TIPOS_DOCUMENTO" optionLabel="label" optionValue="value" placeholder="Tipo" showClear class="!w-[160px]" size="small" />
|
||||
<Select v-if="usedTags.length" v-model="filters.tag" :options="usedTags.map(t => ({ label: t, value: t }))" optionLabel="label" optionValue="value" placeholder="Tag" showClear class="!w-[140px]" size="small" />
|
||||
<Button v-if="hasActiveFilter" icon="pi pi-filter-slash" severity="danger" text rounded size="small" v-tooltip.top="'Limpar filtros'" @click="clearFilters(); fetchDocuments()" />
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
|
||||
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
|
||||
<div class="font-semibold text-sm">
|
||||
{{ hasActiveFilter ? 'Nenhum documento encontrado' : 'Nenhum documento ainda' }}
|
||||
</div>
|
||||
<div class="text-xs opacity-60 mt-1">
|
||||
{{ hasActiveFilter ? 'Limpe os filtros ou ajuste a busca' : resolvedPatientId ? 'Faça upload do primeiro documento' : 'Selecione um paciente para adicionar documentos' }}
|
||||
</div>
|
||||
<Button v-if="resolvedPatientId && !hasActiveFilter" label="Enviar primeiro documento" icon="pi pi-upload" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="uploadDlg = true" />
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-else class="flex flex-col gap-2 p-3">
|
||||
<DocumentCard v-for="doc in documents" :key="doc.id" :doc="doc" @preview="onPreview" @download="onDownload" @edit="onEdit" @delete="onDelete" @share="onShare" @sign="onSign" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="p-3 rounded-md bg-red-500/5 border border-red-500/20 text-sm text-red-500">
|
||||
<i class="pi pi-exclamation-circle mr-1" /> {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Dialogs comuns (usados em ambos os modos)
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
<DocumentUploadDialog :visible="uploadDlg" @update:visible="uploadDlg = $event" :patientId="resolvedPatientId" :patientName="patientName" :usedTags="usedTags" @uploaded="onUploaded" />
|
||||
|
||||
<DocumentPreviewDialog
|
||||
:visible="previewDlg"
|
||||
@update:visible="previewDlg = $event"
|
||||
:doc="selectedDoc"
|
||||
:previewUrl="previewUrl"
|
||||
@download="onDownload"
|
||||
@edit="onEdit"
|
||||
@delete="d => { previewDlg = false; onDelete(d) }"
|
||||
@share="d => { previewDlg = false; onShare(d) }"
|
||||
@sign="d => { previewDlg = false; onSign(d) }"
|
||||
/>
|
||||
|
||||
<DocumentGenerateDialog :visible="generateDlg" @update:visible="generateDlg = $event" :patientId="resolvedPatientId" :patientName="patientName" @generated="onGenerated" />
|
||||
<DocumentSignatureDialog :visible="signatureDlg" @update:visible="signatureDlg = $event" :doc="selectedDoc" />
|
||||
<DocumentShareDialog :visible="shareDlg" @update:visible="shareDlg = $event" :doc="selectedDoc" />
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
@@ -90,118 +90,126 @@ function onSave() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Header: nome e tipo -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-[1fr_200px] gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Nome do template</label>
|
||||
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
|
||||
<div class="flex flex-col gap-3 xl:gap-4">
|
||||
<!-- ══ Card: Identificação ══════════════════════════════ -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<i class="pi pi-tag text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Identificação</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<Select
|
||||
v-model="form.tipo"
|
||||
:options="TIPOS_TEMPLATE"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Descrição</label>
|
||||
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Editor / Preview -->
|
||||
<div class="flex items-center gap-1 border-b border-[var(--surface-border)]">
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === 'editor' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
@click="activeTab = 'editor'"
|
||||
>
|
||||
Editor
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="activeTab === 'preview' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||||
@click="activeTab = 'preview'"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-show="activeTab === 'editor'" class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Campos HTML -->
|
||||
<div class="flex-1 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Cabeçalho</label>
|
||||
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
<div class="p-4 flex flex-col gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-[1fr_220px] gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Nome do template</label>
|
||||
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<Select v-model="form.tipo" :options="TIPOS_TEMPLATE" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Corpo do documento</label>
|
||||
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Rodapé</label>
|
||||
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Descrição</label>
|
||||
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
|
||||
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Painel de variaveis -->
|
||||
<div class="w-full lg:w-[220px] flex-shrink-0">
|
||||
<div class="sticky top-0">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">
|
||||
Variáveis
|
||||
</div>
|
||||
<div class="text-[0.65rem] text-[var(--text-color-secondary)] mb-3">
|
||||
Clique para inserir no campo ativo
|
||||
<!-- ══ Card: Conteúdo ═══════════════════════════════════ -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-file-edit text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Conteúdo do documento</span>
|
||||
</div>
|
||||
<!-- Tabs: Editor / Preview -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
:label="'Editor'"
|
||||
icon="pi pi-pencil"
|
||||
:severity="activeTab === 'editor' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'editor'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'editor'"
|
||||
/>
|
||||
<Button
|
||||
:label="'Preview'"
|
||||
icon="pi pi-eye"
|
||||
:severity="activeTab === 'preview' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'preview'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'preview'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-show="activeTab === 'editor'" class="p-4 flex flex-col lg:flex-row gap-4">
|
||||
<!-- Campos HTML -->
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Cabeçalho</label>
|
||||
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 max-h-[500px] overflow-y-auto pr-1">
|
||||
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
|
||||
<div class="text-[0.65rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="v in vars"
|
||||
:key="v.key"
|
||||
class="text-left text-xs px-2 py-1 rounded hover:bg-primary/10 hover:text-primary transition-colors truncate"
|
||||
:title="v.key"
|
||||
@click="insertVariable(v.key)"
|
||||
>
|
||||
<span class="font-mono text-[0.65rem] opacity-60">{{</span>
|
||||
{{ v.label }}
|
||||
<span class="font-mono text-[0.65rem] opacity-60">}}</span>
|
||||
</button>
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Corpo do documento</label>
|
||||
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Rodapé</label>
|
||||
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Painel de variáveis -->
|
||||
<div class="w-full lg:w-[240px] shrink-0">
|
||||
<div class="lg:sticky lg:top-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)]/50 overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<i class="pi pi-code text-[var(--text-color-secondary)] opacity-60 text-xs" />
|
||||
<span class="font-semibold text-xs">Variáveis</span>
|
||||
</div>
|
||||
<div class="px-3 pt-2 text-[0.68rem] text-[var(--text-color-secondary)] opacity-75 italic">Clique para inserir no campo ativo</div>
|
||||
<div class="flex flex-col gap-3 p-3 max-h-[500px] overflow-y-auto">
|
||||
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
|
||||
<div class="text-[0.62rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="v in vars"
|
||||
:key="v.key"
|
||||
class="text-left text-xs px-2 py-1 rounded-md bg-transparent border-none hover:bg-[var(--primary-color,#6366f1)]/10 hover:text-[var(--primary-color,#6366f1)] transition-colors truncate cursor-pointer"
|
||||
:title="v.key"
|
||||
@click="insertVariable(v.key)"
|
||||
>
|
||||
<span class="font-mono text-[0.62rem] opacity-60">{{</span>
|
||||
{{ v.label }}
|
||||
<span class="font-mono text-[0.62rem] opacity-60">}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-show="activeTab === 'preview'" class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
|
||||
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
|
||||
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
|
||||
<div class="min-h-[300px]" v-html="renderedPreview" />
|
||||
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
|
||||
<!-- Preview -->
|
||||
<div v-show="activeTab === 'preview'" class="p-4">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-white overflow-hidden">
|
||||
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
|
||||
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
|
||||
<div class="min-h-[300px]" v-html="renderedPreview" />
|
||||
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acoes -->
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button label="Cancelar" text @click="emit('cancel')" />
|
||||
<Button :label="mode === 'create' ? 'Criar template' : 'Salvar'" icon="pi pi-check" @click="onSave" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -200,7 +200,10 @@ onMounted(async () => {
|
||||
<!-- ══════════════════════════════════════
|
||||
Hero
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="relative overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-2.5">
|
||||
<section
|
||||
class="sticky mt-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-5"
|
||||
: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-72 h-72 -top-16 -right-12 rounded-full blur-[70px] bg-indigo-500/10" />
|
||||
|
||||
@@ -37,6 +37,7 @@ import PatientActionMenu from '@/components/patients/PatientActionMenu.vue';
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
import { getSysGroupColor, getSystemGroupDefaultColor } from '@/utils/systemGroupColors.js';
|
||||
@@ -99,6 +100,7 @@ function fmtRecorrencia(r) {
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const conversationDrawerStore = useConversationDrawerStore();
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
@@ -132,7 +134,6 @@ const quickDialog = ref(false);
|
||||
const cadastroFullDialog = ref(false);
|
||||
const editPatientId = ref(null);
|
||||
const dialogSaved = ref(false);
|
||||
const searchMobileDlg = ref(false);
|
||||
const createPopoverRef = ref(null);
|
||||
|
||||
const prontuarioOpen = ref(false);
|
||||
@@ -346,6 +347,11 @@ function goEdit(row) {
|
||||
cadastroFullDialog.value = true;
|
||||
}
|
||||
|
||||
function goConversation(row) {
|
||||
if (!row?.id) return;
|
||||
conversationDrawerStore.openForPatient(String(row.id));
|
||||
}
|
||||
|
||||
// ── Filters & Sort ────────────────────────────────────────
|
||||
let searchTimer = null;
|
||||
function onFilterChangedDebounced() {
|
||||
@@ -739,7 +745,7 @@ function isRecent(row) {
|
||||
═══════════════════════════════════════ -->
|
||||
<div
|
||||
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)' }"
|
||||
>
|
||||
@@ -761,21 +767,8 @@ function isRecent(row) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca (desktop) — só o campo de busca, sem "Mais filtros" e sem "Colunas" -->
|
||||
<div class="hidden xl:flex flex-1 min-w-0 mx-2 items-center gap-2">
|
||||
<div class="flex-1 max-w-xs">
|
||||
<FloatLabel variant="on">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText id="patSearch" v-model="filters.search" class="w-full" variant="filled" @input="onFilterChangedDebounced" />
|
||||
</IconField>
|
||||
<label for="patSearch">Buscar por nome, e-mail ou telefone...</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
|
||||
<Button icon="pi pi-percentage" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.top="'Descontos'" @click="router.push('/configuracoes/descontos')" />
|
||||
<Button label="Novo" icon="pi pi-user-plus" class="rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
|
||||
@@ -785,7 +778,6 @@ function isRecent(row) {
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchMobileDlg = true" />
|
||||
<Button icon="pi pi-user-plus" class="h-9 w-9 rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => patMobileMenuRef.toggle(e)" />
|
||||
<Menu ref="patMobileMenuRef" :model="patMobileMenuItems" :popup="true" />
|
||||
@@ -812,7 +804,7 @@ function isRecent(row) {
|
||||
<i class="pi pi-calendar-clock text-[var(--primary-color,#6366f1)]" />
|
||||
<span class="text-[1.1rem] font-bold leading-none text-[var(--primary-color,#6366f1)]">Agenda</span>
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Ver meus compromissos</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Ver meus compromissos</div>
|
||||
</div>
|
||||
|
||||
<!-- Descontos -->
|
||||
@@ -824,7 +816,7 @@ function isRecent(row) {
|
||||
<i class="pi pi-percentage text-amber-500" />
|
||||
<span class="text-[1.1rem] font-bold leading-none text-amber-500">Descontos</span>
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Ver descontos aplicados</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Ver descontos aplicados</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -842,7 +834,7 @@ function isRecent(row) {
|
||||
@click="setStatus('Todos')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ kpis.total }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Total</div>
|
||||
</div>
|
||||
|
||||
<!-- Ativos -->
|
||||
@@ -852,7 +844,7 @@ function isRecent(row) {
|
||||
@click="setStatus('Ativo')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ kpis.active }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Ativos</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Ativos</div>
|
||||
</div>
|
||||
|
||||
<!-- Inativos -->
|
||||
@@ -862,7 +854,7 @@ function isRecent(row) {
|
||||
@click="setStatus('Inativo')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ kpis.inactive }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Inativos</div>
|
||||
</div>
|
||||
|
||||
<!-- Arquivados -->
|
||||
@@ -872,13 +864,13 @@ function isRecent(row) {
|
||||
@click="setStatus('Arquivado')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-slate-500">{{ kpis.archived }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Arquivados</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Arquivados</div>
|
||||
</div>
|
||||
|
||||
<!-- Último atendimento — não clicável -->
|
||||
<div class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1">
|
||||
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) || '—' }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Último atend.</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Último atend.</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -895,19 +887,6 @@ function isRecent(row) {
|
||||
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearAllFilters" />
|
||||
</div>
|
||||
|
||||
<!-- Dialog busca mobile -->
|
||||
<Dialog v-model:visible="searchMobileDlg" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Buscar paciente" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="filters.search" class="w-full" placeholder="Nome, telefone..." autofocus @input="onFilterChangedDebounced" />
|
||||
</IconField>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchMobileDlg = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- TABS -->
|
||||
<Tabs value="pacientes" class="px-3 md:px-4 mb-5">
|
||||
<TabList>
|
||||
@@ -919,7 +898,7 @@ function isRecent(row) {
|
||||
<TabPanels>
|
||||
<TabPanel value="pacientes">
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div class="flex flex-col md:flex-row md:items-end md:flex-wrap gap-3">
|
||||
<div class="w-full lg:flex-1">
|
||||
<FloatLabel variant="on">
|
||||
@@ -1098,6 +1077,7 @@ function isRecent(row) {
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button v-if="historySet.has(data.id)" :label="`Sessões × ${sessionCountMap.get(data.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
|
||||
<Button icon="pi pi-whatsapp" severity="success" outlined size="small" v-tooltip.top="'Conversar no WhatsApp'" @click="goConversation(data)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
|
||||
<PatientActionMenu :patient="data" :hasHistory="historySet.has(data.id)" @updated="fetchAll" />
|
||||
</div>
|
||||
@@ -1164,6 +1144,7 @@ function isRecent(row) {
|
||||
<div class="mt-3 flex gap-2 justify-end flex-wrap">
|
||||
<Button v-if="historySet.has(pat.id)" :label="`Sessões × ${sessionCountMap.get(pat.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
|
||||
<Button icon="pi pi-whatsapp" severity="success" outlined size="small" v-tooltip.top="'Conversar'" @click="goConversation(pat)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
|
||||
<PatientActionMenu :patient="pat" :hasHistory="historySet.has(pat.id)" @updated="fetchAll" />
|
||||
</div>
|
||||
|
||||
@@ -69,10 +69,16 @@ import { logError } from '@/support/supportLogger'
|
||||
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/utils/validators'
|
||||
import CadastroRapidoConvenio from '@/components/CadastroRapidoConvenio.vue'
|
||||
import CadastroRapidoMedico from '@/components/CadastroRapidoMedico.vue'
|
||||
import ContactPhonesEditor from '@/components/ui/ContactPhonesEditor.vue'
|
||||
import ContactEmailsEditor from '@/components/ui/ContactEmailsEditor.vue'
|
||||
|
||||
// V#9 — composables/repo da feature pacientes (extração da página gigante)
|
||||
import { useCep } from '@/features/patients/composables/useCep'
|
||||
import { usePatientSupportContacts } from '@/features/patients/composables/usePatientSupportContacts'
|
||||
// Fase 2b — LGPD export (Art. 18, II)
|
||||
import { useLgpdExport } from '@/composables/useLgpdExport'
|
||||
// Fase 5b-7 — drawer global de conversa
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore'
|
||||
import {
|
||||
listGroups as repoListGroups,
|
||||
listTags as repoListTags,
|
||||
@@ -148,6 +154,37 @@ const patientId = computed(() =>
|
||||
)
|
||||
const isEdit = computed(() => !!patientId.value)
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// LGPD export (Art. 18, II - portabilidade)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const lgpdDialog = ref(false)
|
||||
const { loading: lgpdExporting, exportJSON: lgpdExportJSON, exportPDF: lgpdExportPDF } = useLgpdExport()
|
||||
|
||||
async function onLgpdExport(format) {
|
||||
if (!isEdit.value || !patientId.value) return
|
||||
const name = form.nome_completo || 'paciente'
|
||||
try {
|
||||
if (format === 'json') {
|
||||
await lgpdExportJSON(patientId.value, name)
|
||||
} else {
|
||||
await lgpdExportPDF(patientId.value, name, '')
|
||||
}
|
||||
toast.add({ severity: 'success', summary: 'Export LGPD gerado', detail: `Arquivo ${format.toUpperCase()} baixado. O evento foi registrado na auditoria.`, life: 5000 })
|
||||
lgpdDialog.value = false
|
||||
} catch (err) {
|
||||
const msg = err?.message || String(err)
|
||||
toast.add({ severity: 'error', summary: 'Falha no export LGPD', detail: msg, life: 6000 })
|
||||
logError('PatientsCadastroPage.onLgpdExport', msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fase 5b-7 — Abre drawer global (não navega)
|
||||
const conversationDrawer = useConversationDrawerStore()
|
||||
function goToConversation() {
|
||||
if (!isEdit.value || !patientId.value) return
|
||||
conversationDrawer.openForPatient(patientId.value)
|
||||
}
|
||||
|
||||
function getAreaKey () {
|
||||
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
|
||||
return seg === 'therapist' ? 'therapist' : 'admin'
|
||||
@@ -424,7 +461,9 @@ const ALLOWED = new Set([
|
||||
'nome_completo','nome_social','pronomes',
|
||||
'data_nascimento','genero','estado_civil','cpf','rg','naturalidade','etnia',
|
||||
'profissao','escolaridade',
|
||||
'telefone','email_principal','email_alternativo','telefone_alternativo',
|
||||
// telefone, telefone_alternativo, email_principal, email_alternativo agora são
|
||||
// gerenciados pelos editors polimórficos (contact_phones / contact_emails);
|
||||
// triggers sincronizam de volta pra essas colunas legadas.
|
||||
'canal_preferido','horario_contato',
|
||||
'cep','pais','cidade','estado','endereco','numero','bairro','complemento',
|
||||
'status','convenio','convenio_id','patient_scope',
|
||||
@@ -876,7 +915,8 @@ function fillRandomPatient () {
|
||||
toast.add({ severity:'info', summary:'Preenchido', detail:'Dados fictícios aplicados.', life:2500 })
|
||||
}
|
||||
|
||||
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit })
|
||||
function openLgpdDialog() { lgpdDialog.value = true }
|
||||
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit, openLgpdDialog, lgpdExporting, goToConversation })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -888,7 +928,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section
|
||||
v-if="!dialogMode"
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 shadow-sm"
|
||||
class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 shadow-sm"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<!-- Blobs decorativos -->
|
||||
@@ -957,6 +997,8 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
@click="fillRandomPatient"
|
||||
/>
|
||||
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Voltar" @click="goBack" />
|
||||
<Button v-if="isEdit" icon="pi pi-whatsapp" severity="success" outlined class="h-9 w-9 rounded-full" title="Conversar no WhatsApp" @click="goToConversation" />
|
||||
<Button v-if="isEdit" icon="pi pi-shield" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Exportar dados do paciente (LGPD)" @click="lgpdDialog = true" />
|
||||
<Button v-if="isEdit" icon="pi pi-trash" severity="danger" outlined class="h-9 w-9 rounded-full" :loading="deleting" @click="confirmDelete" />
|
||||
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" @click="onSubmit" />
|
||||
</div>
|
||||
@@ -1255,42 +1297,32 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
|
||||
<span class="text-[0.6rem]" :class="pal.indigo.hint">Card "Contato" no detalhe</span>
|
||||
</div>
|
||||
<!-- Telefones (polimórfico — tipo/número/principal/vinculado) -->
|
||||
<div class="col-span-full">
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5 flex items-center gap-1.5">
|
||||
<i class="pi pi-phone text-[var(--primary-color)]" />
|
||||
Telefones
|
||||
</div>
|
||||
<ContactPhonesEditor
|
||||
entity-type="patient"
|
||||
:entity-id="patientId || null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Emails (polimórfico — tipo/endereço/principal) -->
|
||||
<div class="col-span-full">
|
||||
<div class="text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5 flex items-center gap-1.5">
|
||||
<i class="pi pi-envelope text-[var(--primary-color)]" />
|
||||
Emails
|
||||
</div>
|
||||
<ContactEmailsEditor
|
||||
entity-type="patient"
|
||||
:entity-id="patientId || null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
|
||||
|
||||
<!-- Telefone -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-phone"/><InputMask id="f_tel" v-model="form.telefone" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_tel">Telefone / celular *</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido como "WhatsApp" clicável no perfil.</div>
|
||||
</div>
|
||||
|
||||
<!-- Telefone alternativo -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-phone"/><InputMask id="f_tel2" v-model="form.telefone_alternativo" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_tel2">Telefone alternativo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- E-mail principal -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-envelope"/><InputText id="f_email" v-model="form.email_principal" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_email">E-mail principal *</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Link mailto: no card Contato.</div>
|
||||
</div>
|
||||
|
||||
<!-- E-mail alternativo -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField><InputIcon class="pi pi-envelope"/><InputText id="f_email2" v-model="form.email_alternativo" class="w-full" variant="filled"/></IconField>
|
||||
<label for="f_email2">E-mail alternativo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Canal preferido -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
@@ -1948,4 +1980,38 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
@selected="onMedicoSelected"
|
||||
@created="onMedicoSelected"
|
||||
/>
|
||||
|
||||
<!-- ══ Dialog: Export LGPD (Art. 18, II) ═════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="lgpdDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!lgpdExporting"
|
||||
:dismissableMask="!lgpdExporting"
|
||||
class="w-[34rem]"
|
||||
:breakpoints="{ '768px': '94vw' }"
|
||||
header="Exportar dados do paciente (LGPD)"
|
||||
>
|
||||
<div class="flex flex-col gap-3 text-sm">
|
||||
<Message severity="info" :closable="false" class="text-xs">
|
||||
Atendendo ao <strong>art. 18, II da LGPD</strong> — direito de portabilidade do titular.
|
||||
O evento será registrado na auditoria da clínica.
|
||||
</Message>
|
||||
|
||||
<p>Serão exportados: cadastro, contatos, histórico de status, eventos de agenda, registros financeiros, documentos (metadados), notificações enviadas e auditoria de alterações.</p>
|
||||
|
||||
<p class="text-xs text-surface-500">Escolha o formato:</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button label="JSON (completo)" icon="pi pi-code" severity="secondary" outlined :loading="lgpdExporting" @click="onLgpdExport('json')" />
|
||||
<Button label="PDF (relatório)" icon="pi pi-file-pdf" :loading="lgpdExporting" @click="onLgpdExport('pdf')" />
|
||||
</div>
|
||||
|
||||
<p class="text-[0.7rem] text-surface-400 mt-2">JSON é o formato canônico portável. PDF é legível e adequado pra entregar ao titular.</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" text :disabled="lgpdExporting" @click="lgpdDialog = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -148,7 +148,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)' }"
|
||||
>
|
||||
|
||||
@@ -486,7 +486,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)' }"
|
||||
>
|
||||
@@ -568,9 +568,9 @@ onBeforeUnmount(() => {
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<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="flex-1 min-w-[72px] rounded-md" />
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
@@ -579,7 +579,7 @@ onBeforeUnmount(() => {
|
||||
@click="toggleStatusFilter('')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ totals.total }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Total</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -588,7 +588,7 @@ onBeforeUnmount(() => {
|
||||
@click="toggleStatusFilter('new')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-sky-500">{{ totals.nNew }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Novos</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Novos</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -597,7 +597,7 @@ onBeforeUnmount(() => {
|
||||
@click="toggleStatusFilter('converted')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ totals.nConv }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Convertidos</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Convertidos</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -606,7 +606,7 @@ onBeforeUnmount(() => {
|
||||
@click="toggleStatusFilter('rejected')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ totals.nRej }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Rejeitados</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Rejeitados</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -421,7 +421,7 @@ function isRecent(row) {
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<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)' }"
|
||||
>
|
||||
@@ -490,9 +490,9 @@ function isRecent(row) {
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<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="flex-1 min-w-[80px] rounded-md" />
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
@@ -513,7 +513,7 @@ function isRecent(row) {
|
||||
>
|
||||
{{ 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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
} from '@/services/Medicos.service.js';
|
||||
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
import ContactPhonesEditor from '@/components/ui/ContactPhonesEditor.vue';
|
||||
import ContactEmailsEditor from '@/components/ui/ContactEmailsEditor.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
@@ -388,7 +390,7 @@ function isRecent(row) {
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<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)' }"
|
||||
>
|
||||
@@ -457,9 +459,9 @@ function isRecent(row) {
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<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="flex-1 min-w-[80px] rounded-md" />
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
@@ -480,7 +482,7 @@ function isRecent(row) {
|
||||
>
|
||||
{{ 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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -772,33 +774,32 @@ function isRecent(row) {
|
||||
<div class="flex-1 h-px bg-teal-200/50" />
|
||||
</div>
|
||||
|
||||
<!-- Telefone profissional -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputMask id="dlg_tel_prof" v-model="dlg.telefone_profissional" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
|
||||
<label for="dlg_tel_prof">Telefone profissional</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Consultório ou clínica.</div>
|
||||
<!-- Telefones (polimórfico) -->
|
||||
<div v-if="dlg.id">
|
||||
<div class="text-[0.63rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-70 mb-1">Telefones</div>
|
||||
<ContactPhonesEditor
|
||||
entity-type="medico"
|
||||
:entity-id="dlg.id"
|
||||
confirm-group="medicos-dlg"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-xs italic text-[var(--text-color-secondary)] py-2 px-3 rounded-md bg-[var(--surface-ground)] border border-dashed border-[var(--surface-border)]">
|
||||
<i class="pi pi-info-circle text-sky-500 mr-1" />
|
||||
Salve o médico primeiro pra adicionar telefones.
|
||||
</div>
|
||||
|
||||
<!-- Telefone pessoal -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<InputMask id="dlg_tel_pes" v-model="dlg.telefone_pessoal" mask="(99) 99999-9999" :unmask="false" class="w-full" variant="filled" placeholder="(00) 00000-0000" :disabled="dlg.saving" />
|
||||
<label for="dlg_tel_pes">Telefone pessoal / WhatsApp</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">Pessoal / WhatsApp.</div>
|
||||
<!-- Emails (polimórfico) -->
|
||||
<div v-if="dlg.id">
|
||||
<div class="text-[0.63rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-70 mb-1">Emails</div>
|
||||
<ContactEmailsEditor
|
||||
entity-type="medico"
|
||||
:entity-id="dlg.id"
|
||||
confirm-group="medicos-dlg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-envelope" />
|
||||
<InputText id="dlg_email" v-model="dlg.email" class="w-full" variant="filled" :disabled="dlg.saving" />
|
||||
</IconField>
|
||||
<label for="dlg_email">E-mail profissional</label>
|
||||
</FloatLabel>
|
||||
<div v-else class="text-xs italic text-[var(--text-color-secondary)] py-2 px-3 rounded-md bg-[var(--surface-ground)] border border-dashed border-[var(--surface-border)]">
|
||||
<i class="pi pi-info-circle text-sky-500 mr-1" />
|
||||
Salve o médico primeiro pra adicionar emails.
|
||||
</div>
|
||||
|
||||
<!-- Divider localização -->
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Histórico de conversas na ficha do paciente (CRM 3.6)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
const props = defineProps({
|
||||
patientId: { type: String, required: true },
|
||||
patientName: { type: String, default: '' }
|
||||
});
|
||||
|
||||
const drawerStore = useConversationDrawerStore();
|
||||
const loading = ref(false);
|
||||
const messages = ref([]);
|
||||
const mediaUrls = ref({});
|
||||
const filter = ref('all'); // all | inbound | outbound
|
||||
|
||||
const CHANNEL_ICON = {
|
||||
whatsapp: 'pi pi-whatsapp',
|
||||
sms: 'pi pi-comment',
|
||||
email: 'pi pi-envelope'
|
||||
};
|
||||
|
||||
async function load() {
|
||||
if (!props.patientId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_messages')
|
||||
.select('id, channel, direction, from_number, to_number, body, media_url, media_mime, provider, kanban_status, received_at, created_at, responded_at, delivery_status')
|
||||
.eq('patient_id', props.patientId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(500);
|
||||
if (error) throw error;
|
||||
messages.value = data || [];
|
||||
// Resolve signed URLs pra mídia do bucket
|
||||
for (const m of messages.value) {
|
||||
if (m.media_url && !/^https?:\/\//i.test(m.media_url)) {
|
||||
const { data: signed } = await supabase.storage.from('whatsapp-media').createSignedUrl(m.media_url, 3600);
|
||||
if (signed?.signedUrl) mediaUrls.value[m.id] = signed.signedUrl;
|
||||
} else if (m.media_url) {
|
||||
mediaUrls.value[m.id] = m.media_url;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PatientConversationsTab] load:', e?.message);
|
||||
messages.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = messages.value.length;
|
||||
const inbound = messages.value.filter((m) => m.direction === 'inbound').length;
|
||||
const outbound = messages.value.filter((m) => m.direction === 'outbound').length;
|
||||
const first = messages.value[0]?.created_at || null;
|
||||
const last = messages.value[messages.value.length - 1]?.created_at || null;
|
||||
const channels = new Set(messages.value.map((m) => m.channel));
|
||||
return { total, inbound, outbound, first, last, channels: [...channels] };
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (filter.value === 'all') return messages.value;
|
||||
return messages.value.filter((m) => m.direction === filter.value);
|
||||
});
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return format(new Date(iso), "dd 'de' MMM HH:mm", { locale: ptBR });
|
||||
} catch { return ''; }
|
||||
}
|
||||
function fmtRelative(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ptBR });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function isImage(mime) { return !!mime && mime.startsWith('image/'); }
|
||||
function isAudio(mime) { return !!mime && mime.startsWith('audio/'); }
|
||||
function isVideo(mime) { return !!mime && mime.startsWith('video/'); }
|
||||
|
||||
function openInDrawer() {
|
||||
drawerStore.openForPatient(props.patientId);
|
||||
}
|
||||
|
||||
onMounted(() => { load(); });
|
||||
watch(() => props.patientId, () => { load(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 p-2">
|
||||
<!-- Header com stats + ação -->
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Mensagens</span>
|
||||
<span class="text-xl font-bold leading-none">{{ stats.total }}</span>
|
||||
</div>
|
||||
<div class="h-8 w-px bg-[var(--surface-border)]" />
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Recebidas</span>
|
||||
<span class="text-base font-bold text-green-600 leading-none">{{ stats.inbound }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Enviadas</span>
|
||||
<span class="text-base font-bold text-sky-600 leading-none">{{ stats.outbound }}</span>
|
||||
</div>
|
||||
<div v-if="stats.first" class="flex flex-col">
|
||||
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Primeira</span>
|
||||
<span class="text-xs leading-tight">{{ fmtTime(stats.first) }}</span>
|
||||
</div>
|
||||
<div v-if="stats.last" class="flex flex-col">
|
||||
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Última</span>
|
||||
<span class="text-xs leading-tight">{{ fmtRelative(stats.last) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<SelectButton
|
||||
v-model="filter"
|
||||
:options="[
|
||||
{ label: 'Todas', value: 'all' },
|
||||
{ label: 'Recebidas', value: 'inbound' },
|
||||
{ label: 'Enviadas', value: 'outbound' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
label="Abrir no CRM"
|
||||
icon="pi pi-external-link"
|
||||
severity="primary"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:disabled="!messages.length"
|
||||
v-tooltip.top="'Abre o drawer de conversa com histórico completo + compose'"
|
||||
@click="openInDrawer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex flex-col gap-2">
|
||||
<Skeleton v-for="n in 5" :key="n" height="4rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!messages.length" class="flex flex-col items-center justify-center gap-2 py-12 text-center text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-comments text-4xl opacity-30" />
|
||||
<div class="text-sm">Nenhuma conversa registrada com este paciente ainda.</div>
|
||||
<div class="text-xs opacity-70">
|
||||
Quando {{ props.patientName || 'o paciente' }} enviar uma mensagem pelo WhatsApp (ou você enviar uma), vai aparecer aqui.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="m in filtered"
|
||||
:key="m.id"
|
||||
class="flex gap-2 items-start p-2 rounded-md border"
|
||||
:class="m.direction === 'inbound'
|
||||
? 'bg-[var(--surface-ground)] border-[var(--surface-border)]'
|
||||
: 'bg-emerald-500/5 border-emerald-500/20'"
|
||||
>
|
||||
<div class="w-7 h-7 rounded-full grid place-items-center shrink-0"
|
||||
:class="m.direction === 'inbound' ? 'bg-sky-500/15 text-sky-500' : 'bg-emerald-500/15 text-emerald-600'">
|
||||
<i :class="m.direction === 'inbound' ? 'pi pi-arrow-down' : 'pi pi-arrow-up'" class="text-[0.7rem]" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 text-[0.68rem] text-[var(--text-color-secondary)] mb-0.5 flex-wrap">
|
||||
<i :class="CHANNEL_ICON[m.channel] || 'pi pi-comment'" class="text-[0.65rem]" />
|
||||
<span class="font-semibold">{{ m.direction === 'inbound' ? 'Paciente' : 'Clínica' }}</span>
|
||||
<span class="opacity-50">·</span>
|
||||
<span>{{ fmtTime(m.created_at) }}</span>
|
||||
<span v-if="m.direction === 'outbound' && m.delivery_status === 'read'" class="text-sky-500" v-tooltip.top="'Lida'">
|
||||
<i class="pi pi-check-double text-[0.6rem]" />
|
||||
</span>
|
||||
<span v-else-if="m.direction === 'outbound' && m.delivery_status === 'delivered'" v-tooltip.top="'Entregue'">
|
||||
<i class="pi pi-check-double text-[0.6rem] opacity-60" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mídia -->
|
||||
<template v-if="m.media_url">
|
||||
<div v-if="!mediaUrls[m.id]" class="text-xs italic text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner mr-1" /> carregando mídia…
|
||||
</div>
|
||||
<template v-else>
|
||||
<img v-if="isImage(m.media_mime)" :src="mediaUrls[m.id]" class="max-w-[200px] rounded-md mt-0.5" />
|
||||
<audio v-else-if="isAudio(m.media_mime)" :src="mediaUrls[m.id]" controls preload="metadata" class="block max-w-full mt-0.5" />
|
||||
<video v-else-if="isVideo(m.media_mime)" :src="mediaUrls[m.id]" controls class="max-w-[260px] rounded-md mt-0.5" />
|
||||
<a v-else :href="mediaUrls[m.id]" target="_blank" rel="noopener" class="text-xs underline text-[var(--primary-color)]">
|
||||
<i class="pi pi-paperclip text-[0.6rem] mr-1" />Baixar anexo
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="m.body" class="text-sm whitespace-pre-wrap break-words">{{ m.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer info -->
|
||||
<div v-if="messages.length && messages.length >= 500" class="text-[0.7rem] text-center italic text-[var(--text-color-secondary)] py-2">
|
||||
Exibindo as últimas 500 mensagens. Abra no CRM pra navegar na conversa completa.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,6 +28,7 @@ import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
|
||||
import PatientConversationsTab from './PatientConversationsTab.vue';
|
||||
|
||||
// ── PROPS / EMITS ──────────────────────────────────────────────
|
||||
const props = defineProps({
|
||||
@@ -68,6 +69,7 @@ const mainTabs = [
|
||||
{ label: 'Agenda', icon: 'pi pi-calendar' },
|
||||
{ label: 'Financeiro', icon: 'pi pi-wallet' },
|
||||
{ label: 'Documentos', icon: 'pi pi-folder' },
|
||||
{ label: 'Conversas', icon: 'pi pi-whatsapp' },
|
||||
];
|
||||
|
||||
// ── ACCORDION DA ABA PERFIL ────────────────────────────────────
|
||||
@@ -1109,6 +1111,12 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
<div v-show="activeTab === 5" class="p-2">
|
||||
<PatientConversationsTab
|
||||
:patient-id="patient?.id"
|
||||
:patient-name="patient?.nome_completo || patient?.nome_social || ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,8 @@ import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards
|
||||
const PATIENTS_SELECT_BASE = `
|
||||
id, tenant_id, owner_id, user_id, responsible_member_id, therapist_member_id,
|
||||
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
||||
cpf, rg, data_nascimento, naturalidade, nacionalidade, genero, estado_civil,
|
||||
profissao, escolaridade, status, status_pagamento,
|
||||
cpf, rg, data_nascimento, naturalidade, genero, estado_civil,
|
||||
profissao, escolaridade, status,
|
||||
cep, endereco, numero, bairro, complemento, cidade, estado, pais,
|
||||
nome_responsavel, telefone_responsavel, cpf_responsavel, observacao_responsavel,
|
||||
cobranca_no_responsavel,
|
||||
@@ -36,7 +36,7 @@ const PATIENTS_SELECT_BASE = `
|
||||
export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
|
||||
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tenantId).is('deleted_at', null);
|
||||
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tenantId);
|
||||
if (ownerId) q = q.eq('owner_id', ownerId);
|
||||
if (!includeInactive) q = q.neq('status', 'Inativo');
|
||||
if (limit) q = q.limit(limit);
|
||||
@@ -55,7 +55,6 @@ export async function getPatientById(id, { tenantId } = {}) {
|
||||
.select(PATIENTS_SELECT_BASE)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.is('deleted_at', null)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
@@ -90,7 +89,7 @@ export async function softDeletePatient(id, { tenantId } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({ deleted_at: new Date().toISOString(), status: 'Arquivado' })
|
||||
.update({ status: 'Arquivado' })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId);
|
||||
if (error) throw error;
|
||||
|
||||
@@ -417,7 +417,7 @@ function isRecent(row) {
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<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)' }"
|
||||
>
|
||||
@@ -486,9 +486,9 @@ function isRecent(row) {
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 px-3 md:px-4 mb-3">
|
||||
<template v-if="carregando">
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
|
||||
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="rounded-md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
@@ -501,7 +501,7 @@ function isRecent(row) {
|
||||
}"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none" :class="s.cls === 'qs-ok' ? 'text-green-500' : 'text-[var(--text-color)]'">{{ 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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,8 @@ import AjudaDrawer from '@/components/AjudaDrawer.vue';
|
||||
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue';
|
||||
import GlobalNoticeBanner from '@/features/notices/GlobalNoticeBanner.vue';
|
||||
import AppThemeBar from './AppThemeBar.vue';
|
||||
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
|
||||
import GlobalInboundNotifier from '@/components/conversations/GlobalInboundNotifier.vue';
|
||||
import { useConfiguratorBar } from './composables/useConfiguratorBar';
|
||||
|
||||
const { open: themeBarOpen } = useConfiguratorBar();
|
||||
@@ -171,6 +173,8 @@ onBeforeUnmount(() => {
|
||||
<!-- ══ Global — fora de todos os branches, persiste em qualquer layout/rota ══ -->
|
||||
<SupportDebugBanner />
|
||||
<GlobalNoticeBanner />
|
||||
<ConversationDrawer />
|
||||
<GlobalInboundNotifier />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -357,9 +357,9 @@ function onSearchFocus() {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 🔎 TOPO FIXO -->
|
||||
<div ref="searchWrapEl" class="px-3 pt-3 pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative">
|
||||
<!-- 🔎 TOPO FIXO (56px, alinhado com topbar) -->
|
||||
<div ref="searchWrapEl" class="sticky top-0 z-20 bg-[var(--surface-card)] border-b border-[var(--surface-border)]">
|
||||
<div class="relative h-14 px-3 flex items-center">
|
||||
<div aria-hidden="true" style="position: absolute; left: -9999px; top: -9999px; width: 1px; height: 1px; overflow: hidden">
|
||||
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
|
||||
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
|
||||
@@ -390,12 +390,12 @@ function onSearchFocus() {
|
||||
<label for="menu_search">Encontrar menu...</label>
|
||||
</FloatLabel>
|
||||
|
||||
<button v-if="query.trim()" type="button" class="absolute right-2 top-1/2 -translate-y-1/2 opacity-70 hover:opacity-100" @mousedown.prevent="clearSearch" aria-label="Limpar busca">
|
||||
<button v-if="query.trim()" type="button" class="absolute right-5 top-1/2 -translate-y-1/2 opacity-70 hover:opacity-100" @mousedown.prevent="clearSearch" aria-label="Limpar busca">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showResults && !query.trim() && recent.length" class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||||
<div v-if="showResults && !query.trim() && recent.length" class="mx-3 mb-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||||
<div class="px-3 py-2 text-xs opacity-70 flex items-center justify-content-between">
|
||||
<span>Recentes</span>
|
||||
<button type="button" class="opacity-70 hover:opacity-100" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
|
||||
@@ -409,7 +409,7 @@ function onSearchFocus() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showResults && results.length" class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||||
<div v-else-if="showResults && results.length" class="mx-3 mb-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||||
<button
|
||||
v-for="(r, i) in results"
|
||||
:key="String(r.to)"
|
||||
@@ -427,7 +427,7 @@ function onSearchFocus() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showResults && query && !results.length" class="mt-2 px-3 py-2 text-sm opacity-70">Nenhum item encontrado.</div>
|
||||
<div v-else-if="showResults && query && !results.length" class="mx-3 mb-2 px-3 py-2 text-sm opacity-70">Nenhum item encontrado.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useAjuda } from '@/composables/useAjuda';
|
||||
const { openDrawer: openAjudaDrawer, closeDrawer: closeAjudaDrawer, drawerOpen: ajudaDrawerOpen } = useAjuda();
|
||||
|
||||
import NotificationDrawer from '@/components/notifications/NotificationDrawer.vue';
|
||||
import GlobalSearch from '@/components/search/GlobalSearch.vue';
|
||||
import { useNotifications } from '@/composables/useNotifications';
|
||||
import { useNotificationStore } from '@/stores/notificationStore';
|
||||
const notificationStore = useNotificationStore();
|
||||
@@ -585,6 +586,11 @@ onMounted(async () => {
|
||||
<Menu ref="ctxMenu" :model="ctxMenuModel" popup appendTo="body" :baseZIndex="3000" />
|
||||
</div>
|
||||
|
||||
<!-- Busca global (Ctrl+K) -->
|
||||
<div class="rail-topbar__search">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="rail-topbar__actions">
|
||||
<!-- Plan Dev Button -->
|
||||
@@ -659,6 +665,29 @@ onMounted(async () => {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Busca global — centralizada entre left e actions */
|
||||
.rail-topbar__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 0 1rem;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.rail-topbar__search {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.rail-topbar__search {
|
||||
padding: 0 0.25rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.rail-topbar__btn {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
|
||||
+325
-136
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
|
||||
@@ -43,128 +43,215 @@ const headerSentinelRef = ref(null);
|
||||
const headerStuck = ref(false);
|
||||
let _observer = null;
|
||||
|
||||
const secoes = [
|
||||
const grupos = [
|
||||
{
|
||||
key: 'agenda',
|
||||
label: 'Agenda',
|
||||
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
|
||||
desc: 'Horários, bloqueios e agendador público para pacientes.',
|
||||
icon: 'pi pi-calendar',
|
||||
to: '/configuracoes/agenda',
|
||||
tags: ['Horários', 'Exceções', 'Duração']
|
||||
items: [
|
||||
{
|
||||
key: 'agenda',
|
||||
label: 'Agenda',
|
||||
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
|
||||
icon: 'pi pi-calendar',
|
||||
to: '/configuracoes/agenda'
|
||||
},
|
||||
{
|
||||
key: 'bloqueios',
|
||||
label: 'Bloqueios',
|
||||
desc: 'Feriados nacionais, municipais e períodos bloqueados.',
|
||||
icon: 'pi pi-ban',
|
||||
to: '/configuracoes/bloqueios'
|
||||
},
|
||||
{
|
||||
key: 'agendador',
|
||||
label: 'Agendador Online',
|
||||
desc: 'Link público para pacientes solicitarem horários.',
|
||||
icon: 'pi pi-calendar-clock',
|
||||
to: '/configuracoes/agendador'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'bloqueios',
|
||||
label: 'Bloqueios',
|
||||
desc: 'Feriados nacionais, municipais e períodos bloqueados.',
|
||||
icon: 'pi pi-ban',
|
||||
to: '/configuracoes/bloqueios',
|
||||
tags: ['Feriados', 'Períodos', 'Recorrentes']
|
||||
},
|
||||
{
|
||||
key: 'agendador',
|
||||
label: 'Agendador Online',
|
||||
desc: 'Link público para pacientes solicitarem horários.',
|
||||
icon: 'pi pi-calendar-clock',
|
||||
to: '/configuracoes/agendador',
|
||||
tags: ['PRO', 'Link', 'Pix', 'LGPD']
|
||||
},
|
||||
{
|
||||
key: 'pagamento',
|
||||
label: 'Pagamento',
|
||||
desc: 'Formas de pagamento: Pix, depósito, dinheiro, cartão, convênio.',
|
||||
key: 'financeiro',
|
||||
label: 'Financeiro',
|
||||
desc: 'Formas de pagamento, valores, descontos e convênios.',
|
||||
icon: 'pi pi-wallet',
|
||||
to: '/configuracoes/pagamento',
|
||||
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
|
||||
},
|
||||
{
|
||||
key: 'precificacao',
|
||||
label: 'Precificação',
|
||||
desc: 'Valor padrão da sessão e preços por tipo de compromisso.',
|
||||
icon: 'pi pi-tag',
|
||||
to: '/configuracoes/precificacao',
|
||||
tags: ['Valores', 'Sessão', 'Compromisso']
|
||||
},
|
||||
{
|
||||
key: 'descontos',
|
||||
label: 'Descontos por Paciente',
|
||||
desc: 'Descontos recorrentes aplicados automaticamente.',
|
||||
icon: 'pi pi-percentage',
|
||||
to: '/configuracoes/descontos',
|
||||
tags: ['Desconto', 'Paciente', 'Automático']
|
||||
},
|
||||
{
|
||||
key: 'excecoes-financeiras',
|
||||
label: 'Exceções Financeiras',
|
||||
desc: 'O que cobrar em faltas, cancelamentos e situações excepcionais.',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
to: '/configuracoes/excecoes-financeiras',
|
||||
tags: ['Falta', 'Cancelamento', 'Cobrança']
|
||||
},
|
||||
{
|
||||
key: 'convenios',
|
||||
label: 'Convênios',
|
||||
desc: 'Cadastre os convênios que você atende e seus valores.',
|
||||
icon: 'pi pi-id-card',
|
||||
to: '/configuracoes/convenios',
|
||||
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
|
||||
},
|
||||
{
|
||||
key: 'empresa',
|
||||
label: 'Minha Empresa',
|
||||
desc: 'CNPJ, endereço, logomarca e redes sociais.',
|
||||
icon: 'pi pi-building',
|
||||
to: '/configuracoes/empresa',
|
||||
tags: ['CNPJ', 'Endereço', 'Logo']
|
||||
},
|
||||
{
|
||||
key: 'email-templates',
|
||||
label: 'Templates de E-mail',
|
||||
desc: 'Personalize os e-mails enviados aos pacientes.',
|
||||
icon: 'pi pi-envelope',
|
||||
to: '/configuracoes/email-templates',
|
||||
tags: ['E-mail', 'Notificações', 'Personalizar']
|
||||
items: [
|
||||
{
|
||||
key: 'pagamento',
|
||||
label: 'Pagamento',
|
||||
desc: 'Formas de pagamento: Pix, depósito, dinheiro, cartão, convênio.',
|
||||
icon: 'pi pi-wallet',
|
||||
to: '/configuracoes/pagamento'
|
||||
},
|
||||
{
|
||||
key: 'precificacao',
|
||||
label: 'Precificação',
|
||||
desc: 'Valor padrão da sessão e preços por tipo de compromisso.',
|
||||
icon: 'pi pi-tag',
|
||||
to: '/configuracoes/precificacao'
|
||||
},
|
||||
{
|
||||
key: 'descontos',
|
||||
label: 'Descontos por Paciente',
|
||||
desc: 'Descontos recorrentes aplicados automaticamente.',
|
||||
icon: 'pi pi-percentage',
|
||||
to: '/configuracoes/descontos'
|
||||
},
|
||||
{
|
||||
key: 'excecoes-financeiras',
|
||||
label: 'Exceções Financeiras',
|
||||
desc: 'O que cobrar em faltas, cancelamentos e situações excepcionais.',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
to: '/configuracoes/excecoes-financeiras'
|
||||
},
|
||||
{
|
||||
key: 'convenios',
|
||||
label: 'Convênios',
|
||||
desc: 'Cadastre os convênios que você atende e seus valores.',
|
||||
icon: 'pi pi-id-card',
|
||||
to: '/configuracoes/convenios'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'whatsapp',
|
||||
label: 'WhatsApp',
|
||||
desc: 'Configure a integração WhatsApp e personalize as mensagens.',
|
||||
label: 'WhatsApp & Conversas',
|
||||
desc: 'Canal, tags, auto-reply, lembretes e créditos de mensagens.',
|
||||
icon: 'pi pi-whatsapp',
|
||||
to: '/configuracoes/whatsapp',
|
||||
tags: ['WhatsApp', 'Mensagens', 'Notificações']
|
||||
items: [
|
||||
{
|
||||
key: 'whatsapp',
|
||||
label: 'Canal WhatsApp',
|
||||
desc: 'Escolha o canal (oficial AgenciaPSI ou pessoal) e configure a integração.',
|
||||
icon: 'pi pi-whatsapp',
|
||||
to: '/configuracoes/whatsapp',
|
||||
aliases: ['/configuracoes/whatsapp-pessoal', '/configuracoes/whatsapp-oficial']
|
||||
},
|
||||
{
|
||||
key: 'whatsapp-templates',
|
||||
label: 'Templates WhatsApp',
|
||||
desc: 'Personalize os textos enviados por WhatsApp ou volte ao padrão da plataforma.',
|
||||
icon: 'pi pi-file-edit',
|
||||
to: '/configuracoes/whatsapp-templates'
|
||||
},
|
||||
{
|
||||
key: 'conversas-tags',
|
||||
label: 'Tags de Conversa',
|
||||
desc: 'Etiquetas custom pra classificar threads no CRM (urgente, remarcação, etc).',
|
||||
icon: 'pi pi-tag',
|
||||
to: '/configuracoes/conversas-tags'
|
||||
},
|
||||
{
|
||||
key: 'conversas-autoreply',
|
||||
label: 'Auto-reply WhatsApp',
|
||||
desc: 'Resposta automática quando paciente escreve fora do horário de atendimento.',
|
||||
icon: 'pi pi-reply',
|
||||
to: '/configuracoes/conversas-autoreply'
|
||||
},
|
||||
{
|
||||
key: 'conversas-optouts',
|
||||
label: 'Opt-outs (LGPD)',
|
||||
desc: 'Números que pediram pra não receber mensagens automáticas. Direito de oposição LGPD.',
|
||||
icon: 'pi pi-ban',
|
||||
to: '/configuracoes/conversas-optouts'
|
||||
},
|
||||
{
|
||||
key: 'lembretes-sessao',
|
||||
label: 'Lembretes de Sessão',
|
||||
desc: 'WhatsApp automático 24h e 2h antes das sessões agendadas.',
|
||||
icon: 'pi pi-bell',
|
||||
to: '/configuracoes/lembretes-sessao'
|
||||
},
|
||||
{
|
||||
key: 'creditos-whatsapp',
|
||||
label: 'Créditos WhatsApp',
|
||||
desc: 'Compre pacotes de mensagens, veja saldo e extrato.',
|
||||
icon: 'pi pi-credit-card',
|
||||
to: '/configuracoes/creditos-whatsapp'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'whatsapp-twilio',
|
||||
label: 'WhatsApp Oficial',
|
||||
desc: 'Número WhatsApp Business exclusivo via Twilio.',
|
||||
icon: 'pi pi-whatsapp',
|
||||
to: '/configuracoes/whatsapp-twilio',
|
||||
tags: ['WhatsApp', 'Twilio', 'Número Exclusivo']
|
||||
key: 'comunicacao',
|
||||
label: 'Comunicação',
|
||||
desc: 'SMS e templates de e-mail enviados aos pacientes.',
|
||||
icon: 'pi pi-send',
|
||||
items: [
|
||||
{
|
||||
key: 'sms',
|
||||
label: 'SMS',
|
||||
desc: 'Gerencie créditos SMS e personalize as mensagens enviadas.',
|
||||
icon: 'pi pi-comment',
|
||||
to: '/configuracoes/sms'
|
||||
},
|
||||
{
|
||||
key: 'email-templates',
|
||||
label: 'Templates de E-mail',
|
||||
desc: 'Personalize os e-mails enviados aos pacientes.',
|
||||
icon: 'pi pi-envelope',
|
||||
to: '/configuracoes/email-templates'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'sms',
|
||||
label: 'SMS',
|
||||
desc: 'Gerencie créditos SMS e personalize as mensagens enviadas.',
|
||||
icon: 'pi pi-comment',
|
||||
to: '/configuracoes/sms',
|
||||
tags: ['SMS', 'Créditos', 'Mensagens']
|
||||
},
|
||||
{
|
||||
key: 'recursos-extras',
|
||||
label: 'Recursos Extras',
|
||||
desc: 'Amplíe as funcionalidades com recursos adicionais e créditos.',
|
||||
icon: 'pi pi-box',
|
||||
to: '/configuracoes/recursos-extras',
|
||||
tags: ['Add-ons', 'Créditos', 'Extra']
|
||||
key: 'plataforma',
|
||||
label: 'Empresa & Plataforma',
|
||||
desc: 'Dados da empresa, recursos extras e registro de auditoria.',
|
||||
icon: 'pi pi-building',
|
||||
items: [
|
||||
{
|
||||
key: 'empresa',
|
||||
label: 'Minha Empresa',
|
||||
desc: 'CNPJ, endereço, logomarca e redes sociais.',
|
||||
icon: 'pi pi-building',
|
||||
to: '/configuracoes/empresa'
|
||||
},
|
||||
{
|
||||
key: 'recursos-extras',
|
||||
label: 'Recursos Extras',
|
||||
desc: 'Amplíe as funcionalidades com recursos adicionais e créditos.',
|
||||
icon: 'pi pi-box',
|
||||
to: '/configuracoes/recursos-extras'
|
||||
},
|
||||
{
|
||||
key: 'auditoria',
|
||||
label: 'Auditoria',
|
||||
desc: 'Registro imutável de operações (LGPD Art. 37).',
|
||||
icon: 'pi pi-shield',
|
||||
to: '/configuracoes/auditoria'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Flatten pra cálculos de item ativo (mesma lógica de antes)
|
||||
const secoesFlat = computed(() => grupos.flatMap((g) => g.items));
|
||||
|
||||
const activeTo = computed(() => {
|
||||
const p = route.path || '';
|
||||
const hit = [...secoes].sort((a, b) => b.to.length - a.to.length).find((s) => p === s.to || p.startsWith(s.to + '/'));
|
||||
const hit = [...secoesFlat.value]
|
||||
.sort((a, b) => b.to.length - a.to.length)
|
||||
.find((s) => p === s.to || p.startsWith(s.to + '/') || (s.aliases || []).includes(p));
|
||||
return hit?.to || '/configuracoes/agenda';
|
||||
});
|
||||
|
||||
const activeSecao = computed(() => secoes.find((s) => s.to === activeTo.value));
|
||||
const activeSecao = computed(() => secoesFlat.value.find((s) => s.to === activeTo.value));
|
||||
|
||||
// Grupo que contém a seção ativa (pra auto-expandir no accordion)
|
||||
const activeGrupoKey = computed(() => grupos.find((g) => g.items.some((i) => i.to === activeTo.value))?.key || grupos[0].key);
|
||||
|
||||
// Accordion multi-open: começa com o grupo ativo aberto
|
||||
const openGroups = ref([activeGrupoKey.value]);
|
||||
|
||||
// Quando a rota mudar e o grupo ativo não estiver aberto, abre ele (sem fechar os outros)
|
||||
watch(activeGrupoKey, (k) => {
|
||||
if (k && !openGroups.value.includes(k)) {
|
||||
openGroups.value = [...openGroups.value, k];
|
||||
}
|
||||
});
|
||||
|
||||
function ir(to) {
|
||||
if (!to) return;
|
||||
@@ -203,8 +290,8 @@ onBeforeUnmount(() => {
|
||||
:class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'"
|
||||
:style="{ left: asideLeft }"
|
||||
>
|
||||
<!-- Cabeçalho da aside -->
|
||||
<div class="flex items-center gap-2 px-4 py-3.5 border-b border-[var(--surface-border)] shrink-0">
|
||||
<!-- Cabeçalho da aside (sticky — igual à sidebar principal) -->
|
||||
<div class="cfg-aside-header sticky top-0 z-10 flex items-center gap-2 px-4 h-[50px] border-b border-[var(--surface-border)] shrink-0 bg-[var(--surface-card)]">
|
||||
<div class="grid place-items-center w-8 h-8 rounded-md bg-indigo-500/[0.12] text-[var(--p-primary-500,#6366f1)] shrink-0">
|
||||
<i class="pi pi-cog text-sm" />
|
||||
</div>
|
||||
@@ -217,38 +304,64 @@ onBeforeUnmount(() => {
|
||||
<span>Seções</span>
|
||||
</div>
|
||||
|
||||
<!-- Itens de navegação -->
|
||||
<TransitionGroup name="menu" tag="div" class="flex flex-col gap-0 px-2 pb-3">
|
||||
<button
|
||||
v-for="(s, i) in showMenu ? secoes : []"
|
||||
:key="s.key"
|
||||
:style="{ transitionDelay: `${i * 40}ms` }"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer w-full text-left transition-colors duration-[120ms] hover:bg-[var(--surface-hover)]"
|
||||
:class="activeTo === s.to ? 'cfg-nav-item--active' : ''"
|
||||
@click="ir(s.to)"
|
||||
>
|
||||
<i
|
||||
:class="[s.icon, activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-100' : 'text-[var(--text-color-secondary)] opacity-60']"
|
||||
class="text-[0.85rem] shrink-0 w-4 text-center"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[0.88rem] font-semibold truncate" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'">{{ s.label }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] opacity-70 truncate">{{ s.desc }}</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] shrink-0" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-60' : 'text-[var(--text-color-secondary)] opacity-30'" />
|
||||
</button>
|
||||
</TransitionGroup>
|
||||
<!-- Accordion de grupos -->
|
||||
<div v-if="showMenu" class="cfg-menu-accordion px-2 pb-3">
|
||||
<Accordion v-model:value="openGroups" multiple>
|
||||
<AccordionPanel v-for="g in grupos" :key="g.key" :value="g.key">
|
||||
<AccordionHeader class="cfg-group-header">
|
||||
<div class="flex items-center gap-2.5 w-full min-w-0">
|
||||
<div class="cfg-group-icon grid place-items-center w-9 h-9 rounded-md shrink-0 bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] text-[var(--primary-color,#6366f1)]">
|
||||
<i :class="g.icon" class="text-[0.95rem]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<span class="text-[0.9rem] font-bold leading-5 text-[var(--text-color)] truncate">{{ g.label }}</span>
|
||||
<span class="text-[0.8rem] leading-[1.15rem] text-[var(--text-color-secondary)] opacity-75 whitespace-normal break-words">{{ g.desc }}</span>
|
||||
</div>
|
||||
<Badge :value="g.items.length" severity="contrast" class="cfg-group-badge shrink-0" />
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="cfg-nav-list flex flex-col gap-2 py-1">
|
||||
<button
|
||||
v-for="s in g.items"
|
||||
:key="s.key"
|
||||
class="cfg-nav-item flex items-center gap-3 p-3 cursor-pointer w-full text-left rounded-[6px] border bg-[var(--surface-card)] transition-colors duration-[120ms]"
|
||||
:class="activeTo === s.to ? 'cfg-nav-item--active' : 'border-[var(--surface-border)]'"
|
||||
@click="ir(s.to)"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0 transition-colors duration-[120ms]"
|
||||
:class="activeTo === s.to
|
||||
? 'bg-[color-mix(in_srgb,var(--primary-color)_15%,transparent)] text-[var(--primary-color,#6366f1)]'
|
||||
: 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'"
|
||||
>
|
||||
<i :class="s.icon" class="text-lg" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<span
|
||||
class="font-semibold leading-5 truncate"
|
||||
:class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'"
|
||||
>{{ s.label }}</span>
|
||||
<span class="text-sm leading-[1.15rem] text-[var(--text-color-secondary)] whitespace-normal break-words">{{ s.desc }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Área principal -->
|
||||
<div class="flex-1 min-w-0 xl:pl-[272px]">
|
||||
<div class="flex-1 min-w-0 xl:pl-[320px]">
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- Hero compacto -->
|
||||
<div
|
||||
ref="headerEl"
|
||||
class="sticky top-[var(--layout-sticky-top,56px)] z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 mx-3 md:mx-4 mb-3"
|
||||
class="sticky top-[var(--layout-sticky-top,56px)] z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 mx-3 md:mx-4 my-3"
|
||||
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
||||
>
|
||||
<!-- Blobs decorativos -->
|
||||
@@ -285,6 +398,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-2 ml-auto shrink-0">
|
||||
<!-- Slot de ações específicas da sub-página (via Teleport) -->
|
||||
<div id="cfg-page-actions" class="flex items-center gap-1.5"></div>
|
||||
|
||||
<!-- Toggle aside — mobile/tablet apenas -->
|
||||
<button
|
||||
class="xl:hidden inline-flex items-center gap-1.5 h-9 px-3 rounded-full border border-[var(--surface-border)] bg-transparent text-xs font-semibold text-[var(--text-color)] cursor-pointer hover:bg-[var(--surface-hover)] transition-colors duration-150"
|
||||
@@ -313,7 +429,7 @@ onBeforeUnmount(() => {
|
||||
top: calc(56px + var(--notice-banner-height, 0px));
|
||||
left: 0;
|
||||
height: calc(100dvh - 56px - var(--notice-banner-height, 0px));
|
||||
width: min(272px, 85vw);
|
||||
width: min(320px, 90vw);
|
||||
z-index: 40;
|
||||
overflow-y: auto;
|
||||
transition:
|
||||
@@ -326,7 +442,7 @@ onBeforeUnmount(() => {
|
||||
position: fixed;
|
||||
top: calc(56px + var(--notice-banner-height, 0px));
|
||||
height: calc(100vh - 56px - var(--notice-banner-height, 0px));
|
||||
width: 272px;
|
||||
width: 320px;
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
box-shadow: none;
|
||||
@@ -335,18 +451,91 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.cfg-nav-item--active {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
|
||||
/* ── Lista de subitens (cards) ──────────────────────────────── */
|
||||
.cfg-nav-list {
|
||||
margin: 0.15rem 0.25rem 0.35rem 1.5rem;
|
||||
}
|
||||
|
||||
/* TransitionGroup menu */
|
||||
.menu-enter-active {
|
||||
/* ── Hover e estado ativo dos cards ─────────────────────────── */
|
||||
.cfg-nav-item {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
.menu-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
.cfg-nav-item:hover {
|
||||
background: var(--surface-hover) !important;
|
||||
border-color: color-mix(in srgb, var(--primary-color) 30%, var(--surface-border)) !important;
|
||||
}
|
||||
.cfg-nav-item--active {
|
||||
background: color-mix(in srgb, var(--primary-color) 6%, var(--surface-card));
|
||||
border-color: var(--primary-color, #6366f1) !important;
|
||||
}
|
||||
|
||||
/* ── Accordion do menu — compacto, sem bordas grossas ───────────── */
|
||||
.cfg-menu-accordion :deep(.p-accordion) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.cfg-menu-accordion :deep(.p-accordionpanel) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.cfg-menu-accordion :deep(.p-accordionheader) {
|
||||
padding: 0.5rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
transition: background-color 120ms ease;
|
||||
min-height: 52px;
|
||||
}
|
||||
.cfg-menu-accordion :deep(.p-accordionheader:hover) {
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card)) !important;
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
.cfg-menu-accordion :deep(.p-accordionheader:focus-visible) {
|
||||
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
}
|
||||
.cfg-menu-accordion :deep(.p-accordionheader-toggle-icon) {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.55;
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.25rem;
|
||||
transition: color 150ms ease, opacity 150ms ease, transform 150ms ease;
|
||||
}
|
||||
.cfg-menu-accordion :deep(.p-accordionheader:hover) .p-accordionheader-toggle-icon {
|
||||
color: var(--primary-color, #6366f1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Ícone do grupo ganha anel primary no hover (igual aos subitens) */
|
||||
.cfg-group-icon {
|
||||
transition: box-shadow 150ms ease, background-color 150ms ease;
|
||||
}
|
||||
.cfg-menu-accordion :deep(.p-accordionheader:hover) .cfg-group-icon {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||||
background: color-mix(in srgb, var(--primary-color) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Badge circular do contador de itens (igual ao p-badge-circle do pasteds.txt) */
|
||||
.cfg-group-badge :deep(.p-badge),
|
||||
.cfg-group-badge.p-badge {
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
border-radius: 9999px;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.cfg-menu-accordion :deep(.p-accordioncontent) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.cfg-menu-accordion :deep(.p-accordioncontent-content) {
|
||||
padding: 0.15rem 0.2rem 0.35rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/layout/configuracoes/AddonsExtratoPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useAddonExtrato } from '@/composables/useAddonExtrato';
|
||||
import { downloadExtratoCSV, downloadExtratoPDF, formatters } from '@/utils/addonExtratoExport';
|
||||
import { downloadExcel } from '@/utils/excelExport';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const { rows, balances, filters, dateRange, loading, summary, load, loadBalances } = useAddonExtrato();
|
||||
|
||||
const tenantName = ref('');
|
||||
const exportingPdf = ref(false);
|
||||
|
||||
// ── opções dos filtros ──────────────────────────────────────────────────
|
||||
|
||||
const periodOptions = [
|
||||
{ label: 'Este mês', value: 'thisMonth' },
|
||||
{ label: 'Mês anterior', value: 'lastMonth' },
|
||||
{ label: 'Últimos 90 dias', value: 'last90' },
|
||||
{ label: 'Personalizado', value: 'custom' }
|
||||
];
|
||||
|
||||
const addonTypeOptions = [
|
||||
{ label: 'SMS', value: 'sms' },
|
||||
{ label: 'E-mail', value: 'email' },
|
||||
{ label: 'Servidor', value: 'server' },
|
||||
{ label: 'Domínio', value: 'domain' }
|
||||
];
|
||||
|
||||
const movementOptions = [
|
||||
{ label: 'Compra', value: 'purchase' },
|
||||
{ label: 'Consumo', value: 'consumption' },
|
||||
{ label: 'Ajuste', value: 'adjustment' },
|
||||
{ label: 'Reembolso', value: 'refund' },
|
||||
{ label: 'Bônus', value: 'bonus' },
|
||||
{ label: 'Expiração', value: 'expiration' }
|
||||
];
|
||||
|
||||
// ── labels ──────────────────────────────────────────────────────────────
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
const preset = periodOptions.find((p) => p.value === filters.value.periodPreset)?.label ?? '';
|
||||
const { from, to } = dateRange.value;
|
||||
const fmt = (d) => new Date(d).toLocaleDateString('pt-BR');
|
||||
return `${preset} (${fmt(from)} → ${fmt(to)})`;
|
||||
});
|
||||
|
||||
function movementSeverity(type) {
|
||||
const map = { purchase: 'success', consumption: 'warning', adjustment: 'info', refund: 'secondary', bonus: 'success', expiration: 'danger' };
|
||||
return map[type] || 'secondary';
|
||||
}
|
||||
|
||||
function amountClass(r) {
|
||||
if (r.type === 'consumption' || r.type === 'expiration') return 'text-red-500 font-semibold';
|
||||
if (r.type === 'purchase' || r.type === 'bonus' || r.type === 'refund') return 'text-emerald-500 font-semibold';
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── ações ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function reload() {
|
||||
await Promise.all([load(), loadBalances()]);
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
// recarrega apenas quando muda filtro server-side (período/addon/movimento)
|
||||
reload();
|
||||
}
|
||||
|
||||
function slugify(s) {
|
||||
if (!s) return 'clinica';
|
||||
return String(s)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.slice(0, 40) || 'clinica';
|
||||
}
|
||||
|
||||
function filenameBase() {
|
||||
const now = new Date();
|
||||
const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
return `extrato-addons-${slugify(tenantName.value)}-${ym}`;
|
||||
}
|
||||
|
||||
function onExportCSV() {
|
||||
try {
|
||||
downloadExtratoCSV(rows.value, summary.value, periodLabel.value, `${filenameBase()}.csv`);
|
||||
toast.add({ severity: 'success', summary: 'CSV gerado', detail: `${summary.value.totalRows} registro(s) exportado(s).`, life: 3000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Falha ao gerar CSV', detail: err?.message || 'Erro inesperado', life: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function onExportPDF() {
|
||||
if (exportingPdf.value) return;
|
||||
exportingPdf.value = true;
|
||||
try {
|
||||
await downloadExtratoPDF(rows.value, summary.value, periodLabel.value, tenantName.value, `${filenameBase()}.pdf`);
|
||||
toast.add({ severity: 'success', summary: 'PDF gerado', detail: `${summary.value.totalRows} registro(s) exportado(s).`, life: 3000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Falha ao gerar PDF', detail: err?.message || 'Erro inesperado', life: 5000 });
|
||||
} finally {
|
||||
exportingPdf.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onExportExcel() {
|
||||
try {
|
||||
await downloadExcel({
|
||||
filename: `${filenameBase()}.xlsx`,
|
||||
sheetName: 'Extrato de addons',
|
||||
headers: [
|
||||
{ key: 'created_at', label: 'Data/Hora', type: 'date', width: 20 },
|
||||
{ key: 'addon_label', label: 'Recurso', type: 'text', width: 14 },
|
||||
{ key: 'movement_label', label: 'Tipo', type: 'text', width: 14 },
|
||||
{ key: 'amount', label: 'Quantidade', type: 'number', width: 14 },
|
||||
{ key: 'balance_after', label: 'Saldo após', type: 'number', width: 14 },
|
||||
{ key: 'description', label: 'Descrição', type: 'text', width: 40 },
|
||||
{ key: 'price_cents', label: 'Valor', type: 'money', width: 14 },
|
||||
{ key: 'payment_method', label: 'Método', type: 'text', width: 14 },
|
||||
{ key: 'payment_reference', label: 'Referência', type: 'text', width: 28 }
|
||||
],
|
||||
rows: rows.value.map((r) => ({
|
||||
...r,
|
||||
addon_label: formatters.addonLabel(r.addon_type),
|
||||
movement_label: formatters.movementLabel(r.type)
|
||||
})),
|
||||
footerRows: [
|
||||
['Período', periodLabel.value],
|
||||
['Total de registros', summary.value.totalRows],
|
||||
['Total comprado (créditos)', summary.value.purchasedCredits],
|
||||
['Total comprado (R$)', summary.value.purchasedCents / 100],
|
||||
['Total consumido (créditos)', summary.value.consumedCredits]
|
||||
]
|
||||
});
|
||||
toast.add({ severity: 'success', summary: 'Excel gerado', detail: `${summary.value.totalRows} registro(s) exportado(s).`, life: 3000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Falha ao gerar Excel', detail: err?.message || 'Erro inesperado', life: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTenantName() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
const { data } = await supabase.from('tenants').select('name').eq('id', tenantId).maybeSingle();
|
||||
tenantName.value = data?.name || '';
|
||||
}
|
||||
|
||||
// ── init ────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTenantName();
|
||||
await reload();
|
||||
});
|
||||
|
||||
// recarrega quando muda tenant ativo
|
||||
watch(
|
||||
() => tenantStore.activeTenantId,
|
||||
async () => {
|
||||
await loadTenantName();
|
||||
await reload();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Header -->
|
||||
<Card>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button icon="pi pi-arrow-left" text rounded severity="secondary" class="!w-8 !h-8" @click="router.push({ name: 'ConfiguracoesRecursosExtras' })" v-tooltip.bottom="'Voltar'" />
|
||||
<i class="pi pi-list text-xl" />
|
||||
Extrato de Recursos Extras
|
||||
</div>
|
||||
</template>
|
||||
<template #subtitle> Histórico de compras, consumos e ajustes. Use para disputar cobranças ou planejar renovações. </template>
|
||||
</Card>
|
||||
|
||||
<!-- Filtros -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3">
|
||||
<div class="md:col-span-3">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Período</label>
|
||||
<Select v-model="filters.periodPreset" :options="periodOptions" optionLabel="label" optionValue="value" class="w-full" @change="onFilterChange" />
|
||||
</div>
|
||||
|
||||
<div v-if="filters.periodPreset === 'custom'" class="md:col-span-4">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Intervalo personalizado</label>
|
||||
<DatePicker v-model="filters.customRange" selectionMode="range" dateFormat="dd/mm/yy" :manualInput="false" showIcon class="w-full" @update:model-value="onFilterChange" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Tipo de recurso</label>
|
||||
<MultiSelect v-model="filters.addonTypes" :options="addonTypeOptions" optionLabel="label" optionValue="value" placeholder="Todos" class="w-full" display="chip" :maxSelectedLabels="2" @change="onFilterChange" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Tipo de movimento</label>
|
||||
<MultiSelect v-model="filters.movementTypes" :options="movementOptions" optionLabel="label" optionValue="value" placeholder="Todos" class="w-full" display="chip" :maxSelectedLabels="2" @change="onFilterChange" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-6">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Buscar por referência / descrição / método</label>
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="filters.search" placeholder="ex: ID de pagamento, descrição..." class="w-full" maxlength="120" />
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-6 flex items-end gap-2">
|
||||
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined @click="reload" :loading="loading" />
|
||||
<Button label="Exportar CSV" icon="pi pi-file" severity="secondary" @click="onExportCSV" :disabled="!rows.length" />
|
||||
<Button label="Exportar Excel" icon="pi pi-file-excel" severity="secondary" @click="onExportExcel" :disabled="!rows.length" />
|
||||
<Button label="Exportar PDF" icon="pi pi-file-pdf" severity="secondary" @click="onExportPDF" :disabled="!rows.length" :loading="exportingPdf" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Resumo -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-surface-500 uppercase tracking-wide">Registros no período</span>
|
||||
<span class="text-2xl font-bold mt-1">{{ summary.totalRows }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-surface-500 uppercase tracking-wide">Comprado (créditos)</span>
|
||||
<span class="text-2xl font-bold mt-1 text-emerald-500">+{{ summary.purchasedCredits }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-surface-500 uppercase tracking-wide">Comprado (R$)</span>
|
||||
<span class="text-2xl font-bold mt-1">{{ formatters.fmtBRL(summary.purchasedCents) || 'R$ 0,00' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-surface-500 uppercase tracking-wide">Consumido (créditos)</span>
|
||||
<span class="text-2xl font-bold mt-1 text-red-500">-{{ summary.consumedCredits }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<DataTable
|
||||
:value="rows"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
stripedRows
|
||||
paginator
|
||||
:rows="20"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
sortField="created_at"
|
||||
:sortOrder="-1"
|
||||
emptyMessage="Nenhuma transação encontrada no período. Ajuste os filtros ou selecione um intervalo mais amplo."
|
||||
>
|
||||
<Column field="created_at" header="Data" style="min-width: 150px" sortable>
|
||||
<template #body="{ data }">{{ formatters.fmtDate(data.created_at) }}</template>
|
||||
</Column>
|
||||
|
||||
<Column field="addon_type" header="Recurso" style="width: 110px" sortable>
|
||||
<template #body="{ data }">
|
||||
<Tag :value="formatters.addonLabel(data.addon_type)" severity="info" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="type" header="Tipo" style="width: 130px" sortable>
|
||||
<template #body="{ data }">
|
||||
<Tag :value="formatters.movementLabel(data.type)" :severity="movementSeverity(data.type)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="amount" header="Qtd." style="width: 90px" sortable>
|
||||
<template #body="{ data }">
|
||||
<span :class="amountClass(data)">{{ formatters.fmtAmount(data) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="balance_after" header="Saldo após" style="width: 100px">
|
||||
<template #body="{ data }">{{ data.balance_after }}</template>
|
||||
</Column>
|
||||
|
||||
<Column field="description" header="Descrição">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm">{{ data.description || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="price_cents" header="Valor" style="width: 110px">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data.price_cents">{{ formatters.fmtBRL(data.price_cents) }}</span>
|
||||
<span v-else class="text-surface-400">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="payment_reference" header="Referência" style="max-width: 180px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs text-surface-500 font-mono">{{ data.payment_reference || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,434 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/layout/configuracoes/AuditoriaPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useAuditoria } from '@/composables/useAuditoria';
|
||||
import { downloadAuditCSV, downloadAuditPDF, auditFormatters, sourceLabel, entityLabel } from '@/utils/auditoriaExport';
|
||||
import { downloadExcel } from '@/utils/excelExport';
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const { rows, filters, dateRange, loading, summary, userDisplay, load } = useAuditoria();
|
||||
|
||||
const tenantName = ref('');
|
||||
const exportingPdf = ref(false);
|
||||
|
||||
// detalhes do evento clicado
|
||||
const detailDialog = ref(false);
|
||||
const selectedEvent = ref(null);
|
||||
|
||||
// ── opções dos filtros ──────────────────────────────────────────────────
|
||||
|
||||
const periodOptions = [
|
||||
{ label: 'Hoje', value: 'today' },
|
||||
{ label: 'Últimos 7 dias', value: 'last7' },
|
||||
{ label: 'Últimos 30 dias', value: 'last30' },
|
||||
{ label: 'Últimos 90 dias', value: 'last90' },
|
||||
{ label: 'Personalizado', value: 'custom' }
|
||||
];
|
||||
|
||||
const sourceOptions = [
|
||||
{ label: 'Auditoria (CRUD)', value: 'audit_logs' },
|
||||
{ label: 'Acesso a documentos', value: 'document_access_logs' },
|
||||
{ label: 'Status de paciente', value: 'patient_status_history' },
|
||||
{ label: 'Notificações', value: 'notification_logs' },
|
||||
{ label: 'Recursos extras', value: 'addon_transactions' }
|
||||
];
|
||||
|
||||
const entityOptions = [
|
||||
{ label: 'Paciente', value: 'patients' },
|
||||
{ label: 'Evento de agenda', value: 'agenda_eventos' },
|
||||
{ label: 'Registro financeiro', value: 'financial_records' },
|
||||
{ label: 'Documento', value: 'documents' },
|
||||
{ label: 'Membro do tenant', value: 'tenant_members' },
|
||||
{ label: 'Documento (acesso)', value: 'document' },
|
||||
{ label: 'Notificação', value: 'notification' },
|
||||
{ label: 'Status de paciente', value: 'patient_status' },
|
||||
{ label: 'Transação de recurso', value: 'addon_transaction' }
|
||||
];
|
||||
|
||||
const actionOptions = [
|
||||
{ label: 'Criou', value: 'insert' },
|
||||
{ label: 'Alterou', value: 'update' },
|
||||
{ label: 'Excluiu', value: 'delete' },
|
||||
{ label: 'Visualizou', value: 'visualizou' },
|
||||
{ label: 'Baixou', value: 'baixou' },
|
||||
{ label: 'Imprimiu', value: 'imprimiu' },
|
||||
{ label: 'Compartilhou', value: 'compartilhou' },
|
||||
{ label: 'Assinou', value: 'assinou' },
|
||||
{ label: 'Status alterado', value: 'status_change' },
|
||||
{ label: 'Compra', value: 'purchase' },
|
||||
{ label: 'Consumo', value: 'consumption' }
|
||||
];
|
||||
|
||||
// ── labels ─────────────────────────────────────────────────────────────
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
const preset = periodOptions.find((p) => p.value === filters.value.periodPreset)?.label ?? '';
|
||||
const { from, to } = dateRange.value;
|
||||
const fmt = (d) => new Date(d).toLocaleDateString('pt-BR');
|
||||
return `${preset} (${fmt(from)} → ${fmt(to)})`;
|
||||
});
|
||||
|
||||
function sourceSeverity(src) {
|
||||
const map = {
|
||||
audit_logs: 'info',
|
||||
document_access_logs: 'secondary',
|
||||
patient_status_history: 'warning',
|
||||
notification_logs: 'success',
|
||||
addon_transactions: 'help'
|
||||
};
|
||||
return map[src] || 'secondary';
|
||||
}
|
||||
|
||||
function actionSeverity(action) {
|
||||
if (['delete', 'failed', 'bounced'].includes(action)) return 'danger';
|
||||
if (['insert', 'sent', 'delivered', 'purchase'].includes(action)) return 'success';
|
||||
if (['update', 'status_change'].includes(action)) return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
// ── ações ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function reload() {
|
||||
await load();
|
||||
}
|
||||
|
||||
function openDetails(ev) {
|
||||
selectedEvent.value = ev;
|
||||
detailDialog.value = true;
|
||||
}
|
||||
|
||||
function prettyJson(obj) {
|
||||
if (!obj) return '';
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return String(obj);
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(s) {
|
||||
if (!s) return 'clinica';
|
||||
return (
|
||||
String(s)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.slice(0, 40) || 'clinica'
|
||||
);
|
||||
}
|
||||
|
||||
function filenameBase() {
|
||||
const now = new Date();
|
||||
const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
return `auditoria-${slugify(tenantName.value)}-${ym}`;
|
||||
}
|
||||
|
||||
function onExportCSV() {
|
||||
try {
|
||||
downloadAuditCSV(rows.value, summary.value, periodLabel.value, userDisplay, `${filenameBase()}.csv`);
|
||||
toast.add({ severity: 'success', summary: 'CSV gerado', detail: `${summary.value.totalRows} evento(s) exportado(s).`, life: 3000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Falha ao gerar CSV', detail: err?.message || 'Erro inesperado', life: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function onExportPDF() {
|
||||
if (exportingPdf.value) return;
|
||||
exportingPdf.value = true;
|
||||
try {
|
||||
await downloadAuditPDF(rows.value, summary.value, periodLabel.value, tenantName.value, userDisplay, `${filenameBase()}.pdf`);
|
||||
toast.add({ severity: 'success', summary: 'PDF gerado', detail: `${summary.value.totalRows} evento(s) exportado(s).`, life: 3000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Falha ao gerar PDF', detail: err?.message || 'Erro inesperado', life: 5000 });
|
||||
} finally {
|
||||
exportingPdf.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onExportExcel() {
|
||||
try {
|
||||
await downloadExcel({
|
||||
filename: `${filenameBase()}.xlsx`,
|
||||
sheetName: 'Auditoria',
|
||||
headers: [
|
||||
{ key: 'occurred_at', label: 'Data/Hora', type: 'date', width: 20 },
|
||||
{ key: 'source_label', label: 'Origem', type: 'text', width: 18 },
|
||||
{ key: 'entity_label', label: 'Entidade', type: 'text', width: 18 },
|
||||
{ key: 'entity_id', label: 'ID entidade', type: 'text', width: 38 },
|
||||
{ key: 'action', label: 'Ação', type: 'text', width: 14 },
|
||||
{ key: 'description', label: 'Descrição', type: 'text', width: 60 },
|
||||
{ key: 'user_display', label: 'Usuário', type: 'text', width: 24 }
|
||||
],
|
||||
rows: rows.value.map((r) => ({
|
||||
...r,
|
||||
source_label: sourceLabel(r.source),
|
||||
entity_label: entityLabel(r.entity_type),
|
||||
user_display: userDisplay(r.user_id)
|
||||
})),
|
||||
footerRows: [
|
||||
['Período', periodLabel.value],
|
||||
['Total de registros', summary.value.totalRows],
|
||||
['Usuários distintos', summary.value.distinctUsers]
|
||||
]
|
||||
});
|
||||
toast.add({ severity: 'success', summary: 'Excel gerado', detail: `${summary.value.totalRows} evento(s) exportado(s).`, life: 3000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Falha ao gerar Excel', detail: err?.message || 'Erro inesperado', life: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTenantName() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
const { data } = await supabase.from('tenants').select('name').eq('id', tenantId).maybeSingle();
|
||||
tenantName.value = data?.name || '';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTenantName();
|
||||
await reload();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => tenantStore.activeTenantId,
|
||||
async () => {
|
||||
await loadTenantName();
|
||||
await reload();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Header -->
|
||||
<Card>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-shield text-xl" />
|
||||
Auditoria da Clínica
|
||||
</div>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
Registro imutável de operações de tratamento. Exigido pela <strong>LGPD Art. 37</strong>. Use para responder solicitações do titular ou investigar inconsistências.
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Filtros -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3">
|
||||
<div class="md:col-span-3">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Período</label>
|
||||
<Select v-model="filters.periodPreset" :options="periodOptions" optionLabel="label" optionValue="value" class="w-full" @change="reload" />
|
||||
</div>
|
||||
|
||||
<div v-if="filters.periodPreset === 'custom'" class="md:col-span-4">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Intervalo personalizado</label>
|
||||
<DatePicker v-model="filters.customRange" selectionMode="range" dateFormat="dd/mm/yy" :manualInput="false" showIcon class="w-full" @update:model-value="reload" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Origem</label>
|
||||
<MultiSelect v-model="filters.sources" :options="sourceOptions" optionLabel="label" optionValue="value" placeholder="Todas" class="w-full" display="chip" :maxSelectedLabels="2" @change="reload" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Tipo de entidade</label>
|
||||
<MultiSelect v-model="filters.entityTypes" :options="entityOptions" optionLabel="label" optionValue="value" placeholder="Todas" class="w-full" display="chip" :maxSelectedLabels="2" @change="reload" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Ação</label>
|
||||
<MultiSelect v-model="filters.actions" :options="actionOptions" optionLabel="label" optionValue="value" placeholder="Todas" class="w-full" display="chip" :maxSelectedLabels="2" @change="reload" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-6">
|
||||
<label class="text-xs font-medium text-surface-500 mb-1 block">Buscar (descrição, entidade, ação, usuário)</label>
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="filters.search" placeholder="ex: paciente, login, email..." class="w-full" maxlength="120" />
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-6 flex items-end gap-2">
|
||||
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined @click="reload" :loading="loading" />
|
||||
<Button label="Exportar CSV" icon="pi pi-file" severity="secondary" @click="onExportCSV" :disabled="!rows.length" />
|
||||
<Button label="Exportar Excel" icon="pi pi-file-excel" severity="secondary" @click="onExportExcel" :disabled="!rows.length" />
|
||||
<Button label="Exportar PDF" icon="pi pi-file-pdf" severity="secondary" @click="onExportPDF" :disabled="!rows.length" :loading="exportingPdf" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Resumo -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-surface-500 uppercase tracking-wide">Eventos no período</span>
|
||||
<span class="text-2xl font-bold mt-1">{{ summary.totalRows }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-surface-500 uppercase tracking-wide">Usuários distintos</span>
|
||||
<span class="text-2xl font-bold mt-1">{{ summary.distinctUsers }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-surface-500 uppercase tracking-wide">CRUD (criar/alterar/excluir)</span>
|
||||
<span class="text-2xl font-bold mt-1">
|
||||
{{ (summary.byAction.insert || 0) + (summary.byAction.update || 0) + (summary.byAction.delete || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-surface-500 uppercase tracking-wide">Acessos a documento</span>
|
||||
<span class="text-2xl font-bold mt-1">{{ summary.bySource.document_access_logs || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<DataTable
|
||||
:value="rows"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
stripedRows
|
||||
paginator
|
||||
:rows="25"
|
||||
:rowsPerPageOptions="[10, 25, 50, 100, 200]"
|
||||
sortField="occurred_at"
|
||||
:sortOrder="-1"
|
||||
emptyMessage="Nenhum evento auditado no período. Ajuste os filtros."
|
||||
:pt="{
|
||||
bodyRow: ({ context }) => ({ class: 'cursor-pointer hover:bg-primary/5' })
|
||||
}"
|
||||
@row-click="(ev) => openDetails(ev.data)"
|
||||
>
|
||||
<Column field="occurred_at" header="Data/Hora" style="min-width: 160px" sortable>
|
||||
<template #body="{ data }">{{ auditFormatters.fmtDate(data.occurred_at) }}</template>
|
||||
</Column>
|
||||
|
||||
<Column field="source" header="Origem" style="width: 140px" sortable>
|
||||
<template #body="{ data }">
|
||||
<Tag :value="auditFormatters.sourceLabel(data.source)" :severity="sourceSeverity(data.source)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="entity_type" header="Entidade" style="width: 130px" sortable>
|
||||
<template #body="{ data }">{{ auditFormatters.entityLabel(data.entity_type) }}</template>
|
||||
</Column>
|
||||
|
||||
<Column field="action" header="Ação" style="width: 120px" sortable>
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.action" :severity="actionSeverity(data.action)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="description" header="Descrição">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm">{{ data.description || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="user_id" header="Usuário" style="width: 180px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ userDisplay(data.user_id) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Dialog de detalhes -->
|
||||
<Dialog
|
||||
v-model:visible="detailDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="true"
|
||||
:dismissableMask="true"
|
||||
maximizable
|
||||
class="dc-dialog w-[60rem]"
|
||||
:breakpoints="{ '1199px': '92vw', '768px': '96vw' }"
|
||||
header="Detalhes do evento"
|
||||
>
|
||||
<div v-if="selectedEvent" class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
|
||||
<div><strong>Data/Hora:</strong> {{ auditFormatters.fmtDate(selectedEvent.occurred_at) }}</div>
|
||||
<div><strong>Origem:</strong> {{ auditFormatters.sourceLabel(selectedEvent.source) }}</div>
|
||||
<div><strong>Entidade:</strong> {{ auditFormatters.entityLabel(selectedEvent.entity_type) }}</div>
|
||||
<div><strong>Ação:</strong> {{ selectedEvent.action }}</div>
|
||||
<div><strong>ID da entidade:</strong> <span class="font-mono text-xs">{{ selectedEvent.entity_id || '—' }}</span></div>
|
||||
<div><strong>Usuário:</strong> {{ userDisplay(selectedEvent.user_id) }}</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<strong class="text-sm">Descrição:</strong>
|
||||
<p class="text-sm mt-1">{{ selectedEvent.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedEvent.details && selectedEvent.details.changed_fields?.length">
|
||||
<strong class="text-sm">Campos alterados:</strong>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<Tag v-for="f in selectedEvent.details.changed_fields" :key="f" :value="f" severity="warning" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedEvent.details?.old_values">
|
||||
<strong class="text-sm">Estado anterior:</strong>
|
||||
<pre class="text-xs bg-surface-50 p-2 rounded border border-surface-200 overflow-auto max-h-64 mt-1">{{ prettyJson(selectedEvent.details.old_values) }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedEvent.details?.new_values">
|
||||
<strong class="text-sm">Estado novo:</strong>
|
||||
<pre class="text-xs bg-surface-50 p-2 rounded border border-surface-200 overflow-auto max-h-64 mt-1">{{ prettyJson(selectedEvent.details.new_values) }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedEvent.details && !selectedEvent.details.old_values && !selectedEvent.details.new_values">
|
||||
<strong class="text-sm">Metadados:</strong>
|
||||
<pre class="text-xs bg-surface-50 p-2 rounded border border-surface-200 overflow-auto max-h-64 mt-1">{{ prettyJson(selectedEvent.details) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" text @click="detailDialog = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -574,11 +574,11 @@ onMounted(load);
|
||||
<span class="text-sm font-medium" :class="cfg.ativo ? 'text-green-600' : 'text-surface-400'">
|
||||
{{ cfg.ativo ? 'Ativo' : 'Inativo' }}
|
||||
</span>
|
||||
<InputSwitch :modelValue="cfg.ativo" @update:modelValue="toggleAtivo" />
|
||||
<ToggleSwitch :modelValue="cfg.ativo" @update:modelValue="toggleAtivo" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Tag value="Plano não inclui" severity="warn" class="text-xs" />
|
||||
<InputSwitch :modelValue="false" disabled v-tooltip.left="'Seu plano não inclui o Agendador Online'" />
|
||||
<ToggleSwitch :modelValue="false" disabled v-tooltip.left="'Seu plano não inclui o Agendador Online'" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -752,7 +752,7 @@ onMounted(load);
|
||||
<div class="font-semibold">Botão "Como chegar"</div>
|
||||
<div class="text-surface-400 mt-0.5">Exibe um botão que abre o mapa para o paciente.</div>
|
||||
</div>
|
||||
<InputSwitch v-model="cfg.botao_como_chegar_ativo" />
|
||||
<ToggleSwitch v-model="cfg.botao_como_chegar_ativo" />
|
||||
</div>
|
||||
<div v-if="cfg.botao_como_chegar_ativo" class="flex flex-col gap-1">
|
||||
<label class="cfg-label">URL do Google Maps <span class="font-normal opacity-60">(opcional)</span></label>
|
||||
@@ -986,14 +986,14 @@ onMounted(load);
|
||||
<div class="font-semibold">Motivo da consulta</div>
|
||||
<div class="text-surface-400 mt-0.5">Campo de texto livre opcional para o paciente informar o motivo.</div>
|
||||
</div>
|
||||
<InputSwitch v-model="cfg.triagem_motivo" />
|
||||
<ToggleSwitch v-model="cfg.triagem_motivo" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
|
||||
<div>
|
||||
<div class="font-semibold">Como nos conheceu?</div>
|
||||
<div class="text-surface-400 mt-0.5">Pergunta de origem (indicação, redes sociais, busca…).</div>
|
||||
</div>
|
||||
<InputSwitch v-model="cfg.triagem_como_conheceu" />
|
||||
<ToggleSwitch v-model="cfg.triagem_como_conheceu" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1007,14 +1007,14 @@ onMounted(load);
|
||||
<div class="font-semibold">Verificação de e-mail</div>
|
||||
<div class="text-surface-400 mt-0.5">Paciente confirma o e-mail antes de concluir o agendamento.</div>
|
||||
</div>
|
||||
<InputSwitch v-model="cfg.verificacao_email" />
|
||||
<ToggleSwitch v-model="cfg.verificacao_email" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
|
||||
<div>
|
||||
<div class="font-semibold">Aceite obrigatório de termos (LGPD)</div>
|
||||
<div class="text-surface-400 mt-0.5">Exige que o paciente marque o aceite da política de privacidade antes de finalizar.</div>
|
||||
</div>
|
||||
<InputSwitch v-model="cfg.exigir_aceite_lgpd" />
|
||||
<ToggleSwitch v-model="cfg.exigir_aceite_lgpd" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Auto-reply fora do horário (CRM Grupo 2.3)
|
||||
|--------------------------------------------------------------------------
|
||||
| Configurações de resposta automática quando paciente manda mensagem fora
|
||||
| do horário de atendimento.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useAutoReplySettings } from '@/composables/useAutoReplySettings';
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
const api = useAutoReplySettings();
|
||||
|
||||
const DIAS = [
|
||||
{ dow: 0, label: 'Dom' },
|
||||
{ dow: 1, label: 'Seg' },
|
||||
{ dow: 2, label: 'Ter' },
|
||||
{ dow: 3, label: 'Qua' },
|
||||
{ dow: 4, label: 'Qui' },
|
||||
{ dow: 5, label: 'Sex' },
|
||||
{ dow: 6, label: 'Sáb' }
|
||||
];
|
||||
|
||||
const agendaWindows = ref([]);
|
||||
|
||||
// ── Preview do status atual ─────────────────────────────────
|
||||
const now = ref(new Date());
|
||||
let _tick = null;
|
||||
|
||||
const currentWindows = computed(() => {
|
||||
if (api.settings.value.schedule_mode === 'agenda') return agendaWindows.value;
|
||||
if (api.settings.value.schedule_mode === 'business_hours') return api.settings.value.business_hours || [];
|
||||
if (api.settings.value.schedule_mode === 'custom') return api.settings.value.custom_window || [];
|
||||
return [];
|
||||
});
|
||||
|
||||
function hhmmToMin(s) {
|
||||
const m = String(s || '').match(/^(\d{1,2}):(\d{2})/);
|
||||
return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : -1;
|
||||
}
|
||||
|
||||
const isWithinHoursNow = computed(() => {
|
||||
const d = now.value;
|
||||
// Hora de São Paulo
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Sao_Paulo',
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const parts = fmt.formatToParts(d);
|
||||
const weekday = parts.find((p) => p.type === 'weekday')?.value || 'Sun';
|
||||
const h = parseInt(parts.find((p) => p.type === 'hour')?.value || '0', 10);
|
||||
const mm = parseInt(parts.find((p) => p.type === 'minute')?.value || '0', 10);
|
||||
const dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||
const dow = dowMap[weekday] ?? 0;
|
||||
const mins = h * 60 + mm;
|
||||
for (const w of currentWindows.value) {
|
||||
if (w.dow !== dow) continue;
|
||||
const s = hhmmToMin(w.start);
|
||||
const e = hhmmToMin(w.end);
|
||||
if (s < 0 || e < 0) continue;
|
||||
if (mins >= s && mins < e) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const nowLabel = computed(() => {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
timeZone: 'America/Sao_Paulo',
|
||||
weekday: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(now.value);
|
||||
});
|
||||
|
||||
// ── Editor de janelas (business_hours / custom_window) ──────
|
||||
function targetArrayKey() {
|
||||
if (api.settings.value.schedule_mode === 'business_hours') return 'business_hours';
|
||||
if (api.settings.value.schedule_mode === 'custom') return 'custom_window';
|
||||
return null;
|
||||
}
|
||||
|
||||
function addWindow(dow) {
|
||||
const key = targetArrayKey();
|
||||
if (!key) return;
|
||||
const arr = [...(api.settings.value[key] || [])];
|
||||
arr.push({ dow, start: '08:00', end: '18:00' });
|
||||
arr.sort((a, b) => a.dow - b.dow || hhmmToMin(a.start) - hhmmToMin(b.start));
|
||||
api.settings.value = { ...api.settings.value, [key]: arr };
|
||||
}
|
||||
|
||||
function updateWindow(idx, patch) {
|
||||
const key = targetArrayKey();
|
||||
if (!key) return;
|
||||
const arr = [...(api.settings.value[key] || [])];
|
||||
arr[idx] = { ...arr[idx], ...patch };
|
||||
api.settings.value = { ...api.settings.value, [key]: arr };
|
||||
}
|
||||
|
||||
function removeWindow(idx) {
|
||||
const key = targetArrayKey();
|
||||
if (!key) return;
|
||||
const arr = [...(api.settings.value[key] || [])];
|
||||
arr.splice(idx, 1);
|
||||
api.settings.value = { ...api.settings.value, [key]: arr };
|
||||
}
|
||||
|
||||
function applyDefaultWindow() {
|
||||
const key = targetArrayKey();
|
||||
if (!key) return;
|
||||
// Seg-Sex 09h-18h
|
||||
const arr = [1, 2, 3, 4, 5].map((dow) => ({ dow, start: '09:00', end: '18:00' }));
|
||||
api.settings.value = { ...api.settings.value, [key]: arr };
|
||||
}
|
||||
|
||||
function clearWindows() {
|
||||
const key = targetArrayKey();
|
||||
if (!key) return;
|
||||
api.settings.value = { ...api.settings.value, [key]: [] };
|
||||
}
|
||||
|
||||
const editableWindows = computed(() => {
|
||||
const key = targetArrayKey();
|
||||
if (!key) return [];
|
||||
return api.settings.value[key] || [];
|
||||
});
|
||||
|
||||
// ── Ações ───────────────────────────────────────────────────
|
||||
async function onSave() {
|
||||
const res = await api.save();
|
||||
if (res.ok) {
|
||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAgenda() {
|
||||
agendaWindows.value = await api.loadAgendaWindows();
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await Promise.all([api.load(), refreshAgenda()]);
|
||||
_tick = setInterval(() => { now.value = new Date(); }, 30000);
|
||||
});
|
||||
|
||||
watch(() => tenantStore.activeTenantId, async () => {
|
||||
await Promise.all([api.load(), refreshAgenda()]);
|
||||
});
|
||||
|
||||
import { onBeforeUnmount } from 'vue';
|
||||
onBeforeUnmount(() => { if (_tick) clearInterval(_tick); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Ações no header do parent -->
|
||||
<Teleport to="#cfg-page-actions" defer>
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:loading="api.saving.value"
|
||||
:disabled="api.loading.value"
|
||||
@click="onSave"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full"
|
||||
:loading="api.loading.value"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
@click="api.load()"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Preview de status atual -->
|
||||
<div
|
||||
class="rounded-md border p-3 flex items-center gap-3"
|
||||
:class="api.settings.value.enabled
|
||||
? (isWithinHoursNow
|
||||
? 'border-green-500/30 bg-green-500/5'
|
||||
: 'border-amber-500/30 bg-amber-500/5')
|
||||
: 'border-[var(--surface-border)] bg-[var(--surface-card)] opacity-80'"
|
||||
>
|
||||
<i
|
||||
class="pi text-lg shrink-0"
|
||||
:class="!api.settings.value.enabled
|
||||
? 'pi-pause-circle text-[var(--text-color-secondary)]'
|
||||
: isWithinHoursNow
|
||||
? 'pi-check-circle text-green-500'
|
||||
: 'pi-bolt text-amber-500'"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-[var(--text-color)]">
|
||||
<template v-if="!api.settings.value.enabled">Auto-reply desabilitado</template>
|
||||
<template v-else-if="isWithinHoursNow">Dentro do horário — auto-reply NÃO disparará</template>
|
||||
<template v-else>Fora do horário — auto-reply disparará para novas mensagens</template>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5 capitalize">
|
||||
{{ nowLabel }} · fuso São Paulo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle + mensagem -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<ToggleSwitch v-model="api.settings.value.enabled" />
|
||||
<span class="text-sm font-semibold">
|
||||
{{ api.settings.value.enabled ? 'Auto-reply ativado' : 'Auto-reply desativado' }}
|
||||
</span>
|
||||
</label>
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">
|
||||
Envia resposta automática quando mensagem chega fora do horário configurado.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1">Mensagem automática</label>
|
||||
<Textarea
|
||||
v-model="api.settings.value.message"
|
||||
rows="3"
|
||||
autoResize
|
||||
:maxlength="2000"
|
||||
class="w-full"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
/>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] mt-1">
|
||||
{{ (api.settings.value.message || '').length }} / 2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1">
|
||||
Cooldown por paciente: <span class="text-[var(--text-color)]">{{ api.settings.value.cooldown_minutes }} min</span>
|
||||
</label>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<InputNumber
|
||||
v-model="api.settings.value.cooldown_minutes"
|
||||
:min="0"
|
||||
:max="43200"
|
||||
suffix=" min"
|
||||
inputClass="!w-28"
|
||||
class="shrink-0"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-color-secondary)] flex-1 min-w-0 leading-snug">
|
||||
Tempo mínimo entre auto-replies para a mesma conversa (evita spam). Sugestão: 180 min (3h).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modo de horário -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-clock text-[var(--primary-color)]" />
|
||||
<span class="text-sm font-bold uppercase tracking-wide">Quando disparar</span>
|
||||
</div>
|
||||
|
||||
<SelectButton
|
||||
v-model="api.settings.value.schedule_mode"
|
||||
:options="[
|
||||
{ label: 'Seguir minha agenda', value: 'agenda' },
|
||||
{ label: 'Horário de funcionamento', value: 'business_hours' },
|
||||
{ label: 'Personalizado', value: 'custom' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
/>
|
||||
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
<template v-if="api.settings.value.schedule_mode === 'agenda'">
|
||||
Usa automaticamente os horários da <strong>Agenda</strong> (agenda_regras_semanais) de todos os profissionais ativos do tenant. Se alguém está trabalhando agora, é considerado "dentro do horário".
|
||||
</template>
|
||||
<template v-else-if="api.settings.value.schedule_mode === 'business_hours'">
|
||||
Define um horário geral da clínica, reutilizável por outras automações. Independe das agendas individuais.
|
||||
</template>
|
||||
<template v-else>
|
||||
Define um horário exclusivo para este auto-reply. Útil se você quer que responda antes/depois do expediente normal.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Preview das janelas do modo 'agenda' (read-only) -->
|
||||
<div v-if="api.settings.value.schedule_mode === 'agenda'" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3">
|
||||
<div v-if="!agendaWindows.length" class="text-xs text-[var(--text-color-secondary)] italic">
|
||||
Nenhuma regra semanal ativa encontrada na agenda. Configure em <a href="/configuracoes/agenda" class="text-[var(--primary-color)] underline">Configurações → Agenda</a>.
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-7 gap-1">
|
||||
<div v-for="d in DIAS" :key="d.dow" class="flex flex-col items-center gap-1">
|
||||
<div class="text-[0.7rem] font-semibold text-[var(--text-color-secondary)] uppercase">{{ d.label }}</div>
|
||||
<div class="flex flex-col gap-0.5 w-full">
|
||||
<div
|
||||
v-for="(w, i) in agendaWindows.filter(x => x.dow === d.dow)"
|
||||
:key="i"
|
||||
class="text-[0.65rem] text-center font-mono rounded bg-[var(--primary-color)]/10 text-[var(--primary-color)] px-1 py-0.5"
|
||||
>
|
||||
{{ w.start }}–{{ w.end }}
|
||||
</div>
|
||||
<div v-if="!agendaWindows.some(x => x.dow === d.dow)" class="text-[0.65rem] text-center text-[var(--text-color-secondary)] opacity-40">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor de janelas (business_hours / custom) -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
label="Aplicar padrão Seg-Sex 09h-18h"
|
||||
icon="pi pi-calendar"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
@click="applyDefaultWindow"
|
||||
/>
|
||||
<Button
|
||||
label="Limpar"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
:disabled="!api.settings.value.enabled || !editableWindows.length"
|
||||
@click="clearWindows"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-1.5 border border-[var(--surface-border)] rounded-md bg-[var(--surface-ground)] p-2">
|
||||
<div v-for="d in DIAS" :key="d.dow" class="flex flex-col gap-1 min-h-[80px]">
|
||||
<div class="text-[0.7rem] font-bold text-[var(--text-color-secondary)] uppercase text-center">{{ d.label }}</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="(w, idx) in editableWindows.map((x, i) => ({ ...x, _idx: i })).filter(x => x.dow === d.dow)"
|
||||
:key="w._idx"
|
||||
class="flex flex-col gap-0.5 bg-[var(--surface-card)] rounded p-1"
|
||||
>
|
||||
<input
|
||||
type="time"
|
||||
:value="w.start"
|
||||
class="text-[0.68rem] px-1 py-0.5 rounded border border-[var(--surface-border)] bg-[var(--surface-card)] w-full"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
@change="(e) => updateWindow(w._idx, { start: e.target.value })"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
:value="w.end"
|
||||
class="text-[0.68rem] px-1 py-0.5 rounded border border-[var(--surface-border)] bg-[var(--surface-card)] w-full"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
@change="(e) => updateWindow(w._idx, { end: e.target.value })"
|
||||
/>
|
||||
<button
|
||||
class="text-[0.6rem] text-red-500 hover:text-red-700 bg-transparent border-0 cursor-pointer p-0 leading-tight"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
@click="removeWindow(w._idx)"
|
||||
>
|
||||
<i class="pi pi-times text-[0.55rem]" /> remover
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="text-[0.65rem] text-[var(--text-color-secondary)] hover:text-[var(--primary-color)] bg-transparent border border-dashed border-[var(--surface-border)] rounded py-0.5 cursor-pointer"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
@click="addWindow(d.dow)"
|
||||
>
|
||||
+ adicionar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info sobre dependências -->
|
||||
<div class="rounded-md border border-sky-500/25 bg-sky-500/5 p-3 flex items-start gap-2 text-xs text-[var(--text-color)]">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5" />
|
||||
<div>
|
||||
<strong>Pré-requisito:</strong> o WhatsApp do tenant precisa estar conectado em
|
||||
<a href="/configuracoes/whatsapp" class="text-[var(--primary-color)] underline">Configurações → WhatsApp</a>.
|
||||
O auto-reply é enviado via o mesmo canal.
|
||||
<br/>
|
||||
Todas as datas/horas são tratadas no fuso <strong>America/Sao_Paulo</strong>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,374 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Opt-outs do CRM WhatsApp (LGPD Art. 18 Sec.2)
|
||||
|--------------------------------------------------------------------------
|
||||
| Lista de números que pediram pra não receber mensagens automáticas.
|
||||
| Configuração das palavras-chave de opt-out.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, reactive } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useConversationOptouts } from '@/composables/useConversationOptouts';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
const api = useConversationOptouts();
|
||||
|
||||
const activeTab = ref(0);
|
||||
|
||||
// Dialog de adicionar manual
|
||||
const dlg = reactive({ open: false, phone: '', notes: '' });
|
||||
|
||||
function openAddManual() {
|
||||
dlg.open = true;
|
||||
dlg.phone = '';
|
||||
dlg.notes = '';
|
||||
}
|
||||
|
||||
async function saveManual() {
|
||||
const res = await api.addManual({ phone: dlg.phone, notes: dlg.notes.trim() || null });
|
||||
if (res.ok) {
|
||||
toast.add({ severity: 'success', summary: 'Opt-out adicionado', life: 2200 });
|
||||
dlg.open = false;
|
||||
} else if (res.error === 'already_opted_out') {
|
||||
toast.add({ severity: 'warn', summary: 'Já existe', detail: 'Este número já está opted-out ativo.', life: 3500 });
|
||||
} else if (res.error === 'invalid_phone_format') {
|
||||
toast.add({ severity: 'error', summary: 'Telefone inválido', detail: 'Use formato DDD + número (11 dígitos).', life: 3500 });
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRestore(optout) {
|
||||
const label = optout._patient_name || optout.phone;
|
||||
confirm.require({
|
||||
message: `Restaurar ${label}? A pessoa voltará a receber mensagens automáticas.`,
|
||||
header: 'Restaurar opt-out',
|
||||
icon: 'pi pi-undo',
|
||||
acceptLabel: 'Restaurar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
const res = await api.restore(optout.id);
|
||||
if (res.ok) {
|
||||
toast.add({ severity: 'success', summary: 'Restaurado', life: 2000 });
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keywords
|
||||
const newKeyword = ref('');
|
||||
async function addKw() {
|
||||
const res = await api.addKeyword(newKeyword.value);
|
||||
if (res.ok) {
|
||||
newKeyword.value = '';
|
||||
toast.add({ severity: 'success', summary: 'Keyword adicionada', life: 1800 });
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleKw(kw, enabled) {
|
||||
const res = await api.toggleKeyword(kw.id, enabled);
|
||||
if (!res.ok) toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
|
||||
function confirmDeleteKw(kw) {
|
||||
confirm.require({
|
||||
message: `Remover keyword "${kw.keyword}"?`,
|
||||
header: 'Remover',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Remover',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
const res = await api.deleteKeyword(kw.id);
|
||||
if (res.ok) toast.add({ severity: 'success', summary: 'Removida', life: 1800 });
|
||||
else toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function formatPhoneDisplay(phone) {
|
||||
const s = String(phone || '').replace(/\D/g, '');
|
||||
// 55 11 98765-4321
|
||||
if (s.length === 13 && s.startsWith('55')) {
|
||||
return `+55 (${s.slice(2, 4)}) ${s.slice(4, 9)}-${s.slice(9)}`;
|
||||
}
|
||||
if (s.length === 12 && s.startsWith('55')) {
|
||||
return `+55 (${s.slice(2, 4)}) ${s.slice(4, 8)}-${s.slice(8)}`;
|
||||
}
|
||||
return `+${s}`;
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
const quickStats = computed(() => [
|
||||
{ label: 'Ativos', value: api.activeOptouts.value.length, cls: api.activeOptouts.value.length > 0 ? 'qs-warn' : '' },
|
||||
{ label: 'Por keyword', value: api.activeOptouts.value.filter((o) => o.source === 'keyword').length, cls: '' },
|
||||
{ label: 'Adicionados manual', value: api.activeOptouts.value.filter((o) => o.source === 'manual').length, cls: '' },
|
||||
{ label: 'Histórico (restaurados)', value: api.historyOptouts.value.length, cls: '' }
|
||||
]);
|
||||
|
||||
onMounted(() => { api.load(); });
|
||||
watch(() => tenantStore.activeTenantId, () => { api.load(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<Teleport to="#cfg-page-actions" defer>
|
||||
<Button
|
||||
v-if="activeTab === 0"
|
||||
label="Adicionar manualmente"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="openAddManual"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full"
|
||||
:loading="api.loading.value"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
@click="api.load()"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Info LGPD -->
|
||||
<div class="rounded-md border border-amber-500/25 bg-amber-500/5 p-3 flex items-start gap-2 text-xs text-[var(--text-color)]">
|
||||
<i class="pi pi-shield text-amber-500 mt-0.5 text-base" />
|
||||
<div>
|
||||
<strong>LGPD — Direito de oposição (Art. 18, §2):</strong>
|
||||
quando o paciente envia "PARAR" (ou similar), o sistema bloqueia automaticamente envios de auto-reply, lembretes e outras mensagens automáticas.
|
||||
O terapeuta ainda pode escrever manualmente — a relação terapêutica existe.
|
||||
Pra voltar a receber, o paciente envia "VOLTAR".
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<div
|
||||
v-for="s in quickStats"
|
||||
:key="s.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border transition-colors"
|
||||
:class="s.cls === 'qs-warn' ? 'border-amber-500/25 bg-amber-500/5' : 'border-[var(--surface-border)] bg-[var(--surface-card)]'"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<Tabs :value="activeTab" @update:value="activeTab = $event">
|
||||
<TabList>
|
||||
<Tab :value="0"><i class="pi pi-ban mr-2" />Opt-outs</Tab>
|
||||
<Tab :value="1"><i class="pi pi-key mr-2" />Palavras-chave</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<!-- Tab 0: Lista de opt-outs -->
|
||||
<TabPanel :value="0">
|
||||
<div v-if="api.loading.value" class="flex flex-col gap-2 pt-3">
|
||||
<Skeleton v-for="n in 5" :key="n" height="3.5rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!api.optouts.value.length" class="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||
<i class="pi pi-check-circle text-4xl text-green-500 opacity-40" />
|
||||
<div class="text-[1rem] font-semibold">Nenhum opt-out ainda</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">Quando um paciente enviar "PARAR" ou similar, aparece aqui.</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2 pt-3">
|
||||
<div
|
||||
v-for="o in api.optouts.value"
|
||||
:key="o.id"
|
||||
class="flex items-center gap-3 p-3 rounded-md border transition-all"
|
||||
:class="o.opted_back_in_at
|
||||
? 'border-[var(--surface-border)] bg-[var(--surface-ground)] opacity-70'
|
||||
: 'border-amber-500/30 bg-amber-500/5'"
|
||||
>
|
||||
<i
|
||||
class="pi text-lg shrink-0"
|
||||
:class="o.opted_back_in_at ? 'pi-check-circle text-green-500' : 'pi-ban text-amber-500'"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-semibold text-[var(--text-color)] truncate">
|
||||
{{ o._patient_name || formatPhoneDisplay(o.phone) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-px rounded text-[0.62rem] font-bold uppercase"
|
||||
:class="o.source === 'keyword'
|
||||
? 'bg-indigo-500/10 text-indigo-500'
|
||||
: 'bg-slate-500/10 text-slate-500'"
|
||||
>
|
||||
{{ o.source === 'keyword' ? `Keyword: ${o.keyword_matched}` : 'Manual' }}
|
||||
</span>
|
||||
<span v-if="o.opted_back_in_at" class="inline-flex items-center px-1.5 py-px rounded text-[0.62rem] font-bold uppercase bg-green-500/10 text-green-500">
|
||||
Restaurado
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
<span v-if="o._patient_name">{{ formatPhoneDisplay(o.phone) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(o.opted_out_at) }}</span>
|
||||
<span v-if="o.opted_back_in_at">· restaurado em {{ formatDate(o.opted_back_in_at) }}</span>
|
||||
</div>
|
||||
<div v-if="o.original_message" class="text-xs text-[var(--text-color-secondary)] italic mt-1 truncate" :title="o.original_message">
|
||||
"{{ o.original_message }}"
|
||||
</div>
|
||||
<div v-if="o.notes" class="text-xs text-[var(--text-color)] mt-1">{{ o.notes }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<Button
|
||||
v-if="!o.opted_back_in_at"
|
||||
label="Restaurar"
|
||||
icon="pi pi-undo"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:loading="api.saving.value"
|
||||
@click="confirmRestore(o)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- Tab 1: Keywords -->
|
||||
<TabPanel :value="1">
|
||||
<div class="flex flex-col gap-3 pt-3">
|
||||
<!-- Add -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<InputText
|
||||
v-model="newKeyword"
|
||||
placeholder="Adicionar palavra-chave (ex: não quero receber)"
|
||||
class="flex-1"
|
||||
:maxlength="100"
|
||||
@keyup.enter="addKw"
|
||||
/>
|
||||
<Button
|
||||
label="Adicionar"
|
||||
icon="pi pi-plus"
|
||||
:loading="api.saving.value"
|
||||
:disabled="!newKeyword.trim()"
|
||||
@click="addKw"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-if="api.loading.value" class="flex flex-col gap-2">
|
||||
<Skeleton v-for="n in 5" :key="n" height="3rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!api.keywords.value.length" class="text-sm text-[var(--text-color-secondary)] italic text-center py-6">
|
||||
Nenhuma palavra-chave ainda.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="kw in api.keywords.value"
|
||||
:key="kw.id"
|
||||
class="flex items-center gap-3 p-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]"
|
||||
>
|
||||
<i class="pi pi-key text-sm shrink-0" :class="kw.enabled ? 'text-[var(--primary-color)]' : 'text-[var(--text-color-secondary)] opacity-40'" />
|
||||
|
||||
<span class="font-mono text-sm flex-1" :class="!kw.enabled ? 'opacity-50 line-through' : ''">{{ kw.keyword }}</span>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-px rounded text-[0.6rem] font-bold uppercase shrink-0"
|
||||
:class="kw.is_system ? 'bg-slate-500/10 text-slate-500' : 'bg-indigo-500/10 text-indigo-500'"
|
||||
>
|
||||
{{ kw.is_system ? 'Sistema' : 'Custom' }}
|
||||
</span>
|
||||
|
||||
<ToggleSwitch
|
||||
:modelValue="kw.enabled"
|
||||
@update:modelValue="(v) => onToggleKw(kw, v)"
|
||||
:disabled="api.saving.value"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="!kw.is_system"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
class="h-8 w-8"
|
||||
v-tooltip.top="'Remover'"
|
||||
:disabled="api.saving.value"
|
||||
@click="confirmDeleteKw(kw)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-[var(--text-color-secondary)] italic">
|
||||
Palavras-chave do sistema não podem ser removidas, mas podem ser desabilitadas.
|
||||
Match é case-insensitive e ignora acentos. Exemplo: "PARAR", "parar", "PARÁR" todos funcionam.
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Dialog adicionar manual -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
header="Adicionar opt-out manualmente"
|
||||
modal
|
||||
:style="{ width: 'min(440px, 95vw)' }"
|
||||
:closable="!api.saving.value"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1">Telefone (com DDD)</label>
|
||||
<InputText
|
||||
v-model="dlg.phone"
|
||||
placeholder="11987654321"
|
||||
class="w-full"
|
||||
@keydown.enter="saveManual"
|
||||
/>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] mt-1">
|
||||
Só dígitos. DDI 55 é adicionado automaticamente se faltar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1">Motivo (opcional)</label>
|
||||
<Textarea
|
||||
v-model="dlg.notes"
|
||||
rows="2"
|
||||
autoResize
|
||||
class="w-full"
|
||||
:maxlength="500"
|
||||
placeholder="Ex: paciente pediu por telefone pra parar de receber"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" text :disabled="api.saving.value" @click="dlg.open = false" />
|
||||
<Button label="Adicionar" :loading="api.saving.value" :disabled="!dlg.phone.trim()" @click="saveManual" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,391 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Tags de Conversa (configurações)
|
||||
|--------------------------------------------------------------------------
|
||||
| CRUD de tags custom. Tags do sistema aparecem em modo leitura.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useConversationTags } from '@/composables/useConversationTags';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
const tagsApi = useConversationTags();
|
||||
|
||||
// ── Usage count (quantas threads usam cada tag) ──────────────
|
||||
const usageByTag = ref({}); // { [tagId]: count }
|
||||
|
||||
async function loadUsage() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
usageByTag.value = {};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_thread_tags')
|
||||
.select('tag_id')
|
||||
.eq('tenant_id', tenantId);
|
||||
if (error) throw error;
|
||||
const counts = {};
|
||||
for (const row of data || []) {
|
||||
counts[row.tag_id] = (counts[row.tag_id] || 0) + 1;
|
||||
}
|
||||
usageByTag.value = counts;
|
||||
} catch (e) {
|
||||
console.error('[ConfiguracoesConversasTags] loadUsage:', e?.message);
|
||||
usageByTag.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([tagsApi.loadAllTags(), loadUsage()]);
|
||||
}
|
||||
|
||||
// ── Quick stats ─────────────────────────────────────────────
|
||||
const quickStats = computed(() => {
|
||||
const all = tagsApi.allTags.value || [];
|
||||
const system = all.filter((t) => t.is_system).length;
|
||||
const custom = all.filter((t) => !t.is_system).length;
|
||||
const emUso = Object.keys(usageByTag.value).length;
|
||||
return [
|
||||
{ label: 'Total de tags', value: all.length, cls: '' },
|
||||
{ label: 'Sistema', value: system, cls: '' },
|
||||
{ label: 'Minhas tags', value: custom, cls: custom > 0 ? 'qs-ok' : '' },
|
||||
{ label: 'Em uso', value: emUso, cls: emUso > 0 ? 'qs-ok' : '' }
|
||||
];
|
||||
});
|
||||
|
||||
// ── Dialog create/edit ──────────────────────────────────────
|
||||
const presetColors = [
|
||||
'#ef4444', '#f97316', '#f59e0b', '#eab308',
|
||||
'#84cc16', '#22c55e', '#14b8a6', '#06b6d4',
|
||||
'#0ea5e9', '#6366f1', '#8b5cf6', '#a855f7',
|
||||
'#ec4899', '#64748b', '#292524'
|
||||
];
|
||||
|
||||
const presetIcons = [
|
||||
{ icon: 'pi pi-tag', label: 'Tag' },
|
||||
{ icon: 'pi pi-exclamation-triangle', label: 'Alerta' },
|
||||
{ icon: 'pi pi-exclamation-circle', label: 'Exclamação' },
|
||||
{ icon: 'pi pi-info-circle', label: 'Info' },
|
||||
{ icon: 'pi pi-check-circle', label: 'Check' },
|
||||
{ icon: 'pi pi-star', label: 'Estrela' },
|
||||
{ icon: 'pi pi-heart', label: 'Coração' },
|
||||
{ icon: 'pi pi-flag', label: 'Bandeira' },
|
||||
{ icon: 'pi pi-bookmark', label: 'Marcador' },
|
||||
{ icon: 'pi pi-user', label: 'Usuário' },
|
||||
{ icon: 'pi pi-user-plus', label: 'Novo usuário' },
|
||||
{ icon: 'pi pi-users', label: 'Grupo' },
|
||||
{ icon: 'pi pi-calendar', label: 'Calendário' },
|
||||
{ icon: 'pi pi-calendar-times', label: 'Calendário cancelado' },
|
||||
{ icon: 'pi pi-clock', label: 'Relógio' },
|
||||
{ icon: 'pi pi-phone', label: 'Telefone' },
|
||||
{ icon: 'pi pi-comment', label: 'Balão' },
|
||||
{ icon: 'pi pi-whatsapp', label: 'WhatsApp' },
|
||||
{ icon: 'pi pi-envelope', label: 'Envelope' },
|
||||
{ icon: 'pi pi-send', label: 'Enviar' },
|
||||
{ icon: 'pi pi-reply', label: 'Responder' },
|
||||
{ icon: 'pi pi-bolt', label: 'Raio' },
|
||||
{ icon: 'pi pi-dollar', label: 'Dinheiro' },
|
||||
{ icon: 'pi pi-shield', label: 'Escudo' }
|
||||
];
|
||||
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
mode: 'create',
|
||||
id: '',
|
||||
name: '',
|
||||
color: '#6366f1',
|
||||
icon: 'pi pi-tag'
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(dlg, {
|
||||
open: true,
|
||||
mode: 'create',
|
||||
id: '',
|
||||
name: '',
|
||||
color: '#6366f1',
|
||||
icon: 'pi pi-tag'
|
||||
});
|
||||
}
|
||||
|
||||
function openEdit(tag) {
|
||||
Object.assign(dlg, {
|
||||
open: true,
|
||||
mode: 'edit',
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
icon: tag.icon || 'pi pi-tag'
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTag() {
|
||||
const clean = (dlg.name || '').trim();
|
||||
if (!clean) {
|
||||
toast.add({ severity: 'warn', summary: 'Nome obrigatório', life: 2500 });
|
||||
return;
|
||||
}
|
||||
let res;
|
||||
if (dlg.mode === 'create') {
|
||||
res = await tagsApi.createCustomTag({ name: clean, color: dlg.color, icon: dlg.icon });
|
||||
} else {
|
||||
res = await tagsApi.updateCustomTag(dlg.id, { name: clean, color: dlg.color, icon: dlg.icon });
|
||||
}
|
||||
if (res.ok) {
|
||||
toast.add({ severity: 'success', summary: dlg.mode === 'create' ? 'Tag criada' : 'Tag atualizada', life: 2000 });
|
||||
dlg.open = false;
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error || 'Falha ao salvar', life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(tag) {
|
||||
const usage = usageByTag.value[tag.id] || 0;
|
||||
const msg = usage > 0
|
||||
? `A tag "${tag.name}" está aplicada em ${usage} conversa${usage > 1 ? 's' : ''}. Ao remover, ela sai de todas. Continuar?`
|
||||
: `Remover a tag "${tag.name}"?`;
|
||||
confirm.require({
|
||||
message: msg,
|
||||
header: 'Remover tag',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Remover',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
const res = await tagsApi.deleteCustomTag(tag.id);
|
||||
if (res.ok) {
|
||||
await loadUsage();
|
||||
toast.add({ severity: 'success', summary: 'Tag removida', life: 2000 });
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────
|
||||
onMounted(() => { refresh(); });
|
||||
|
||||
watch(() => tenantStore.activeTenantId, () => { refresh(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Botões teleportados pro header do ConfiguracoesPage -->
|
||||
<Teleport to="#cfg-page-actions" defer>
|
||||
<Button
|
||||
label="Nova tag"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="openCreate"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full"
|
||||
:loading="tagsApi.loading.value"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
@click="refresh"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<template v-if="tagsApi.loading.value">
|
||||
<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.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border transition-colors"
|
||||
:class="[
|
||||
s.cls === 'qs-ok'
|
||||
? 'border-green-500/25 bg-green-500/5'
|
||||
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'
|
||||
]"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none" :class="s.cls === 'qs-ok' ? 'text-green-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>
|
||||
|
||||
<!-- Lista de tags -->
|
||||
<div>
|
||||
<div v-if="tagsApi.loading.value" class="flex flex-col gap-2">
|
||||
<Skeleton v-for="n in 6" :key="n" height="4rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!tagsApi.allTags.value.length" class="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||
<i class="pi pi-tag text-4xl text-[var(--text-color-secondary)] opacity-40" />
|
||||
<div class="text-[1rem] font-semibold">Nenhuma tag ainda</div>
|
||||
<Button label="Criar primeira tag" icon="pi pi-plus" size="small" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="tag in tagsApi.allTags.value"
|
||||
:key="tag.id"
|
||||
class="flex items-center gap-3 p-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] hover:border-indigo-300/50 hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)] transition-all"
|
||||
>
|
||||
<!-- Preview da pill -->
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[0.78rem] font-semibold shrink-0"
|
||||
:style="{
|
||||
background: tag.color + '20',
|
||||
color: tag.color,
|
||||
border: `1px solid ${tag.color}40`
|
||||
}"
|
||||
>
|
||||
<i v-if="tag.icon" :class="tag.icon" class="text-[0.7rem]" />
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs font-mono text-[var(--text-color-secondary)]">{{ tag.slug }}</span>
|
||||
<span
|
||||
v-if="tag.is_system"
|
||||
class="inline-flex items-center px-1.5 py-px rounded text-[0.65rem] font-bold uppercase tracking-wide bg-slate-500/10 text-slate-500"
|
||||
>
|
||||
Sistema
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-1.5 py-px rounded text-[0.65rem] font-bold uppercase tracking-wide bg-indigo-500/10 text-indigo-500"
|
||||
>
|
||||
Custom
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||||
<span v-if="usageByTag[tag.id]">
|
||||
Em {{ usageByTag[tag.id] }} conversa{{ usageByTag[tag.id] > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-else class="italic opacity-60">Não utilizada</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
v-if="!tag.is_system"
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-8 w-8 rounded-full"
|
||||
v-tooltip.top="'Editar'"
|
||||
:disabled="tagsApi.saving.value"
|
||||
@click="openEdit(tag)"
|
||||
/>
|
||||
<Button
|
||||
v-if="!tag.is_system"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
class="h-8 w-8 rounded-full"
|
||||
v-tooltip.top="'Remover'"
|
||||
:disabled="tagsApi.saving.value"
|
||||
@click="confirmDelete(tag)"
|
||||
/>
|
||||
<span v-if="tag.is_system" class="text-[0.7rem] text-[var(--text-color-secondary)] italic opacity-60 px-2">
|
||||
bloqueada
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /flex flex-col gap-3 wrapper -->
|
||||
|
||||
<!-- Dialog create/edit -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
:header="dlg.mode === 'create' ? 'Nova tag' : 'Editar tag'"
|
||||
modal
|
||||
:style="{ width: 'min(460px, 95vw)' }"
|
||||
:closable="!tagsApi.saving.value"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Preview -->
|
||||
<div class="flex items-center justify-center py-3 bg-[var(--surface-ground)] rounded-md">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold"
|
||||
:style="{
|
||||
background: dlg.color + '20',
|
||||
color: dlg.color,
|
||||
border: `1px solid ${dlg.color}40`
|
||||
}"
|
||||
>
|
||||
<i v-if="dlg.icon" :class="dlg.icon" class="text-xs" />
|
||||
{{ dlg.name.trim() || 'Preview' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Nome -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1">Nome</label>
|
||||
<InputText v-model="dlg.name" placeholder="Ex: Aguardando confirmação" :maxlength="40" class="w-full" @keydown.enter="saveTag" />
|
||||
</div>
|
||||
|
||||
<!-- Cor -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1">Cor</label>
|
||||
<div class="flex items-center flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="c in presetColors"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="w-7 h-7 rounded-full border-2 transition-transform cursor-pointer"
|
||||
:class="dlg.color === c ? 'border-[var(--text-color)] scale-110' : 'border-transparent hover:scale-105'"
|
||||
:style="{ background: c }"
|
||||
:aria-label="c"
|
||||
@click="dlg.color = c"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
v-model="dlg.color"
|
||||
class="w-7 h-7 rounded-full border-0 cursor-pointer"
|
||||
aria-label="Cor personalizada"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ícone -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1">Ícone</label>
|
||||
<div class="grid grid-cols-8 gap-1 max-h-[180px] overflow-y-auto pr-1 border border-[var(--surface-border)] rounded-md p-2 bg-[var(--surface-ground)]">
|
||||
<button
|
||||
v-for="p in presetIcons"
|
||||
:key="p.icon"
|
||||
type="button"
|
||||
class="w-9 h-9 rounded-md flex items-center justify-center bg-transparent border cursor-pointer transition-all hover:scale-105"
|
||||
:class="dlg.icon === p.icon ? 'border-[var(--primary-color)] bg-[var(--primary-color)]/10 text-[var(--primary-color)]' : 'border-[var(--surface-border)] text-[var(--text-color-secondary)]'"
|
||||
v-tooltip.top="p.label"
|
||||
@click="dlg.icon = p.icon"
|
||||
>
|
||||
<i :class="p.icon" class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" text :disabled="tagsApi.saving.value" @click="dlg.open = false" />
|
||||
<Button :label="dlg.mode === 'create' ? 'Criar' : 'Salvar'" :loading="tagsApi.saving.value" :disabled="!dlg.name.trim()" @click="saveTag" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,425 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Créditos WhatsApp (Marco B)
|
||||
|--------------------------------------------------------------------------
|
||||
| Saldo + loja de pacotes + histórico de compras e uso.
|
||||
| Créditos são consumidos apenas no canal Oficial AgenciaPSI (Twilio);
|
||||
| WhatsApp Pessoal (Evolution) é gratuito.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, reactive } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useWhatsappCredits } from '@/composables/useWhatsappCredits';
|
||||
import { isValidCPF, fmtCPF, isValidCNPJ, fmtCNPJ } from '@/utils/validators';
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
const api = useWhatsappCredits();
|
||||
|
||||
// Dialog de confirmação (coleta CPF/CNPJ antes de criar a cobrança)
|
||||
const confirmDlg = reactive({
|
||||
open: false,
|
||||
pkg: null,
|
||||
docInput: '',
|
||||
error: ''
|
||||
});
|
||||
|
||||
// Dialog de pagamento PIX
|
||||
const pixDlg = reactive({
|
||||
open: false,
|
||||
purchase: null,
|
||||
});
|
||||
|
||||
function formatDoc(digits) {
|
||||
const d = String(digits || '').replace(/\D/g, '');
|
||||
if (d.length === 11) return fmtCPF(d);
|
||||
if (d.length === 14) return fmtCNPJ(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
function validateDoc(raw) {
|
||||
const d = String(raw || '').replace(/\D/g, '');
|
||||
if (!d) return { ok: false, error: 'Informe o CPF ou CNPJ do titular.' };
|
||||
if (d.length === 11) {
|
||||
if (!isValidCPF(d)) return { ok: false, error: 'CPF inválido. Confira os dígitos.' };
|
||||
return { ok: true, digits: d };
|
||||
}
|
||||
if (d.length === 14) {
|
||||
if (!isValidCNPJ(d)) return { ok: false, error: 'CNPJ inválido. Confira os dígitos.' };
|
||||
return { ok: true, digits: d };
|
||||
}
|
||||
return { ok: false, error: 'Informe 11 dígitos (CPF) ou 14 (CNPJ).' };
|
||||
}
|
||||
|
||||
function openConfirm(pkg) {
|
||||
confirmDlg.pkg = pkg;
|
||||
confirmDlg.docInput = formatDoc(api.tenantCpfCnpj.value);
|
||||
confirmDlg.error = '';
|
||||
confirmDlg.open = true;
|
||||
}
|
||||
|
||||
async function onConfirmBuy() {
|
||||
const v = validateDoc(confirmDlg.docInput);
|
||||
if (!v.ok) { confirmDlg.error = v.error; return; }
|
||||
confirmDlg.error = '';
|
||||
|
||||
const res = await api.createPurchase(confirmDlg.pkg.id, v.digits);
|
||||
if (!res.ok) {
|
||||
const msg = res.message
|
||||
|| (res.error?.includes('não configurado')
|
||||
? 'Sistema de pagamento não está configurado. Contate o suporte.'
|
||||
: res.error || 'Falha ao criar cobrança');
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: msg, life: 5000 });
|
||||
return;
|
||||
}
|
||||
confirmDlg.open = false;
|
||||
pixDlg.purchase = res.purchase;
|
||||
pixDlg.open = true;
|
||||
}
|
||||
|
||||
function copyPix() {
|
||||
const code = pixDlg.purchase?.asaas_pix_copy_paste;
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code).then(
|
||||
() => toast.add({ severity: 'success', summary: 'Código copiado', life: 1800 }),
|
||||
() => toast.add({ severity: 'error', summary: 'Falha ao copiar', life: 2500 })
|
||||
);
|
||||
}
|
||||
|
||||
// Threshold edit
|
||||
const editingThreshold = ref(false);
|
||||
const thresholdInput = ref(20);
|
||||
|
||||
function startEditThreshold() {
|
||||
thresholdInput.value = api.balance.value?.low_balance_threshold ?? 20;
|
||||
editingThreshold.value = true;
|
||||
}
|
||||
|
||||
async function saveThreshold() {
|
||||
const res = await api.updateLowBalanceThreshold(thresholdInput.value);
|
||||
if (res.ok) {
|
||||
toast.add({ severity: 'success', summary: 'Alerta atualizado', life: 2000 });
|
||||
editingThreshold.value = false;
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Formatters
|
||||
function brl(v) {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(v) || 0);
|
||||
}
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
const kindLabel = {
|
||||
purchase: { label: 'Compra', icon: 'pi pi-shopping-cart', cls: 'text-green-500' },
|
||||
topup_manual: { label: 'Topup manual', icon: 'pi pi-plus-circle', cls: 'text-sky-500' },
|
||||
usage: { label: 'Uso', icon: 'pi pi-send', cls: 'text-orange-500' },
|
||||
refund: { label: 'Estorno', icon: 'pi pi-replay', cls: 'text-amber-500' },
|
||||
adjustment: { label: 'Ajuste', icon: 'pi pi-pencil', cls: 'text-slate-500' }
|
||||
};
|
||||
|
||||
const statusLabel = {
|
||||
pending: { label: 'Aguardando pagamento', cls: 'bg-amber-500/10 text-amber-600' },
|
||||
paid: { label: 'Pago', cls: 'bg-green-500/10 text-green-600' },
|
||||
failed: { label: 'Falhou', cls: 'bg-red-500/10 text-red-600' },
|
||||
expired: { label: 'Expirado', cls: 'bg-slate-500/10 text-slate-600' },
|
||||
cancelled: { label: 'Cancelado', cls: 'bg-slate-500/10 text-slate-600' },
|
||||
refunded: { label: 'Estornado', cls: 'bg-orange-500/10 text-orange-600' }
|
||||
};
|
||||
|
||||
const pendingPurchases = computed(() => api.purchases.value.filter((p) => p.status === 'pending'));
|
||||
|
||||
function reopenPixDialog(purchase) {
|
||||
pixDlg.purchase = purchase;
|
||||
pixDlg.open = true;
|
||||
}
|
||||
|
||||
onMounted(() => { api.loadAll(); });
|
||||
watch(() => tenantStore.activeTenantId, () => { api.loadAll(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#cfg-page-actions" defer>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full"
|
||||
:loading="api.loading.value"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
@click="api.loadAll()"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Saldo atual -->
|
||||
<div
|
||||
class="rounded-md border p-4 flex items-center gap-4 flex-wrap"
|
||||
:class="api.isLow.value && api.currentBalance.value > 0
|
||||
? 'border-amber-500/30 bg-amber-500/5'
|
||||
: api.currentBalance.value === 0
|
||||
? 'border-red-500/30 bg-red-500/5'
|
||||
: 'border-emerald-500/25 bg-emerald-500/5'"
|
||||
>
|
||||
<div class="grid place-items-center w-14 h-14 rounded-md shrink-0" :class="api.currentBalance.value === 0 ? 'bg-red-500/10 text-red-500' : api.isLow.value ? 'bg-amber-500/10 text-amber-500' : 'bg-emerald-500/10 text-emerald-500'">
|
||||
<i class="pi pi-credit-card text-2xl" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Saldo atual</div>
|
||||
<div class="text-3xl font-bold leading-tight">
|
||||
<template v-if="api.loading.value && !api.balance.value">
|
||||
<Skeleton width="8rem" height="2rem" />
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ api.currentBalance.value }} <span class="text-base font-normal text-[var(--text-color-secondary)]">créditos</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
<span v-if="api.balance.value">
|
||||
Comprados: <strong>{{ api.balance.value.lifetime_purchased || 0 }}</strong>
|
||||
· Usados: <strong>{{ api.balance.value.lifetime_used || 0 }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="!editingThreshold">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">
|
||||
Alerta abaixo de <strong>{{ api.balance.value?.low_balance_threshold ?? 20 }}</strong>
|
||||
</span>
|
||||
<Button icon="pi pi-pencil" severity="secondary" text size="small" class="h-7 w-7" v-tooltip.top="'Editar'" @click="startEditThreshold" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<InputNumber v-model="thresholdInput" :min="0" :max="10000" inputClass="!w-20 text-xs" />
|
||||
<Button icon="pi pi-check" size="small" severity="primary" class="h-7 w-7" @click="saveThreshold" />
|
||||
<Button icon="pi pi-times" severity="secondary" text size="small" class="h-7 w-7" @click="editingThreshold = false" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info: só Oficial consome -->
|
||||
<div class="rounded-md border border-sky-500/25 bg-sky-500/5 p-3 flex items-start gap-2 text-xs text-[var(--text-color)]">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5" />
|
||||
<div>
|
||||
Créditos são consumidos apenas no canal <strong>WhatsApp Oficial AgenciaPSI</strong>. O <strong>WhatsApp Pessoal</strong> (via QR code) é gratuito mas tem limitações (celular precisa estar ligado, sem SLA, risco de ban).
|
||||
<br/>
|
||||
<strong>1 crédito = 1 mensagem enviada.</strong> Mensagens recebidas não consomem. Saldo não expira.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ordens pendentes (CTA pra pagar) -->
|
||||
<div v-if="pendingPurchases.length" class="rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="pi pi-clock text-amber-500" />
|
||||
<span class="text-sm font-bold">{{ pendingPurchases.length }} cobrança{{ pendingPurchases.length > 1 ? 's' : '' }} aguardando pagamento</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div v-for="p in pendingPurchases" :key="p.id" class="flex items-center justify-between gap-2 p-2 rounded bg-[var(--surface-card)] text-sm">
|
||||
<span class="truncate">{{ p.package_name }} — {{ p.credits }} créditos · {{ brl(p.amount_brl) }}</span>
|
||||
<Button label="Pagar" icon="pi pi-qrcode" size="small" class="rounded-full" @click="reopenPixDialog(p)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loja de pacotes -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-shopping-cart text-[var(--primary-color)]" />
|
||||
<span class="text-sm font-bold uppercase tracking-wide">Comprar créditos</span>
|
||||
</div>
|
||||
|
||||
<div v-if="api.loading.value && !api.packages.value.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Skeleton v-for="n in 4" :key="n" height="12rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!api.packages.value.length" class="text-center py-8 text-sm text-[var(--text-color-secondary)] italic">
|
||||
Nenhum pacote disponível no momento. Contate o suporte.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div
|
||||
v-for="pkg in api.packages.value"
|
||||
:key="pkg.id"
|
||||
class="relative flex flex-col gap-3 p-4 rounded-md border-2 bg-[var(--surface-card)] transition-all hover:shadow-md"
|
||||
:class="pkg.is_featured ? 'border-emerald-500 shadow-[0_0_0_3px_rgba(34,197,94,0.1)]' : 'border-[var(--surface-border)] hover:border-indigo-300'"
|
||||
>
|
||||
<span v-if="pkg.is_featured" class="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 rounded-full text-[0.6rem] font-bold uppercase tracking-wide bg-emerald-500 text-white">
|
||||
⭐ Mais vendido
|
||||
</span>
|
||||
<div class="text-sm font-bold text-[var(--text-color)]">{{ pkg.name }}</div>
|
||||
<div v-if="pkg.description" class="text-xs text-[var(--text-color-secondary)]">{{ pkg.description }}</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="text-[1.6rem] font-bold leading-none text-[var(--primary-color)]">{{ pkg.credits }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">mensagens</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5 mt-auto">
|
||||
<div class="text-2xl font-bold">{{ brl(pkg.price_brl) }}</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">{{ brl(pkg.price_brl / pkg.credits) }} / mensagem</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Comprar"
|
||||
icon="pi pi-shopping-cart"
|
||||
:severity="pkg.is_featured ? 'primary' : 'secondary'"
|
||||
:outlined="!pkg.is_featured"
|
||||
class="rounded-full"
|
||||
:loading="api.creating.value"
|
||||
@click="openConfirm(pkg)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Histórico de compras -->
|
||||
<div v-if="api.purchases.value.length" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i class="pi pi-receipt text-[var(--primary-color)]" />
|
||||
<span class="text-sm font-bold uppercase tracking-wide">Histórico de compras</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 max-h-[360px] overflow-y-auto">
|
||||
<div
|
||||
v-for="p in api.purchases.value"
|
||||
:key="p.id"
|
||||
class="flex items-center gap-2 p-2 rounded-md border border-[var(--surface-border)] text-sm"
|
||||
>
|
||||
<span class="inline-flex items-center px-1.5 py-px rounded text-[0.65rem] font-bold uppercase shrink-0" :class="statusLabel[p.status]?.cls">
|
||||
{{ statusLabel[p.status]?.label || p.status }}
|
||||
</span>
|
||||
<span class="flex-1 truncate">{{ p.package_name }} — {{ p.credits }} créditos</span>
|
||||
<span class="font-semibold">{{ brl(p.amount_brl) }}</span>
|
||||
<span class="text-xs text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(p.paid_at || p.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extrato -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i class="pi pi-list text-[var(--primary-color)]" />
|
||||
<span class="text-sm font-bold uppercase tracking-wide">Extrato (50 últimos)</span>
|
||||
</div>
|
||||
<div v-if="!api.transactions.value.length" class="text-xs text-[var(--text-color-secondary)] italic text-center py-4">
|
||||
Nenhuma transação ainda.
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-0.5 max-h-[300px] overflow-y-auto text-xs">
|
||||
<div
|
||||
v-for="tx in api.transactions.value"
|
||||
:key="tx.id"
|
||||
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-2 py-1.5 px-2 rounded hover:bg-[var(--surface-hover)]"
|
||||
>
|
||||
<i :class="[kindLabel[tx.kind]?.icon || 'pi pi-circle', kindLabel[tx.kind]?.cls || '']" />
|
||||
<span>{{ kindLabel[tx.kind]?.label || tx.kind }}<span v-if="tx.note" class="ml-1 text-[var(--text-color-secondary)]">— {{ tx.note }}</span></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)]">{{ fmtDate(tx.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de confirmação (CPF/CNPJ + resumo) -->
|
||||
<Dialog
|
||||
v-model:visible="confirmDlg.open"
|
||||
:header="`Comprar ${confirmDlg.pkg?.name || 'pacote'}`"
|
||||
modal
|
||||
:style="{ width: 'min(440px, 95vw)' }"
|
||||
@hide="confirmDlg.error = ''"
|
||||
>
|
||||
<div v-if="confirmDlg.pkg" class="flex flex-col gap-3">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs uppercase tracking-wide text-[var(--text-color-secondary)] font-semibold">{{ confirmDlg.pkg.name }}</span>
|
||||
<span class="text-sm">{{ confirmDlg.pkg.credits }} créditos (mensagens WhatsApp)</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-[var(--primary-color)]">{{ brl(confirmDlg.pkg.price_brl) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">
|
||||
CPF ou CNPJ do titular <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
v-model="confirmDlg.docInput"
|
||||
placeholder="000.000.000-00"
|
||||
:invalid="!!confirmDlg.error"
|
||||
@input="confirmDlg.error = ''"
|
||||
@blur="confirmDlg.docInput = formatDoc(confirmDlg.docInput)"
|
||||
@keydown.enter="onConfirmBuy"
|
||||
maxlength="20"
|
||||
/>
|
||||
<small v-if="confirmDlg.error" class="text-red-500">{{ confirmDlg.error }}</small>
|
||||
<small v-else class="text-[var(--text-color-secondary)]">
|
||||
Exigido pelo gateway (Asaas). Fica salvo no seu tenant pras próximas compras.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-[var(--surface-ground)] p-2 text-xs text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-info-circle mr-1 text-sky-500" />
|
||||
Ao confirmar, geramos um QR Code PIX. Os créditos aparecem automaticamente após o pagamento (poucos segundos).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" text @click="confirmDlg.open = false" />
|
||||
<Button
|
||||
label="Gerar cobrança PIX"
|
||||
icon="pi pi-qrcode"
|
||||
:loading="api.creating.value"
|
||||
@click="onConfirmBuy"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Dialog PIX -->
|
||||
<Dialog
|
||||
v-model:visible="pixDlg.open"
|
||||
header="Pagamento PIX"
|
||||
modal
|
||||
:style="{ width: 'min(420px, 95vw)' }"
|
||||
>
|
||||
<div v-if="pixDlg.purchase" class="flex flex-col gap-3">
|
||||
<div class="text-center">
|
||||
<div class="text-xs uppercase text-[var(--text-color-secondary)] font-semibold tracking-wide mb-1">Valor total</div>
|
||||
<div class="text-3xl font-bold text-[var(--primary-color)]">{{ brl(pixDlg.purchase.amount_brl) }}</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">{{ pixDlg.purchase.package_name }} · {{ pixDlg.purchase.credits }} créditos</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pixDlg.purchase.asaas_pix_qrcode" class="flex flex-col items-center gap-2">
|
||||
<img :src="`data:image/png;base64,${pixDlg.purchase.asaas_pix_qrcode}`" alt="QR Code PIX" class="w-48 h-48 rounded-md border border-[var(--surface-border)]" />
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Aponte a câmera do banco pro QR Code</span>
|
||||
</div>
|
||||
|
||||
<div v-if="pixDlg.purchase.asaas_pix_copy_paste" class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Ou copie o código PIX</span>
|
||||
<div class="flex gap-1">
|
||||
<InputText :modelValue="pixDlg.purchase.asaas_pix_copy_paste" readonly class="flex-1 text-xs font-mono" />
|
||||
<Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar'" @click="copyPix" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pixDlg.purchase.asaas_payment_link" class="text-center">
|
||||
<a :href="pixDlg.purchase.asaas_payment_link" target="_blank" rel="noopener" class="text-xs text-[var(--primary-color)] underline">
|
||||
Ou pague com cartão (link externo)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-[var(--surface-ground)] p-2 text-xs text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-info-circle mr-1 text-sky-500" />
|
||||
Assim que o pagamento for confirmado (PIX é instantâneo), seu saldo é atualizado automaticamente. Pode fechar essa janela — a confirmação aparece aqui quando concluída.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" text @click="pixDlg.open = false" />
|
||||
<Button label="Já paguei, atualizar" icon="pi pi-refresh" @click="api.loadAll(); pixDlg.open = false;" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,348 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Lembretes automáticos de sessão (CRM Grupo 2.4)
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useSessionReminders } from '@/composables/useSessionReminders';
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
const api = useSessionReminders();
|
||||
|
||||
const VARIABLES = [
|
||||
{ key: '{{nome_paciente}}', label: 'Nome do paciente' },
|
||||
{ key: '{{data_sessao}}', label: 'Data (ex: 22 de abril)' },
|
||||
{ key: '{{hora_sessao}}', label: 'Hora (ex: 14:30)' },
|
||||
{ key: '{{modalidade}}', label: 'Online / presencial' },
|
||||
{ key: '{{nome_clinica}}', label: 'Nome da clínica' }
|
||||
];
|
||||
|
||||
const template24Ref = ref(null);
|
||||
const template2Ref = ref(null);
|
||||
|
||||
function insertVariable(field, variable) {
|
||||
const el = field === '24h' ? template24Ref.value?.$el?.querySelector('textarea') : template2Ref.value?.$el?.querySelector('textarea');
|
||||
if (!el) return;
|
||||
const start = el.selectionStart ?? 0;
|
||||
const end = el.selectionEnd ?? 0;
|
||||
const key = field === '24h' ? 'template_24h' : 'template_2h';
|
||||
const before = api.settings.value[key].slice(0, start);
|
||||
const after = api.settings.value[key].slice(end);
|
||||
api.settings.value[key] = before + variable + after;
|
||||
setTimeout(() => {
|
||||
el.focus();
|
||||
const pos = start + variable.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const res = await api.save();
|
||||
if (res.ok) toast.add({ severity: 'success', summary: 'Salvo', life: 2000 });
|
||||
else toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
|
||||
async function onRunNow() {
|
||||
const res = await api.runNow();
|
||||
if (res.ok) {
|
||||
const s = res.stats || {};
|
||||
const sent = s.sent || 0;
|
||||
const considered = s.considered || 0;
|
||||
const skippedTotal = Object.values(s.skipped || {}).reduce((a, b) => a + b, 0);
|
||||
toast.add({
|
||||
severity: sent > 0 ? 'success' : 'info',
|
||||
summary: `Execução concluída`,
|
||||
detail: `Considerados: ${considered} · Enviados: ${sent} · Pulados: ${skippedTotal}`,
|
||||
life: 5000
|
||||
});
|
||||
await api.load();
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Preview renderizado
|
||||
const previewVars = {
|
||||
nome_paciente: 'Maria Silva',
|
||||
data_sessao: '22 de abril',
|
||||
hora_sessao: '14:30',
|
||||
modalidade: 'presencial',
|
||||
nome_clinica: 'Clínica Exemplo'
|
||||
};
|
||||
|
||||
function renderPreview(tpl) {
|
||||
return String(tpl || '')
|
||||
.replaceAll('{{nome_paciente}}', previewVars.nome_paciente)
|
||||
.replaceAll('{{data_sessao}}', previewVars.data_sessao)
|
||||
.replaceAll('{{hora_sessao}}', previewVars.hora_sessao)
|
||||
.replaceAll('{{modalidade}}', previewVars.modalidade)
|
||||
.replaceAll('{{nome_clinica}}', previewVars.nome_clinica);
|
||||
}
|
||||
|
||||
const preview24 = computed(() => renderPreview(api.settings.value.template_24h));
|
||||
const preview2h = computed(() => renderPreview(api.settings.value.template_2h));
|
||||
|
||||
// Stats
|
||||
const recentSent = computed(() => api.recentLogs.value.filter((l) => l.provider !== 'skipped').length);
|
||||
const recentSkipped = computed(() => api.recentLogs.value.filter((l) => l.provider === 'skipped').length);
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatPhone(p) {
|
||||
const s = String(p || '').replace(/\D/g, '');
|
||||
if (s.length === 13 && s.startsWith('55')) return `(${s.slice(2, 4)}) ${s.slice(4, 9)}-${s.slice(9)}`;
|
||||
return p;
|
||||
}
|
||||
|
||||
onMounted(() => { api.load(); });
|
||||
watch(() => tenantStore.activeTenantId, () => { api.load(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#cfg-page-actions" defer>
|
||||
<Button
|
||||
label="Testar agora"
|
||||
icon="pi pi-bolt"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:loading="api.saving.value"
|
||||
v-tooltip.bottom="'Roda o processo manualmente (útil pra testar ou fazer catch-up)'"
|
||||
@click="onRunNow"
|
||||
/>
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:loading="api.saving.value"
|
||||
:disabled="api.loading.value"
|
||||
@click="onSave"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full"
|
||||
:loading="api.loading.value"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
@click="api.load()"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Info -->
|
||||
<div class="rounded-md border border-sky-500/25 bg-sky-500/5 p-3 flex items-start gap-2 text-xs text-[var(--text-color)]">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5" />
|
||||
<div>
|
||||
<strong>Como funciona:</strong> a cada 15 minutos o sistema verifica sessões agendadas que estão chegando em
|
||||
<strong>24h ± 15min</strong> ou <strong>2h ± 15min</strong>, e envia WhatsApp pro paciente usando o canal ativo.
|
||||
Respeita opt-out (LGPD), quiet hours, e só envia uma vez por sessão+tipo.
|
||||
<br/>
|
||||
<strong>Pré-requisito:</strong> WhatsApp ativo (Oficial AgenciaPSI ou Pessoal) + paciente com telefone cadastrado.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle principal + stats -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<ToggleSwitch v-model="api.settings.value.enabled" />
|
||||
<span class="text-sm font-semibold">
|
||||
{{ api.settings.value.enabled ? 'Lembretes ativados' : 'Lembretes desativados' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ recentSent }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Enviados (últimos 30)</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-slate-500">{{ recentSkipped }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Pulados (opt-out, duplicata, etc)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lead times + templates -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-clock text-[var(--primary-color)]" />
|
||||
<span class="text-sm font-bold uppercase tracking-wide">Lembretes a enviar</span>
|
||||
</div>
|
||||
|
||||
<!-- 24h -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch v-model="api.settings.value.send_24h" :disabled="!api.settings.value.enabled" />
|
||||
<span class="text-sm font-semibold">24h antes da sessão</span>
|
||||
</div>
|
||||
<div v-if="api.settings.value.send_24h" class="flex flex-col gap-2 pl-10">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
v-for="v in VARIABLES"
|
||||
:key="v.key"
|
||||
:label="v.key"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="!text-[0.7rem] !py-0.5 !px-1.5 font-mono"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
v-tooltip.top="v.label"
|
||||
@click="insertVariable('24h', v.key)"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
ref="template24Ref"
|
||||
v-model="api.settings.value.template_24h"
|
||||
rows="3"
|
||||
autoResize
|
||||
class="w-full text-sm"
|
||||
:maxlength="2000"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
/>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] p-2 rounded bg-[var(--surface-ground)] italic">
|
||||
<strong class="not-italic">Preview:</strong> {{ preview24 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2h -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch v-model="api.settings.value.send_2h" :disabled="!api.settings.value.enabled" />
|
||||
<span class="text-sm font-semibold">2h antes da sessão</span>
|
||||
</div>
|
||||
<div v-if="api.settings.value.send_2h" class="flex flex-col gap-2 pl-10">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
v-for="v in VARIABLES"
|
||||
:key="v.key"
|
||||
:label="v.key"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="!text-[0.7rem] !py-0.5 !px-1.5 font-mono"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
v-tooltip.top="v.label"
|
||||
@click="insertVariable('2h', v.key)"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
ref="template2Ref"
|
||||
v-model="api.settings.value.template_2h"
|
||||
rows="3"
|
||||
autoResize
|
||||
class="w-full text-sm"
|
||||
:maxlength="2000"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
/>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] p-2 rounded bg-[var(--surface-ground)] italic">
|
||||
<strong class="not-italic">Preview:</strong> {{ preview2h }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quiet hours + LGPD -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-moon text-[var(--primary-color)]" />
|
||||
<span class="text-sm font-bold uppercase tracking-wide">Horário silencioso & compliance</span>
|
||||
</div>
|
||||
|
||||
<!-- Quiet hours -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch v-model="api.settings.value.quiet_hours_enabled" :disabled="!api.settings.value.enabled" />
|
||||
<span class="text-sm font-semibold">Não enviar em horário silencioso</span>
|
||||
</div>
|
||||
<div v-if="api.settings.value.quiet_hours_enabled" class="flex items-center gap-2 pl-10 flex-wrap">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Das</span>
|
||||
<input
|
||||
type="time"
|
||||
v-model="api.settings.value.quiet_hours_start"
|
||||
class="text-sm px-2 py-1 rounded border border-[var(--surface-border)] bg-[var(--surface-card)]"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">até</span>
|
||||
<input
|
||||
type="time"
|
||||
v-model="api.settings.value.quiet_hours_end"
|
||||
class="text-sm px-2 py-1 rounded border border-[var(--surface-border)] bg-[var(--surface-card)]"
|
||||
:disabled="!api.settings.value.enabled"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-color-secondary)] italic">(fuso São Paulo)</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] pl-10">
|
||||
Sessões que caem dentro do horário silencioso não disparam o lembrete — mesmo que "24h/2h antes" batam nessa janela.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opt-out -->
|
||||
<div class="flex flex-col gap-2 pt-2 border-t border-[var(--surface-border)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch v-model="api.settings.value.respect_opt_out" :disabled="!api.settings.value.enabled" />
|
||||
<span class="text-sm font-semibold">Respeitar opt-out (LGPD)</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] pl-10">
|
||||
Pacientes que enviaram "PARAR" não recebem lembretes. <strong>Recomendado manter ativado</strong>
|
||||
— LGPD Art. 18 §2 (direito de oposição). Desligar só com base legal específica.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log recente -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-history text-[var(--primary-color)]" />
|
||||
<span class="text-sm font-bold uppercase tracking-wide">Histórico recente (30 últimos)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="api.loading.value" class="flex flex-col gap-1.5">
|
||||
<Skeleton v-for="n in 4" :key="n" height="2.5rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!api.recentLogs.value.length" class="text-xs text-[var(--text-color-secondary)] italic text-center py-4">
|
||||
Nenhum lembrete processado ainda. Clique em "Testar agora" pra executar manualmente.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-1 max-h-[360px] overflow-y-auto">
|
||||
<div
|
||||
v-for="log in api.recentLogs.value"
|
||||
:key="log.id"
|
||||
class="flex items-center gap-2 p-2 rounded-md border text-xs"
|
||||
:class="log.provider === 'skipped'
|
||||
? 'border-[var(--surface-border)] bg-[var(--surface-ground)] opacity-80'
|
||||
: 'border-green-500/25 bg-green-500/5'"
|
||||
>
|
||||
<i
|
||||
class="pi shrink-0"
|
||||
:class="log.provider === 'skipped' ? 'pi-forward text-[var(--text-color-secondary)]' : 'pi-send text-green-500'"
|
||||
/>
|
||||
<span class="font-semibold text-[0.7rem] uppercase px-1.5 py-px rounded shrink-0" :class="log.reminder_type === '24h' ? 'bg-indigo-500/10 text-indigo-500' : 'bg-amber-500/10 text-amber-500'">
|
||||
{{ log.reminder_type }}
|
||||
</span>
|
||||
<span v-if="log.to_phone" class="font-mono">{{ formatPhone(log.to_phone) }}</span>
|
||||
<span v-if="log.provider === 'skipped'" class="italic text-[var(--text-color-secondary)]">
|
||||
{{ log.skip_reason }}
|
||||
</span>
|
||||
<span v-else class="text-green-600">enviado</span>
|
||||
<span class="ml-auto text-[var(--text-color-secondary)]">{{ fmtDate(log.sent_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,12 +16,14 @@
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
const router = useRouter();
|
||||
|
||||
// ── Contexto ──────────────────────────────────────────────────
|
||||
const userId = ref(null);
|
||||
@@ -138,9 +140,12 @@ onMounted(async () => {
|
||||
<!-- Header -->
|
||||
<Card>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-box text-xl" />
|
||||
Recursos Extras
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-box text-xl" />
|
||||
Recursos Extras
|
||||
</div>
|
||||
<Button label="Ver extrato" icon="pi pi-list" severity="secondary" outlined size="small" @click="router.push({ name: 'ConfiguracoesRecursosExtrasExtrato' })" />
|
||||
</div>
|
||||
</template>
|
||||
<template #subtitle>Amplie as funcionalidades da sua clínica com recursos adicionais.</template>
|
||||
|
||||
@@ -170,7 +170,13 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<Teleport to="#cfg-page-actions" defer>
|
||||
<router-link to="/configuracoes/whatsapp">
|
||||
<Button label="Trocar canal" icon="pi pi-arrow-left" severity="secondary" outlined size="small" class="rounded-full" />
|
||||
</router-link>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Loading inicial ───────────────────────────────────────────── -->
|
||||
<div v-if="store.loadingMyChannel" class="flex justify-center py-12">
|
||||
<ProgressSpinner style="width: 40px; height: 40px" />
|
||||
@@ -253,13 +259,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Provedor</span>
|
||||
<span>Twilio WhatsApp Business</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Subconta ID</span>
|
||||
<span class="font-mono text-xs text-(--text-color-secondary)">
|
||||
{{ store.myChannel?.twilio_subaccount_sid?.slice(0, 20) ?? '—' }}…
|
||||
</span>
|
||||
<span>WhatsApp Business Oficial — AgenciaPSI</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Ativado em</span>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — WhatsApp Chooser (landing)
|
||||
|--------------------------------------------------------------------------
|
||||
| Apresenta os 2 canais WhatsApp disponíveis e gerencia exclusividade.
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const switching = ref(false);
|
||||
const activeChannel = ref(null); // row de notification_channels (provider, is_active, etc) ou null
|
||||
|
||||
const activeProvider = computed(() => {
|
||||
if (!activeChannel.value) return null;
|
||||
return activeChannel.value.provider; // 'twilio' | 'evolution'
|
||||
});
|
||||
|
||||
async function loadChannel() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
activeChannel.value = null;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('notification_channels')
|
||||
.select('id, provider, is_active, connection_status, twilio_phone_number, credentials, updated_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('channel', 'whatsapp')
|
||||
.is('deleted_at', null)
|
||||
.maybeSingle();
|
||||
activeChannel.value = data || null;
|
||||
} catch (e) {
|
||||
console.error('[whatsapp-chooser] load:', e?.message);
|
||||
activeChannel.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goSetup(provider) {
|
||||
if (provider === 'evolution') router.push('/configuracoes/whatsapp-pessoal');
|
||||
else if (provider === 'twilio') router.push('/configuracoes/whatsapp-oficial');
|
||||
}
|
||||
|
||||
async function handleChoose(provider) {
|
||||
// Se nao tem canal ativo, segue direto pro setup
|
||||
if (!activeProvider.value) {
|
||||
goSetup(provider);
|
||||
return;
|
||||
}
|
||||
// Mesmo provider → so navega
|
||||
if (activeProvider.value === provider) {
|
||||
goSetup(provider);
|
||||
return;
|
||||
}
|
||||
// Provider diferente → confirmar troca
|
||||
const from = activeProvider.value === 'twilio' ? 'Oficial AgenciaPSI' : 'Pessoal';
|
||||
const to = provider === 'twilio' ? 'Oficial AgenciaPSI' : 'Pessoal';
|
||||
confirm.require({
|
||||
message: `Você está usando o WhatsApp ${from}. Trocar pro ${to} vai desativar o canal atual e você vai precisar reconfigurar. Continuar?`,
|
||||
header: 'Trocar canal WhatsApp',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Trocar',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptClass: 'p-button-warning',
|
||||
accept: async () => {
|
||||
await deactivateCurrent();
|
||||
goSetup(provider);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deactivateCurrent() {
|
||||
if (!activeChannel.value) return;
|
||||
switching.value = true;
|
||||
try {
|
||||
// 1ª tentativa: UPDATE direto (funciona se owner_id = user atual)
|
||||
const { error } = await supabase
|
||||
.from('notification_channels')
|
||||
.update({ is_active: false, deleted_at: new Date().toISOString() })
|
||||
.eq('id', activeChannel.value.id);
|
||||
|
||||
if (error) {
|
||||
// Fallback: canal criado pelo SaaS admin (RLS bloqueia update do tenant).
|
||||
// Usa edge function com service_role pra deativar.
|
||||
const { data: fnData, error: fnErr } = await supabase.functions.invoke('deactivate-notification-channel', {
|
||||
body: { channel_id: activeChannel.value.id }
|
||||
});
|
||||
if (fnErr || !fnData?.ok) {
|
||||
// Nem via edge function. Segue em frente — o setup do novo provider cuida.
|
||||
console.warn('[chooser] deactivate failed:', error.message, fnErr?.message);
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Canal anterior não foi desativado',
|
||||
detail: 'Prossiga com o novo canal. Se o envio ainda usar o antigo, contate o suporte.',
|
||||
life: 4500
|
||||
});
|
||||
activeChannel.value = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
activeChannel.value = null;
|
||||
toast.add({ severity: 'info', summary: 'Canal anterior desativado', life: 2500 });
|
||||
} catch (e) {
|
||||
console.warn('[chooser] deactivate unexpected error:', e?.message);
|
||||
activeChannel.value = null; // limpa pra UI não ficar travada
|
||||
} finally {
|
||||
switching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const providerLabel = computed(() => {
|
||||
if (!activeProvider.value) return null;
|
||||
return activeProvider.value === 'twilio' ? 'WhatsApp Oficial AgenciaPSI' : 'WhatsApp Pessoal';
|
||||
});
|
||||
|
||||
const providerIcon = computed(() => activeProvider.value === 'twilio' ? 'pi-verified' : 'pi-mobile');
|
||||
|
||||
const connectionDot = computed(() => {
|
||||
const s = activeChannel.value?.connection_status;
|
||||
const active = activeChannel.value?.is_active;
|
||||
if (!active) return { cls: 'bg-slate-400', label: 'Inativo' };
|
||||
if (s === 'open' || s === 'connected') return { cls: 'bg-green-500', label: 'Conectado' };
|
||||
if (s === 'close' || s === 'disconnected') return { cls: 'bg-red-500', label: 'Desconectado' };
|
||||
return { cls: 'bg-amber-400', label: 'Aguardando' };
|
||||
});
|
||||
|
||||
onMounted(() => { loadChannel(); });
|
||||
watch(() => tenantStore.activeTenantId, () => { loadChannel(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
|
||||
<Teleport to="#cfg-page-actions" defer>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full"
|
||||
:loading="loading"
|
||||
v-tooltip.bottom="'Recarregar'"
|
||||
@click="loadChannel"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Skeleton inicial -->
|
||||
<div v-if="loading" class="flex flex-col gap-3">
|
||||
<Skeleton height="5rem" class="rounded-md" />
|
||||
<Skeleton height="14rem" class="rounded-md" />
|
||||
<Skeleton height="14rem" class="rounded-md" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Canal atual ativo -->
|
||||
<div
|
||||
v-if="activeProvider"
|
||||
class="rounded-md border p-4 flex items-center gap-3"
|
||||
:class="activeProvider === 'twilio' ? 'border-emerald-500/25 bg-emerald-500/5' : 'border-indigo-500/25 bg-indigo-500/5'"
|
||||
>
|
||||
<div
|
||||
class="grid place-items-center w-11 h-11 rounded-md shrink-0"
|
||||
:class="activeProvider === 'twilio' ? 'bg-emerald-500/15 text-emerald-500' : 'bg-indigo-500/15 text-indigo-500'"
|
||||
>
|
||||
<i :class="['pi', providerIcon, 'text-xl']" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-bold text-[var(--text-color)]">Canal ativo: {{ providerLabel }}</span>
|
||||
<span class="inline-flex items-center gap-1 text-[0.7rem] font-semibold">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="connectionDot.cls" />
|
||||
{{ connectionDot.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="activeProvider === 'twilio' && activeChannel?.twilio_phone_number" class="text-xs text-[var(--text-color-secondary)] mt-0.5 font-mono">
|
||||
{{ activeChannel.twilio_phone_number }}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||||
Escolha outro canal abaixo pra trocar, ou clique em "Gerenciar" pra configurar o atual.
|
||||
</div>
|
||||
</div>
|
||||
<router-link :to="activeProvider === 'twilio' ? '/configuracoes/whatsapp-oficial' : '/configuracoes/whatsapp-pessoal'">
|
||||
<Button
|
||||
label="Gerenciar"
|
||||
icon="pi pi-cog"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Título -->
|
||||
<div class="text-xs font-bold uppercase tracking-wide text-[var(--text-color-secondary)] opacity-65 mt-1">
|
||||
{{ activeProvider ? 'Outros canais disponíveis' : 'Escolha como conectar seu WhatsApp' }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<!-- Cartão: WhatsApp Oficial AgenciaPSI (Twilio) -->
|
||||
<button
|
||||
class="flex flex-col text-left gap-3 p-5 rounded-md border-2 bg-[var(--surface-card)] cursor-pointer transition-all hover:shadow-md"
|
||||
:class="activeProvider === 'twilio'
|
||||
? 'border-emerald-500 shadow-[0_0_0_4px_rgba(34,197,94,0.12)]'
|
||||
: 'border-[var(--surface-border)] hover:border-emerald-400/50'"
|
||||
:disabled="switching"
|
||||
@click="handleChoose('twilio')"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="grid place-items-center w-12 h-12 rounded-md shrink-0 bg-emerald-500/10 text-emerald-500">
|
||||
<i class="pi pi-verified text-2xl" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-[1rem] font-bold text-[var(--text-color)]">WhatsApp Oficial AgenciaPSI</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.65rem] font-bold bg-emerald-500 text-white">⭐ Recomendado</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||||
Canal oficial gerenciado pela AgenciaPSI. Máxima confiabilidade e compliance.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col gap-1.5 text-xs">
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-check-circle text-emerald-500 mt-0.5" />
|
||||
<span><strong>Zero risco de banimento</strong> — aprovado pela Meta, API oficial</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-check-circle text-emerald-500 mt-0.5" />
|
||||
<span><strong>Número exclusivo</strong> da clínica, sem celular intermediário</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-check-circle text-emerald-500 mt-0.5" />
|
||||
<span><strong>Conexão gerenciada</strong> — 99,9% uptime, zero manutenção</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-check-circle text-emerald-500 mt-0.5" />
|
||||
<span><strong>Compliance LGPD + auditoria</strong> completa</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-dollar text-emerald-500 mt-0.5" />
|
||||
<span>Consome <strong>créditos por mensagem</strong> enviada</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-auto pt-2 border-t border-[var(--surface-border)] flex items-center justify-between">
|
||||
<span class="text-[0.7rem] text-[var(--text-color-secondary)]">
|
||||
Ideal pra <strong>clínicas</strong> e alto volume
|
||||
</span>
|
||||
<span class="text-[0.72rem] font-bold text-emerald-500 flex items-center gap-1">
|
||||
{{ activeProvider === 'twilio' ? 'Ativo' : 'Ativar' }} <i class="pi pi-arrow-right text-[0.6rem]" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Cartão: WhatsApp Pessoal (Evolution) -->
|
||||
<button
|
||||
class="flex flex-col text-left gap-3 p-5 rounded-md border-2 bg-[var(--surface-card)] cursor-pointer transition-all hover:shadow-md"
|
||||
:class="activeProvider === 'evolution'
|
||||
? 'border-indigo-500 shadow-[0_0_0_4px_rgba(99,102,241,0.12)]'
|
||||
: 'border-[var(--surface-border)] hover:border-indigo-400/50'"
|
||||
:disabled="switching"
|
||||
@click="handleChoose('evolution')"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="grid place-items-center w-12 h-12 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-mobile text-2xl" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-[1rem] font-bold text-[var(--text-color)]">WhatsApp Pessoal</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.65rem] font-bold bg-slate-500/10 text-slate-600">Gratuito</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||||
Conecta seu WhatsApp pessoal via QR code. Sem custo por mensagem.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col gap-1.5 text-xs">
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-check-circle text-indigo-500 mt-0.5" />
|
||||
<span><strong>Gratuito</strong> — sem custo por mensagem</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-check-circle text-indigo-500 mt-0.5" />
|
||||
<span>Setup rápido via <strong>QR code</strong></span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-exclamation-triangle text-amber-500 mt-0.5" />
|
||||
<span><strong>Uso informal</strong> — Meta pode restringir/banir o número</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-exclamation-triangle text-amber-500 mt-0.5" />
|
||||
<span>Depende do <strong>celular ligado e com internet</strong></span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="pi pi-info-circle text-amber-500 mt-0.5" />
|
||||
<span>Sem SLA, sem garantia de uptime</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-auto pt-2 border-t border-[var(--surface-border)] flex items-center justify-between">
|
||||
<span class="text-[0.7rem] text-[var(--text-color-secondary)]">
|
||||
Ideal pra <strong>uso pessoal</strong> e baixo volume
|
||||
</span>
|
||||
<span class="text-[0.72rem] font-bold text-indigo-500 flex items-center gap-1">
|
||||
{{ activeProvider === 'evolution' ? 'Ativo' : 'Ativar' }} <i class="pi pi-arrow-right text-[0.6rem]" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info geral -->
|
||||
<div class="rounded-md border border-sky-500/25 bg-sky-500/5 p-3 flex items-start gap-2 text-xs text-[var(--text-color)]">
|
||||
<i class="pi pi-info-circle text-sky-500 mt-0.5" />
|
||||
<div>
|
||||
<strong>Só um canal ativo por vez.</strong>
|
||||
Trocar de canal desativa o outro — você vai precisar reconfigurar (conectar QR novamente no Pessoal, ou ativar o número no Oficial).
|
||||
Histórico de conversas é <strong>preservado</strong> em ambos os casos.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,15 +16,24 @@
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// Detecta area (admin clinica vs therapist) pra direcionar corretamente
|
||||
function goConversas() {
|
||||
const role = tenantStore.activeRole || tenantStore.role || '';
|
||||
const isTherapistArea = role === 'therapist';
|
||||
router.push(isTherapistArea ? '/therapist/conversas' : '/admin/conversas');
|
||||
}
|
||||
|
||||
// ── Contexto ──────────────────────────────────────────────────
|
||||
const userId = ref(null);
|
||||
const tenantId = ref(null); // tenant_id real (da tabela tenants)
|
||||
@@ -46,6 +55,7 @@ async function loadUser() {
|
||||
|
||||
const credentials = ref({ api_url: '', api_key: '', instance_name: '' });
|
||||
const hasCredentials = ref(false);
|
||||
const channelRecord = ref(null); // row de notification_channels pra sincronizar connection_status
|
||||
const connectionStatus = ref(null); // 'open' | 'close' | 'connecting' | null
|
||||
const connectionLoading = ref(false);
|
||||
|
||||
@@ -95,6 +105,7 @@ async function loadCredentials() {
|
||||
instance_name: data.credentials.instance_name || ''
|
||||
};
|
||||
hasCredentials.value = true;
|
||||
channelRecord.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,8 +119,26 @@ 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 retorna [{ instance: { instanceName, status } }], v2 retorna [{ 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 SaaS admin ver status atualizado na listagem
|
||||
if (channelRecord.value?.id) {
|
||||
const dbStatus = rawState === 'open' ? 'connected' : rawState === 'connecting' ? 'connecting' : 'disconnected';
|
||||
if (channelRecord.value.connection_status !== dbStatus) {
|
||||
await supabase
|
||||
.from('notification_channels')
|
||||
.update({ connection_status: dbStatus, last_health_check: new Date().toISOString() })
|
||||
.eq('id', channelRecord.value.id);
|
||||
channelRecord.value.connection_status = dbStatus;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
connectionStatus.value = 'close';
|
||||
toast.add({
|
||||
@@ -133,16 +162,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) {
|
||||
// Instância pode já estar conectada
|
||||
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();
|
||||
@@ -177,6 +219,58 @@ function openQrDialog() {
|
||||
fetchQrCode();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Inbox (Fase 5a) — configurar webhook MESSAGES_UPSERT na Evolution
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
const inboxProvisioning = ref(false);
|
||||
const inboxWebhookUrl = ref('');
|
||||
|
||||
// Resolve URL pública do Supabase pra Evolution conseguir alcançar a edge function.
|
||||
// Em dev local, localhost de dentro do container Evolution não funciona — precisa host.docker.internal.
|
||||
function resolveSupabasePublicUrl() {
|
||||
const envUrl = import.meta.env.VITE_SUPABASE_URL || '';
|
||||
try {
|
||||
const u = new URL(envUrl);
|
||||
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
|
||||
u.hostname = 'host.docker.internal';
|
||||
return u.toString().replace(/\/+$/, '');
|
||||
}
|
||||
} catch {}
|
||||
return envUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async function provisionInbox() {
|
||||
if (!tenantId.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Tenant inválido', life: 3000 });
|
||||
return;
|
||||
}
|
||||
if (!credentials.value?.api_url || !credentials.value?.api_key || !credentials.value?.instance_name) {
|
||||
toast.add({ severity: 'warn', summary: 'Salve as credenciais antes', detail: 'URL, API key e nome da instância são obrigatórios.', life: 4000 });
|
||||
return;
|
||||
}
|
||||
inboxProvisioning.value = true;
|
||||
try {
|
||||
const publicUrl = resolveSupabasePublicUrl();
|
||||
const { data, error } = await supabase.functions.invoke('evolution-webhook-provision', {
|
||||
body: {
|
||||
tenant_id: tenantId.value,
|
||||
api_url: credentials.value.api_url,
|
||||
api_key: credentials.value.api_key,
|
||||
instance_name: credentials.value.instance_name,
|
||||
public_url: publicUrl
|
||||
}
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data?.ok) throw new Error(data?.error || 'Falha no provisionamento');
|
||||
inboxWebhookUrl.value = data.webhook_url;
|
||||
toast.add({ severity: 'success', summary: 'Inbox conectada!', detail: 'Mensagens recebidas agora vão aparecer em /admin/conversas.', life: 5000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Falha ao configurar inbox', detail: err?.message || String(err), life: 6000 });
|
||||
} finally {
|
||||
inboxProvisioning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeQrDialog() {
|
||||
qrDialog.value = false;
|
||||
clearQrTimer();
|
||||
@@ -509,6 +603,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#cfg-page-actions" defer>
|
||||
<router-link to="/configuracoes/whatsapp">
|
||||
<Button label="Trocar canal" icon="pi pi-arrow-left" severity="secondary" outlined size="small" class="rounded-full" />
|
||||
</router-link>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Abas -->
|
||||
<Tabs :value="activeTab" @update:value="activeTab = $event">
|
||||
@@ -563,6 +663,28 @@ onBeforeUnmount(() => {
|
||||
e escaneie o QR Code que aparecerá na tela.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inbox / CRM de conversas (Fase 5a) -->
|
||||
<div v-if="connectionStatus === 'open'" class="flex flex-col gap-3 px-4 py-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid place-items-center w-10 h-10 rounded-full bg-blue-100 text-blue-600">
|
||||
<i class="pi pi-inbox text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Inbox de conversas</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Receba respostas dos pacientes direto no sistema.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Conectar inbox" icon="pi pi-link" size="small" :loading="inboxProvisioning" @click="provisionInbox" />
|
||||
<Button icon="pi pi-external-link" severity="secondary" outlined size="small" v-tooltip.bottom="'Abrir Conversas'" @click="goConversas" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="inboxWebhookUrl" class="text-[0.7rem] text-[var(--text-color-secondary)] bg-[var(--surface-card)] border border-[var(--surface-border)] rounded px-2 py-1.5 font-mono break-all">
|
||||
Webhook configurado: {{ inboxWebhookUrl }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/layout/configuracoes/ConfiguracoesWhatsappTemplatesPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
const drawerStore = useConversationDrawerStore();
|
||||
|
||||
const userId = ref(null);
|
||||
const tenantId = ref(null);
|
||||
|
||||
const templates = ref([]);
|
||||
const loading = ref(true);
|
||||
const saving = ref({});
|
||||
const reverting = ref({});
|
||||
|
||||
const EVENT_TYPE_LABELS = {
|
||||
lembrete_sessao: 'Lembrete de sessão',
|
||||
confirmacao_sessao: 'Confirmação de sessão',
|
||||
cancelamento_sessao: 'Cancelamento de sessão',
|
||||
reagendamento: 'Reagendamento',
|
||||
cobranca_pendente: 'Cobrança pendente',
|
||||
boas_vindas_paciente: 'Boas-vindas ao paciente',
|
||||
intake_recebido: 'Triagem recebida',
|
||||
intake_aprovado: 'Triagem aprovada',
|
||||
intake_rejeitado: 'Triagem rejeitada'
|
||||
};
|
||||
const EVENT_SEVERITY = {
|
||||
lembrete_sessao: 'info',
|
||||
confirmacao_sessao: 'success',
|
||||
cancelamento_sessao: 'danger',
|
||||
reagendamento: 'warn',
|
||||
cobranca_pendente: 'warn',
|
||||
boas_vindas_paciente: 'success',
|
||||
intake_recebido: 'info',
|
||||
intake_aprovado: 'success',
|
||||
intake_rejeitado: 'danger'
|
||||
};
|
||||
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' };
|
||||
|
||||
const textareaRefs = ref({});
|
||||
function setTextareaRef(key, el) {
|
||||
if (el) textareaRefs.value[key] = el;
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
const {
|
||||
data: { user }
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
userId.value = user.id;
|
||||
tenantId.value = tenantStore.activeTenantId || user.id;
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
if (!tenantId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data: globals, error: gErr } = await supabase
|
||||
.from('notification_templates')
|
||||
.select('*')
|
||||
.is('tenant_id', null)
|
||||
.eq('channel', 'whatsapp')
|
||||
.eq('is_default', true)
|
||||
.eq('is_active', true)
|
||||
.is('deleted_at', null)
|
||||
.order('domain')
|
||||
.order('event_type');
|
||||
if (gErr) throw gErr;
|
||||
|
||||
const { data: customs, error: cErr } = await supabase
|
||||
.from('notification_templates')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.eq('channel', 'whatsapp')
|
||||
.is('deleted_at', null);
|
||||
if (cErr) throw cErr;
|
||||
|
||||
const customMap = {};
|
||||
for (const c of customs || []) customMap[c.key] = c;
|
||||
|
||||
templates.value = (globals || []).map((g) => {
|
||||
const custom = customMap[g.key];
|
||||
const current = custom?.body_text ?? g.body_text;
|
||||
return {
|
||||
key: g.key,
|
||||
domain: g.domain,
|
||||
event_type: g.event_type,
|
||||
label: EVENT_TYPE_LABELS[g.event_type] || g.event_type,
|
||||
type_severity: EVENT_SEVERITY[g.event_type] || 'secondary',
|
||||
domain_label: DOMAIN_LABEL[g.domain] || g.domain,
|
||||
variables: Array.isArray(g.variables) ? g.variables : [],
|
||||
default_body: g.body_text,
|
||||
id: custom?.id || null,
|
||||
body_text: current,
|
||||
saved_body: current,
|
||||
is_custom: !!custom
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar templates', detail: e.message, life: 4000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function insertVariable(key, variable) {
|
||||
const snippet = `{{${variable}}}`;
|
||||
const tpl = templates.value.find((t) => t.key === key);
|
||||
if (!tpl) return;
|
||||
|
||||
const taWrapper = textareaRefs.value[key];
|
||||
const ta = taWrapper?.$el?.querySelector('textarea') ?? taWrapper;
|
||||
if (ta?.setSelectionRange) {
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? start;
|
||||
tpl.body_text = (tpl.body_text || '').slice(0, start) + snippet + (tpl.body_text || '').slice(end);
|
||||
nextTick(() => {
|
||||
const pos = start + snippet.length;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(pos, pos);
|
||||
});
|
||||
} else {
|
||||
tpl.body_text = (tpl.body_text || '') + snippet;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate(tpl) {
|
||||
if (!tenantId.value || saving.value[tpl.key]) return;
|
||||
const body = (tpl.body_text || '').trim();
|
||||
if (!body) {
|
||||
toast.add({ severity: 'warn', summary: 'Mensagem não pode ficar vazia', life: 3000 });
|
||||
return;
|
||||
}
|
||||
if (body.length > 4096) {
|
||||
toast.add({ severity: 'warn', summary: 'Mensagem muito longa (máx. 4096 caracteres)', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value[tpl.key] = true;
|
||||
try {
|
||||
if (tpl.id) {
|
||||
const { error } = await supabase.from('notification_templates').update({ body_text: body }).eq('id', tpl.id);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { data: existing } = await supabase.from('notification_templates').select('id').eq('tenant_id', tenantId.value).eq('key', tpl.key).is('deleted_at', null).maybeSingle();
|
||||
|
||||
if (existing?.id) {
|
||||
const { error } = await supabase.from('notification_templates').update({ body_text: body, is_active: true }).eq('id', existing.id);
|
||||
if (error) throw error;
|
||||
tpl.id = existing.id;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('notification_templates')
|
||||
.insert({
|
||||
owner_id: userId.value,
|
||||
tenant_id: tenantId.value,
|
||||
channel: 'whatsapp',
|
||||
key: tpl.key,
|
||||
domain: tpl.domain,
|
||||
event_type: tpl.event_type,
|
||||
body_text: body,
|
||||
variables: tpl.variables,
|
||||
is_active: true,
|
||||
is_default: false
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
tpl.id = data.id;
|
||||
}
|
||||
tpl.is_custom = true;
|
||||
}
|
||||
tpl.saved_body = body;
|
||||
tpl.body_text = body;
|
||||
drawerStore.invalidateTemplates();
|
||||
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
|
||||
} finally {
|
||||
saving.value[tpl.key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRevert(tpl) {
|
||||
if (!tpl.is_custom || !tpl.id) return;
|
||||
confirm.require({
|
||||
group: 'headless',
|
||||
message: `Reverter "${tpl.label}" para o texto padrão? Sua personalização será removida.`,
|
||||
header: 'Reverter ao padrão',
|
||||
icon: 'pi-undo',
|
||||
color: '#f59e0b',
|
||||
accept: async () => {
|
||||
if (reverting.value[tpl.key]) return;
|
||||
reverting.value[tpl.key] = true;
|
||||
try {
|
||||
const { error } = await supabase.from('notification_templates').update({ deleted_at: new Date().toISOString() }).eq('id', tpl.id);
|
||||
if (error) throw error;
|
||||
tpl.id = null;
|
||||
tpl.is_custom = false;
|
||||
tpl.body_text = tpl.default_body;
|
||||
tpl.saved_body = tpl.default_body;
|
||||
drawerStore.invalidateTemplates();
|
||||
toast.add({ severity: 'success', summary: 'Revertido ao padrão', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao reverter', detail: e.message, life: 5000 });
|
||||
} finally {
|
||||
reverting.value[tpl.key] = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isDirty(tpl) {
|
||||
return (tpl.body_text || '') !== (tpl.saved_body || '');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadUser();
|
||||
await loadTemplates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-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">
|
||||
<div class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20" :style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }">
|
||||
<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>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<Message severity="info" :closable="false">
|
||||
Os textos padrão já funcionam — edite apenas se quiser personalizar. A qualquer momento você pode <strong>reverter ao padrão</strong> definido pelo administrador.
|
||||
</Message>
|
||||
|
||||
<template v-if="loading">
|
||||
<div v-for="n in 4" :key="n" class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<Skeleton width="2.5rem" height="2.5rem" borderRadius="6px" />
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
<Skeleton width="10rem" height="1rem" />
|
||||
<Skeleton width="6rem" height="0.75rem" />
|
||||
</div>
|
||||
<Skeleton width="5rem" height="1.4rem" borderRadius="999px" />
|
||||
</div>
|
||||
<Skeleton width="100%" height="5rem" class="mb-2" />
|
||||
<div class="flex gap-1">
|
||||
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" borderRadius="999px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!templates.length"
|
||||
class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 text-center text-[var(--text-color-secondary)]"
|
||||
>
|
||||
<i class="pi pi-whatsapp text-3xl opacity-30 block mb-2" />
|
||||
Nenhum template WhatsApp disponível no momento.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.key"
|
||||
class="rounded-[6px] border bg-[var(--surface-card)] p-4 flex flex-col gap-3 overflow-hidden"
|
||||
:class="tpl.is_custom ? 'border-green-300' : 'border-[var(--surface-border)]'"
|
||||
>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div
|
||||
class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
|
||||
:class="tpl.is_custom ? 'bg-green-100 text-green-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'"
|
||||
>
|
||||
<i class="pi pi-whatsapp text-lg" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[var(--text-color)]">{{ tpl.label }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] flex items-center gap-1.5 mt-0.5">
|
||||
<Tag :value="tpl.domain_label" :severity="tpl.type_severity" class="text-[0.6rem]" />
|
||||
<code class="font-mono opacity-70">{{ tpl.key }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
|
||||
<Tag v-else value="Padrão" severity="secondary" class="text-[0.65rem]" />
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
:ref="(el) => setTextareaRef(tpl.key, el)"
|
||||
v-model="tpl.body_text"
|
||||
rows="4"
|
||||
auto-resize
|
||||
class="w-full text-sm"
|
||||
:maxlength="4096"
|
||||
placeholder="Digite o texto do template..."
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<span class="text-[0.68rem] text-[var(--text-color-secondary)]">{{ (tpl.body_text || '').length }} / 4096 caracteres</span>
|
||||
<span v-if="isDirty(tpl)" class="text-[0.68rem] text-amber-600 font-medium flex items-center gap-1">
|
||||
<i class="pi pi-circle-fill text-[0.4rem]" />
|
||||
Alterações não salvas
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="tpl.variables.length" class="flex flex-col gap-1.5">
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável:</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
v-for="v in tpl.variables"
|
||||
:key="v"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="font-mono !text-[0.68rem] !py-1 !px-2"
|
||||
@click="insertVariable(tpl.key, v)"
|
||||
>
|
||||
<span v-text="'{{' + v + '}}'"></span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 justify-end flex-wrap">
|
||||
<Button
|
||||
v-if="tpl.is_custom"
|
||||
label="Reverter ao padrão"
|
||||
icon="pi pi-undo"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="reverting[tpl.key]"
|
||||
:disabled="reverting[tpl.key]"
|
||||
@click="confirmRevert(tpl)"
|
||||
/>
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
:loading="saving[tpl.key]"
|
||||
:disabled="saving[tpl.key] || !isDirty(tpl)"
|
||||
@click="saveTemplate(tpl)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -47,6 +47,14 @@ export default function adminMenu(ctx = {}) {
|
||||
]
|
||||
},
|
||||
|
||||
// CRM de conversas / WhatsApp
|
||||
{
|
||||
label: 'Conversas',
|
||||
icon: 'pi pi-fw pi-comments',
|
||||
to: { name: 'admin-conversas' },
|
||||
badgeKey: 'conversasUnread'
|
||||
},
|
||||
|
||||
// ✅ SEM IF: sempre existe, só fica visível quando a feature estiver ligada
|
||||
{
|
||||
label: 'Pacientes',
|
||||
|
||||
@@ -30,6 +30,13 @@ export default [
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Conversas',
|
||||
items: [
|
||||
{ label: 'CRM de WhatsApp', icon: 'pi pi-fw pi-comments', to: '/therapist/conversas', badgeKey: 'conversasUnread' }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Pacientes',
|
||||
items: [
|
||||
|
||||
@@ -41,6 +41,16 @@ export default {
|
||||
// ======================================================
|
||||
{ path: '', name: 'admin.dashboard', component: () => import('@/views/pages/clinic/ClinicDashboard.vue') },
|
||||
|
||||
// ======================================================
|
||||
// 💬 CONVERSAS (CRM de WhatsApp)
|
||||
// ======================================================
|
||||
{
|
||||
path: 'conversas',
|
||||
name: 'admin-conversas',
|
||||
component: () => import('@/features/conversations/CRMConversasPage.vue'),
|
||||
meta: { roles: ['clinic_admin', 'tenant_admin'] }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🧩 CLÍNICA — MÓDULOS (tenant_features)
|
||||
// ======================================================
|
||||
|
||||
@@ -86,13 +86,28 @@ export default {
|
||||
{
|
||||
path: 'whatsapp',
|
||||
name: 'ConfiguracoesWhatsapp',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesWhatsappChooserPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'whatsapp-pessoal',
|
||||
name: 'ConfiguracoesWhatsappPessoal',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesWhatsappPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'whatsapp-twilio',
|
||||
name: 'ConfiguracoesWhatsappTwilio',
|
||||
path: 'whatsapp-oficial',
|
||||
name: 'ConfiguracoesWhatsappOficial',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesTwilioWhatsappPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'whatsapp-templates',
|
||||
name: 'ConfiguracoesWhatsappTemplates',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesWhatsappTemplatesPage.vue')
|
||||
},
|
||||
// Backcompat: old /whatsapp-twilio → redirect
|
||||
{
|
||||
path: 'whatsapp-twilio',
|
||||
redirect: { name: 'ConfiguracoesWhatsappOficial' }
|
||||
},
|
||||
{
|
||||
path: 'sms',
|
||||
name: 'ConfiguracoesSms',
|
||||
@@ -107,6 +122,41 @@ export default {
|
||||
path: 'recursos-extras',
|
||||
name: 'ConfiguracoesRecursosExtras',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesRecursosExtrasPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'recursos-extras/extrato',
|
||||
name: 'ConfiguracoesRecursosExtrasExtrato',
|
||||
component: () => import('@/layout/configuracoes/AddonsExtratoPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'auditoria',
|
||||
name: 'ConfiguracoesAuditoria',
|
||||
component: () => import('@/layout/configuracoes/AuditoriaPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'conversas-tags',
|
||||
name: 'ConfiguracoesConversasTags',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesConversasTagsPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'conversas-autoreply',
|
||||
name: 'ConfiguracoesConversasAutoreply',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesConversasAutoreplyPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'conversas-optouts',
|
||||
name: 'ConfiguracoesConversasOptouts',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesConversasOptoutsPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'lembretes-sessao',
|
||||
name: 'ConfiguracoesLembretesSessao',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesLembretesSessaoPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'creditos-whatsapp',
|
||||
name: 'ConfiguracoesCreditosWhatsapp',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesCreditosWhatsappPage.vue')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -38,6 +38,15 @@ export default {
|
||||
// ======================================================
|
||||
{ path: '', name: 'therapist.dashboard', component: () => import('@/views/pages/therapist/TherapistDashboard.vue') },
|
||||
|
||||
// ======================================================
|
||||
// 💬 CONVERSAS (CRM de WhatsApp)
|
||||
// ======================================================
|
||||
{
|
||||
path: 'conversas',
|
||||
name: 'therapist-conversas',
|
||||
component: () => import('@/features/conversations/CRMConversasPage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📅 AGENDA
|
||||
// ======================================================
|
||||
@@ -193,6 +202,15 @@ export default {
|
||||
meta: { feature: 'agenda.view' }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔔 NOTIFICAÇÕES
|
||||
// ======================================================
|
||||
{
|
||||
path: 'notificacoes',
|
||||
name: 'therapist-notificacoes',
|
||||
component: () => import('@/views/pages/therapist/NotificationsHistoryPage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔐 SECURITY
|
||||
// ======================================================
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Arquivo: src/stores/conversationDrawerStore.js
|
||||
|
|
||||
| Store global do drawer de conversa. Permite abrir de qualquer página
|
||||
| (ficha de paciente, lista, agenda, dashboard) sem navegar.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// Mapeia códigos de erro do edge function pra mensagens amigáveis ao usuário.
|
||||
// O edge function retorna { ok: false, error: '<code>', message: '<human>' } — priorizamos message.
|
||||
function friendlySendError(code, providedMessage) {
|
||||
if (providedMessage) return providedMessage;
|
||||
const c = String(code || '').toLowerCase();
|
||||
if (c === 'insufficient_credits') return 'Saldo de créditos insuficiente. Compre um pacote em Configurações → Créditos WhatsApp.';
|
||||
if (c.startsWith('twilio_send_failed')) return 'Não conseguimos enviar pelo WhatsApp oficial. Verifique se o canal está conectado em Configurações → WhatsApp.';
|
||||
if (c.includes('canal whatsapp nao configurado') || c.includes('canal whatsapp inativo')) return 'Nenhum canal WhatsApp ativo. Configure em Configurações → WhatsApp.';
|
||||
if (c.includes('credenciais evolution incompletas')) return 'As credenciais do WhatsApp Pessoal estão incompletas. Acesse Configurações → WhatsApp.';
|
||||
if (c.includes('twilio credenciais incompletas')) return 'As credenciais do WhatsApp Oficial estão incompletas. Contate o suporte.';
|
||||
if (c.startsWith('evolution retornou')) return 'O servidor do WhatsApp Pessoal não respondeu. Verifique se o celular está conectado.';
|
||||
if (c === 'auth') return 'Sua sessão expirou. Faça login novamente.';
|
||||
if (c === 'forbidden') return 'Você não tem permissão pra enviar por este canal.';
|
||||
if (c === 'edge function returned a non-2xx status code') return 'O servidor recusou o envio. Tente novamente ou contate o suporte.';
|
||||
// Fallback: retorna o código original se nada bateu
|
||||
return code || 'Falha ao enviar. Tente novamente.';
|
||||
}
|
||||
|
||||
export const useConversationDrawerStore = defineStore('conversationDrawer', {
|
||||
state: () => ({
|
||||
isOpen: false,
|
||||
thread: null, // { patient_id, patient_name, contact_number, channel, kanban_status, ... }
|
||||
messages: [],
|
||||
loading: false,
|
||||
sending: false,
|
||||
error: null,
|
||||
_realtimeChannel: null,
|
||||
// cache compartilhado
|
||||
templates: [],
|
||||
templatesLoaded: false,
|
||||
templatesLoading: false,
|
||||
tenantName: ''
|
||||
}),
|
||||
|
||||
getters: {
|
||||
threadKey(state) {
|
||||
if (!state.thread) return null;
|
||||
return state.thread.patient_id || `anon:${state.thread.contact_number || 'unknown'}`;
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// ── Abertura ───────────────────────────────────────────────
|
||||
async openForThread(thread) {
|
||||
if (!thread) return;
|
||||
this.thread = { ...thread };
|
||||
this.isOpen = true;
|
||||
await this.loadMessages();
|
||||
this._ensureTenantName();
|
||||
this._subscribeRealtime();
|
||||
// Marca inbound como lidas
|
||||
if ((thread.unread_count || 0) > 0) {
|
||||
await this.markRead();
|
||||
}
|
||||
},
|
||||
|
||||
async openForPatient(patientId) {
|
||||
if (!patientId) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
|
||||
// Procura thread existente pelo paciente
|
||||
const { data: existing } = await supabase
|
||||
.from('conversation_threads')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('patient_id', patientId)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
return this.openForThread(existing);
|
||||
}
|
||||
|
||||
// Não tem thread — cria stub com dados do paciente
|
||||
const { data: pat } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, telefone')
|
||||
.eq('id', patientId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!pat) {
|
||||
this.error = new Error('Paciente não encontrado');
|
||||
return;
|
||||
}
|
||||
if (!pat.telefone) {
|
||||
this.error = new Error('Paciente sem telefone cadastrado');
|
||||
return;
|
||||
}
|
||||
|
||||
const stub = {
|
||||
thread_key: pat.id,
|
||||
tenant_id: tenantId,
|
||||
patient_id: pat.id,
|
||||
patient_name: pat.nome_completo,
|
||||
contact_number: String(pat.telefone).replace(/\D/g, ''),
|
||||
channel: 'whatsapp',
|
||||
message_count: 0,
|
||||
unread_count: 0,
|
||||
kanban_status: 'awaiting_us',
|
||||
last_message_at: null,
|
||||
last_message_body: null,
|
||||
last_message_direction: null
|
||||
};
|
||||
return this.openForThread(stub);
|
||||
},
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this._unsubscribeRealtime();
|
||||
// preserva thread por um momento pra transição, mas limpa messages
|
||||
setTimeout(() => {
|
||||
if (!this.isOpen) {
|
||||
this.thread = null;
|
||||
this.messages = [];
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// ── Mensagens da thread ────────────────────────────────────
|
||||
async loadMessages() {
|
||||
if (!this.thread) return;
|
||||
const tenantStore = useTenantStore();
|
||||
this.loading = true;
|
||||
try {
|
||||
let q = supabase
|
||||
.from('conversation_messages')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantStore.activeTenantId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(500);
|
||||
|
||||
if (this.thread.patient_id) {
|
||||
q = q.eq('patient_id', this.thread.patient_id);
|
||||
} else {
|
||||
q = q.or(`from_number.eq.${this.thread.contact_number},to_number.eq.${this.thread.contact_number}`).is('patient_id', null);
|
||||
}
|
||||
|
||||
const { data, error: qErr } = await q;
|
||||
if (qErr) throw qErr;
|
||||
this.messages = data ?? [];
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
messageBelongsToCurrentThread(msg) {
|
||||
if (!msg || !this.thread) return false;
|
||||
if (this.thread.patient_id) return msg.patient_id === this.thread.patient_id;
|
||||
if (msg.patient_id) return false;
|
||||
return (
|
||||
msg.from_number === this.thread.contact_number ||
|
||||
msg.to_number === this.thread.contact_number
|
||||
);
|
||||
},
|
||||
|
||||
_subscribeRealtime() {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
this._unsubscribeRealtime();
|
||||
|
||||
this._realtimeChannel = supabase
|
||||
.channel(`conv_drawer_${tenantId}_${Date.now()}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
|
||||
(payload) => {
|
||||
const msg = payload.new;
|
||||
if (this.messageBelongsToCurrentThread(msg)) {
|
||||
const exists = this.messages.some((m) => m.id === msg.id);
|
||||
if (!exists) this.messages.push(msg);
|
||||
}
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
|
||||
(payload) => {
|
||||
const msg = payload.new;
|
||||
if (this.messageBelongsToCurrentThread(msg)) {
|
||||
const idx = this.messages.findIndex((m) => m.id === msg.id);
|
||||
if (idx >= 0) this.messages[idx] = msg;
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
},
|
||||
|
||||
_unsubscribeRealtime() {
|
||||
if (this._realtimeChannel) {
|
||||
supabase.removeChannel(this._realtimeChannel);
|
||||
this._realtimeChannel = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Ações ──────────────────────────────────────────────────
|
||||
async sendMessage(text) {
|
||||
const cleanText = String(text || '').trim();
|
||||
if (!cleanText || this.sending) return { ok: false, error: 'Mensagem vazia' };
|
||||
if (!this.thread?.contact_number) return { ok: false, error: 'Conversa sem número de contato' };
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
this.sending = true;
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('send-whatsapp-message', {
|
||||
body: {
|
||||
tenant_id: tenantStore.activeTenantId,
|
||||
to_number: this.thread.contact_number,
|
||||
body: cleanText,
|
||||
patient_id: this.thread.patient_id || null
|
||||
}
|
||||
});
|
||||
|
||||
// Erro HTTP (não-2xx) — extrai body da resposta pra mostrar msg amigável
|
||||
if (error) {
|
||||
let body = null;
|
||||
try {
|
||||
body = await error.context?.json?.();
|
||||
} catch { /* noop */ }
|
||||
return { ok: false, error: friendlySendError(body?.error, body?.message) };
|
||||
}
|
||||
if (!data?.ok) {
|
||||
return { ok: false, error: friendlySendError(data?.error, data?.message) };
|
||||
}
|
||||
|
||||
this.thread.kanban_status = 'awaiting_patient';
|
||||
return { ok: true, data };
|
||||
} catch (err) {
|
||||
return { ok: false, error: friendlySendError(err?.message || String(err)) };
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
},
|
||||
|
||||
async setKanbanStatus(status) {
|
||||
if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(status)) return;
|
||||
if (!this.thread) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const patch = { kanban_status: status };
|
||||
if (status === 'resolved') patch.resolved_at = new Date().toISOString();
|
||||
|
||||
let q = supabase.from('conversation_messages').update(patch).eq('tenant_id', tenantStore.activeTenantId);
|
||||
if (this.thread.patient_id) q = q.eq('patient_id', this.thread.patient_id);
|
||||
else q = q.eq('from_number', this.thread.contact_number).is('patient_id', null);
|
||||
|
||||
await q;
|
||||
this.thread.kanban_status = status;
|
||||
},
|
||||
|
||||
async markRead() {
|
||||
if (!this.thread) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const nowIso = new Date().toISOString();
|
||||
let q = supabase
|
||||
.from('conversation_messages')
|
||||
.update({ read_at: nowIso })
|
||||
.eq('tenant_id', tenantStore.activeTenantId)
|
||||
.eq('direction', 'inbound')
|
||||
.is('read_at', null);
|
||||
if (this.thread.patient_id) q = q.eq('patient_id', this.thread.patient_id);
|
||||
else q = q.eq('from_number', this.thread.contact_number).is('patient_id', null);
|
||||
await q;
|
||||
},
|
||||
|
||||
// ── Templates (cache global) ───────────────────────────────
|
||||
async loadTemplates({ force = false } = {}) {
|
||||
if ((this.templatesLoaded || this.templatesLoading) && !force) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
|
||||
this.templatesLoading = true;
|
||||
try {
|
||||
const { data: globals } = await supabase
|
||||
.from('notification_templates')
|
||||
.select('id, key, body_text, variables, event_type')
|
||||
.eq('channel', 'whatsapp')
|
||||
.is('tenant_id', null)
|
||||
.eq('is_default', true)
|
||||
.eq('is_active', true)
|
||||
.is('deleted_at', null);
|
||||
|
||||
const { data: customs } = await supabase
|
||||
.from('notification_templates')
|
||||
.select('id, key, body_text, variables, event_type')
|
||||
.eq('channel', 'whatsapp')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('is_active', true)
|
||||
.is('deleted_at', null);
|
||||
|
||||
const customMap = new Map((customs || []).map((c) => [c.key, c]));
|
||||
const merged = (globals || []).map((g) => customMap.get(g.key) || g);
|
||||
for (const c of customs || []) {
|
||||
if (!merged.find((m) => m.key === c.key)) merged.push(c);
|
||||
}
|
||||
this.templates = merged;
|
||||
this.templatesLoaded = true;
|
||||
} finally {
|
||||
this.templatesLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
invalidateTemplates() {
|
||||
this.templatesLoaded = false;
|
||||
},
|
||||
|
||||
async _ensureTenantName() {
|
||||
if (this.tenantName) return;
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return;
|
||||
const { data } = await supabase.from('tenants').select('name').eq('id', tenantId).maybeSingle();
|
||||
this.tenantName = data?.name || '';
|
||||
},
|
||||
|
||||
// ── Resolução de variáveis de template ─────────────────────
|
||||
// Retorna: { text, missing } — missing é lista de variáveis não resolvidas
|
||||
async resolveTemplate(tpl) {
|
||||
if (!tpl?.body_text) return { text: '', missing: [] };
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
|
||||
// Contexto base (sempre disponível)
|
||||
const now = new Date();
|
||||
const ctx = {
|
||||
data: now.toLocaleDateString('pt-BR'),
|
||||
hora: now.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }),
|
||||
nome_paciente: this.thread?.patient_name || '',
|
||||
nome_clinica: this.tenantName || '',
|
||||
clinica: this.tenantName || '',
|
||||
// aliases curtos
|
||||
paciente: this.thread?.patient_name || '',
|
||||
nome: (this.thread?.patient_name || '').split(' ')[0] || '',
|
||||
primeiro_nome: (this.thread?.patient_name || '').split(' ')[0] || ''
|
||||
};
|
||||
|
||||
// Enriquece com dados do DB (terapeuta + próxima sessão)
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
const uid = authData?.user?.id;
|
||||
if (uid) {
|
||||
const { data: profile } = await supabase.from('profiles').select('full_name, nickname').eq('id', uid).maybeSingle();
|
||||
const name = profile?.full_name || profile?.nickname || '';
|
||||
ctx.nome_terapeuta = name;
|
||||
ctx.terapeuta = name;
|
||||
}
|
||||
|
||||
if (this.thread?.patient_id && tenantId) {
|
||||
const { data: sess } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('inicio_em, modalidade, price')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('patient_id', this.thread.patient_id)
|
||||
.gte('inicio_em', new Date().toISOString())
|
||||
.neq('tipo', 'bloqueio')
|
||||
.order('inicio_em', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (sess?.inicio_em) {
|
||||
const d = new Date(sess.inicio_em);
|
||||
ctx.data_sessao = d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
ctx.hora_sessao = d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
ctx.modalidade = sess.modalidade === 'online' ? 'Online' : 'Presencial';
|
||||
if (sess.price) {
|
||||
ctx.valor_sessao = Number(sess.price).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// contexto opcional — segue sem
|
||||
}
|
||||
|
||||
// Substitui {{var}} (Mustache) e também {var} (legado) case-insensitive
|
||||
const missing = [];
|
||||
const text = String(tpl.body_text).replace(/\{\{?\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}?\}/g, (match, varName) => {
|
||||
const key = varName.toLowerCase();
|
||||
if (key in ctx && ctx[key] != null && ctx[key] !== '') {
|
||||
return ctx[key];
|
||||
}
|
||||
if (!missing.includes(varName)) missing.push(varName);
|
||||
return match; // preserva placeholder pro user editar manualmente
|
||||
});
|
||||
|
||||
return { text, missing };
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -17,6 +17,76 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
// ─── Browser Notification helpers ────────────────────────────
|
||||
const BROWSER_NOTIF_PREF_KEY = 'agenciapsi.browser_notifications_enabled';
|
||||
|
||||
function browserNotifSupported() {
|
||||
return typeof window !== 'undefined' && 'Notification' in window;
|
||||
}
|
||||
|
||||
function browserNotifAllowed() {
|
||||
return browserNotifSupported() && Notification.permission === 'granted';
|
||||
}
|
||||
|
||||
function browserNotifEnabled() {
|
||||
if (!browserNotifAllowed()) return false;
|
||||
try {
|
||||
const v = localStorage.getItem(BROWSER_NOTIF_PREF_KEY);
|
||||
return v !== 'false'; // default: on quando permitido
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function fireBrowserNotification(item) {
|
||||
if (!browserNotifEnabled()) return;
|
||||
if (document.hasFocus() && document.visibilityState === 'visible') return; // não notifica se tab ativa
|
||||
try {
|
||||
const title = item?.payload?.title || 'Nova notificação';
|
||||
const body = item?.payload?.detail || '';
|
||||
const n = new Notification(title, {
|
||||
body,
|
||||
icon: '/favicon.ico',
|
||||
tag: `agenciapsi:${item.id}`,
|
||||
requireInteraction: false
|
||||
});
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
if (item?.payload?.deeplink) {
|
||||
window.location.hash = '';
|
||||
window.location.pathname = item.payload.deeplink;
|
||||
}
|
||||
n.close();
|
||||
};
|
||||
} catch {
|
||||
// browser sem suporte ou permissão revogada
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestBrowserNotificationPermission() {
|
||||
if (!browserNotifSupported()) return false;
|
||||
if (Notification.permission === 'granted') return true;
|
||||
if (Notification.permission === 'denied') return false;
|
||||
try {
|
||||
const res = await Notification.requestPermission();
|
||||
return res === 'granted';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setBrowserNotificationEnabled(enabled) {
|
||||
try {
|
||||
localStorage.setItem(BROWSER_NOTIF_PREF_KEY, enabled ? 'true' : 'false');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function getBrowserNotificationEnabled() {
|
||||
return browserNotifEnabled();
|
||||
}
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', {
|
||||
state: () => ({
|
||||
items: [],
|
||||
@@ -59,6 +129,7 @@ export const useNotificationStore = defineStore('notifications', {
|
||||
},
|
||||
(payload) => {
|
||||
this.items.unshift(payload.new);
|
||||
fireBrowserNotification(payload.new);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/utils/addonExtratoExport.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { htmlToPdfDownload } from '@/services/pdf.service';
|
||||
|
||||
// ─── labels ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOVEMENT_LABELS = {
|
||||
purchase: 'Compra',
|
||||
consumption: 'Consumo',
|
||||
adjustment: 'Ajuste',
|
||||
refund: 'Reembolso',
|
||||
bonus: 'Bônus',
|
||||
expiration: 'Expiração'
|
||||
};
|
||||
|
||||
const ADDON_LABELS = {
|
||||
sms: 'SMS',
|
||||
email: 'E-mail',
|
||||
server: 'Servidor',
|
||||
domain: 'Domínio'
|
||||
};
|
||||
|
||||
// ─── formatadores ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function fmtAmount(row) {
|
||||
const amt = Number(row.amount) || 0;
|
||||
if (row.type === 'purchase' || row.type === 'bonus' || row.type === 'refund') {
|
||||
return `+${amt}`;
|
||||
}
|
||||
if (row.type === 'consumption') {
|
||||
return `-${Math.abs(amt)}`;
|
||||
}
|
||||
return amt >= 0 ? `+${amt}` : `${amt}`;
|
||||
}
|
||||
|
||||
function fmtBRL(cents) {
|
||||
if (!cents) return '';
|
||||
return (cents / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function addonLabel(type) {
|
||||
return ADDON_LABELS[type] || type || '';
|
||||
}
|
||||
|
||||
function movementLabel(type) {
|
||||
return MOVEMENT_LABELS[type] || type || '';
|
||||
}
|
||||
|
||||
// ─── sanitização para CSV ───────────────────────────────────────────────────
|
||||
|
||||
function csvCell(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
const s = String(value)
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.replace(/"/g, '""')
|
||||
.trim();
|
||||
// Previne CSV injection: prefixa com espaço se começar com =, +, -, @
|
||||
const safe = /^[=+\-@]/.test(s) ? ' ' + s : s;
|
||||
return `"${safe}"`;
|
||||
}
|
||||
|
||||
function csvLine(cells) {
|
||||
return cells.map(csvCell).join(';');
|
||||
}
|
||||
|
||||
// ─── export CSV ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildExtratoCSV(rows, summary, periodLabel) {
|
||||
const header = ['Data', 'Addon', 'Tipo', 'Quantidade', 'Saldo após', 'Descrição', 'Valor (R$)', 'Método', 'Referência'];
|
||||
|
||||
const lines = [csvLine(header)];
|
||||
for (const r of rows) {
|
||||
lines.push(
|
||||
csvLine([
|
||||
fmtDate(r.created_at),
|
||||
addonLabel(r.addon_type),
|
||||
movementLabel(r.type),
|
||||
fmtAmount(r),
|
||||
r.balance_after ?? '',
|
||||
r.description ?? '',
|
||||
r.price_cents ? fmtBRL(r.price_cents) : '',
|
||||
r.payment_method ?? '',
|
||||
r.payment_reference ?? ''
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
// Rodapé com resumo
|
||||
lines.push('');
|
||||
lines.push(csvLine(['Período', periodLabel, '', '', '', '', '', '', '']));
|
||||
lines.push(csvLine(['Registros', summary.totalRows, '', '', '', '', '', '', '']));
|
||||
lines.push(csvLine(['Total comprado (créditos)', summary.purchasedCredits, '', '', '', '', '', '', '']));
|
||||
lines.push(csvLine(['Total comprado (R$)', fmtBRL(summary.purchasedCents), '', '', '', '', '', '', '']));
|
||||
lines.push(csvLine(['Total consumido (créditos)', summary.consumedCredits, '', '', '', '', '', '', '']));
|
||||
|
||||
return '\uFEFF' + lines.join('\r\n');
|
||||
}
|
||||
|
||||
export function downloadExtratoCSV(rows, summary, periodLabel, filename) {
|
||||
const csv = buildExtratoCSV(rows, summary, periodLabel);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `extrato-addons-${Date.now()}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ─── export PDF ─────────────────────────────────────────────────────────────
|
||||
|
||||
function htmlEscape(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildExtratoHTML(rows, summary, periodLabel, tenantName) {
|
||||
const tbody = rows
|
||||
.map(
|
||||
(r) => `
|
||||
<tr>
|
||||
<td>${htmlEscape(fmtDate(r.created_at))}</td>
|
||||
<td>${htmlEscape(addonLabel(r.addon_type))}</td>
|
||||
<td>${htmlEscape(movementLabel(r.type))}</td>
|
||||
<td class="num">${htmlEscape(fmtAmount(r))}</td>
|
||||
<td class="num">${htmlEscape(r.balance_after ?? '')}</td>
|
||||
<td>${htmlEscape(r.description ?? '')}</td>
|
||||
<td class="num">${htmlEscape(r.price_cents ? fmtBRL(r.price_cents) : '')}</td>
|
||||
<td>${htmlEscape(r.payment_reference ?? '')}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Extrato de Recursos Extras</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; color: #1a1a1a; margin: 0; padding: 24px; font-size: 11px; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px 0; color: #2e3440; }
|
||||
.subtitle { color: #6b7280; font-size: 11px; margin-bottom: 16px; }
|
||||
.summary { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
|
||||
.card { flex: 1 1 150px; border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px; background: #f9fafb; }
|
||||
.card .label { font-size: 9px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.card .value { font-size: 14px; font-weight: 600; color: #111827; margin-top: 2px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 10px; }
|
||||
th { background: #374151; color: #fff; padding: 6px 8px; text-align: left; font-weight: 600; }
|
||||
td { padding: 5px 8px; border-bottom: 1px solid #e5e7eb; }
|
||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
tr:nth-child(even) td { background: #f9fafb; }
|
||||
.footer { margin-top: 16px; font-size: 9px; color: #9ca3af; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Extrato de Recursos Extras</h1>
|
||||
<div class="subtitle">
|
||||
${htmlEscape(tenantName || '')} · Período: <strong>${htmlEscape(periodLabel)}</strong> ·
|
||||
Gerado em ${htmlEscape(fmtDate(new Date().toISOString()))}
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="card"><div class="label">Registros</div><div class="value">${summary.totalRows}</div></div>
|
||||
<div class="card"><div class="label">Comprado (créditos)</div><div class="value">${summary.purchasedCredits}</div></div>
|
||||
<div class="card"><div class="label">Comprado (R$)</div><div class="value">${htmlEscape(fmtBRL(summary.purchasedCents) || 'R$ 0,00')}</div></div>
|
||||
<div class="card"><div class="label">Consumido (créditos)</div><div class="value">${summary.consumedCredits}</div></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Addon</th>
|
||||
<th>Tipo</th>
|
||||
<th class="num">Qtd.</th>
|
||||
<th class="num">Saldo após</th>
|
||||
<th>Descrição</th>
|
||||
<th class="num">Valor</th>
|
||||
<th>Referência</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tbody || '<tr><td colspan="8" style="text-align:center;color:#9ca3af;padding:16px;">Sem transações no período</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">AgênciaPSI · Extrato de débitos e créditos de recursos extras</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function downloadExtratoPDF(rows, summary, periodLabel, tenantName, filename) {
|
||||
const html = buildExtratoHTML(rows, summary, periodLabel, tenantName);
|
||||
await htmlToPdfDownload(html, filename || `extrato-addons-${Date.now()}.pdf`);
|
||||
}
|
||||
|
||||
// ─── util exposto pra página ────────────────────────────────────────────────
|
||||
|
||||
export const formatters = {
|
||||
fmtDate,
|
||||
fmtAmount,
|
||||
fmtBRL,
|
||||
addonLabel,
|
||||
movementLabel
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/utils/auditoriaExport.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { htmlToPdfDownload } from '@/services/pdf.service';
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
audit_logs: 'Auditoria',
|
||||
document_access_logs: 'Acesso a documento',
|
||||
patient_status_history: 'Status paciente',
|
||||
notification_logs: 'Notificação',
|
||||
addon_transactions: 'Recurso extra'
|
||||
};
|
||||
|
||||
const ENTITY_LABELS = {
|
||||
patients: 'Paciente',
|
||||
agenda_eventos: 'Evento de agenda',
|
||||
financial_records: 'Registro financeiro',
|
||||
documents: 'Documento',
|
||||
tenant_members: 'Membro do tenant',
|
||||
document: 'Documento',
|
||||
notification: 'Notificação',
|
||||
patient_status: 'Status do paciente',
|
||||
addon_transaction: 'Transação de recurso'
|
||||
};
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function sourceLabel(src) {
|
||||
return SOURCE_LABELS[src] || src || '';
|
||||
}
|
||||
|
||||
export function entityLabel(ent) {
|
||||
return ENTITY_LABELS[ent] || ent || '';
|
||||
}
|
||||
|
||||
// ─── sanitização CSV (anti-injection) ────────────────────────────────────────
|
||||
|
||||
function csvCell(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
const s = String(value)
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.replace(/"/g, '""')
|
||||
.trim();
|
||||
const safe = /^[=+\-@]/.test(s) ? ' ' + s : s;
|
||||
return `"${safe}"`;
|
||||
}
|
||||
|
||||
function csvLine(cells) {
|
||||
return cells.map(csvCell).join(';');
|
||||
}
|
||||
|
||||
export function buildAuditCSV(rows, summary, periodLabel, userDisplay) {
|
||||
const header = ['Data/Hora', 'Origem', 'Entidade', 'ID entidade', 'Ação', 'Descrição', 'Usuário'];
|
||||
const lines = [csvLine(header)];
|
||||
|
||||
for (const r of rows) {
|
||||
lines.push(
|
||||
csvLine([
|
||||
fmtDate(r.occurred_at),
|
||||
sourceLabel(r.source),
|
||||
entityLabel(r.entity_type),
|
||||
r.entity_id ?? '',
|
||||
r.action ?? '',
|
||||
r.description ?? '',
|
||||
userDisplay(r.user_id)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(csvLine(['Período', periodLabel]));
|
||||
lines.push(csvLine(['Total de registros', summary.totalRows]));
|
||||
lines.push(csvLine(['Usuários distintos', summary.distinctUsers]));
|
||||
|
||||
return '\uFEFF' + lines.join('\r\n');
|
||||
}
|
||||
|
||||
export function downloadAuditCSV(rows, summary, periodLabel, userDisplay, filename) {
|
||||
const csv = buildAuditCSV(rows, summary, periodLabel, userDisplay);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `auditoria-${Date.now()}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ─── PDF ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function htmlEscape(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildAuditHTML(rows, summary, periodLabel, tenantName, userDisplay) {
|
||||
const tbody = rows
|
||||
.map(
|
||||
(r) => `
|
||||
<tr>
|
||||
<td>${htmlEscape(fmtDate(r.occurred_at))}</td>
|
||||
<td>${htmlEscape(sourceLabel(r.source))}</td>
|
||||
<td>${htmlEscape(entityLabel(r.entity_type))}</td>
|
||||
<td>${htmlEscape(r.description ?? '')}</td>
|
||||
<td>${htmlEscape(userDisplay(r.user_id))}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Relatório de Auditoria</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; color: #1a1a1a; margin: 0; padding: 24px; font-size: 10px; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px 0; color: #2e3440; }
|
||||
.subtitle { color: #6b7280; font-size: 10px; margin-bottom: 16px; }
|
||||
.summary { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
|
||||
.card { flex: 1 1 120px; border: 1px solid #e5e7eb; border-radius: 6px; padding: 6px 10px; background: #f9fafb; }
|
||||
.card .label { font-size: 8px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.card .value { font-size: 13px; font-weight: 600; color: #111827; margin-top: 2px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 9px; }
|
||||
th { background: #374151; color: #fff; padding: 5px 7px; text-align: left; font-weight: 600; }
|
||||
td { padding: 4px 7px; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
|
||||
tr:nth-child(even) td { background: #f9fafb; }
|
||||
.footer { margin-top: 14px; font-size: 8px; color: #9ca3af; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Relatório de Auditoria</h1>
|
||||
<div class="subtitle">
|
||||
${htmlEscape(tenantName || '')} · Período: <strong>${htmlEscape(periodLabel)}</strong>
|
||||
· Gerado em ${htmlEscape(fmtDate(new Date().toISOString()))}
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="card"><div class="label">Registros</div><div class="value">${summary.totalRows}</div></div>
|
||||
<div class="card"><div class="label">Usuários distintos</div><div class="value">${summary.distinctUsers}</div></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data/Hora</th>
|
||||
<th>Origem</th>
|
||||
<th>Entidade</th>
|
||||
<th>Descrição</th>
|
||||
<th>Usuário</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tbody || '<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:16px;">Nenhum evento no período</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">AgênciaPSI · Registro imutável de operações de tratamento (LGPD Art. 37)</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function downloadAuditPDF(rows, summary, periodLabel, tenantName, userDisplay, filename) {
|
||||
const html = buildAuditHTML(rows, summary, periodLabel, tenantName, userDisplay);
|
||||
await htmlToPdfDownload(html, filename || `auditoria-${Date.now()}.pdf`);
|
||||
}
|
||||
|
||||
export const auditFormatters = {
|
||||
fmtDate,
|
||||
sourceLabel,
|
||||
entityLabel
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/utils/excelExport.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
/**
|
||||
* Tipos de coluna suportados:
|
||||
* text — string (default)
|
||||
* money — cents (divide por 100 + formata BRL)
|
||||
* date — ISO/Date (formata dd/mm/yyyy hh:mm)
|
||||
* number — inteiro/decimal
|
||||
*/
|
||||
|
||||
function fmtDateCell(value) {
|
||||
if (!value) return null;
|
||||
const d = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(d.getTime())) return String(value);
|
||||
return d;
|
||||
}
|
||||
|
||||
function sanitizeText(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
const s = String(value).replace(/[\r\n]+/g, ' ').trim();
|
||||
// Previne fórmula Excel: prefixa com espaço se começar com =, +, -, @
|
||||
return /^[=+\-@]/.test(s) ? ' ' + s : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera workbook Excel a partir de headers e rows.
|
||||
* headers: [{ key, label, type: 'text'|'money'|'date'|'number', width?: number }]
|
||||
* rows: array de objetos
|
||||
*/
|
||||
export async function buildExcelBlob({ sheetName = 'Dados', headers, rows, footerRows = [] }) {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'AgenciaPSI';
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet(sheetName, {
|
||||
views: [{ state: 'frozen', ySplit: 1 }]
|
||||
});
|
||||
|
||||
// ── Cabeçalho ──────────────────────────────────────────────
|
||||
ws.columns = headers.map((h) => ({
|
||||
header: h.label,
|
||||
key: h.key,
|
||||
width: h.width || 18
|
||||
}));
|
||||
|
||||
const headerRow = ws.getRow(1);
|
||||
headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
headerRow.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FF374151' }
|
||||
};
|
||||
headerRow.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||
headerRow.height = 20;
|
||||
|
||||
// ── Rows ───────────────────────────────────────────────────
|
||||
for (const row of rows) {
|
||||
const cells = {};
|
||||
for (const h of headers) {
|
||||
const raw = row[h.key];
|
||||
switch (h.type) {
|
||||
case 'money': {
|
||||
const cents = Number(raw) || 0;
|
||||
cells[h.key] = cents / 100;
|
||||
break;
|
||||
}
|
||||
case 'date':
|
||||
cells[h.key] = fmtDateCell(raw);
|
||||
break;
|
||||
case 'number':
|
||||
cells[h.key] = Number(raw) || 0;
|
||||
break;
|
||||
default:
|
||||
cells[h.key] = sanitizeText(raw);
|
||||
}
|
||||
}
|
||||
const r = ws.addRow(cells);
|
||||
|
||||
// Formatação por tipo
|
||||
headers.forEach((h, idx) => {
|
||||
const cell = r.getCell(idx + 1);
|
||||
if (h.type === 'money') {
|
||||
cell.numFmt = '"R$" #,##0.00;[Red]-"R$" #,##0.00';
|
||||
} else if (h.type === 'date') {
|
||||
cell.numFmt = 'dd/mm/yyyy hh:mm';
|
||||
} else if (h.type === 'number') {
|
||||
cell.numFmt = '#,##0';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Zebra (linhas pares) ───────────────────────────────────
|
||||
for (let i = 2; i <= ws.rowCount; i++) {
|
||||
if (i % 2 === 0) {
|
||||
ws.getRow(i).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFF9FAFB' }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer com resumo ──────────────────────────────────────
|
||||
if (footerRows.length) {
|
||||
ws.addRow([]);
|
||||
for (const fr of footerRows) {
|
||||
const r = ws.addRow(fr);
|
||||
r.font = { bold: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Borders ────────────────────────────────────────────────
|
||||
const borderStyle = { style: 'thin', color: { argb: 'FFE5E7EB' } };
|
||||
ws.eachRow((row) => {
|
||||
row.eachCell((cell) => {
|
||||
cell.border = { top: borderStyle, left: borderStyle, bottom: borderStyle, right: borderStyle };
|
||||
});
|
||||
});
|
||||
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return new Blob([buf], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadExcel({ filename, ...opts }) {
|
||||
const blob = await buildExcelBlob(opts);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `export-${Date.now()}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/utils/lgpdExportFormats.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { htmlToPdfDownload } from '@/services/pdf.service';
|
||||
|
||||
function htmlEscape(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return String(iso);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBRL(cents) {
|
||||
if (cents === null || cents === undefined) return '';
|
||||
const n = typeof cents === 'number' ? cents / 100 : Number(cents) / 100;
|
||||
if (!isFinite(n)) return '';
|
||||
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function renderKV(label, value) {
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
return `<div class="kv"><span class="k">${htmlEscape(label)}:</span> <span class="v">${htmlEscape(value)}</span></div>`;
|
||||
}
|
||||
|
||||
function renderSectionHeader(title, count) {
|
||||
const badge = count != null ? `<span class="count">${count}</span>` : '';
|
||||
return `<h2>${htmlEscape(title)} ${badge}</h2>`;
|
||||
}
|
||||
|
||||
function renderTable(headers, rows) {
|
||||
if (!rows || !rows.length) {
|
||||
return '<p class="empty">Sem registros.</p>';
|
||||
}
|
||||
const th = headers.map((h) => `<th>${htmlEscape(h.label)}</th>`).join('');
|
||||
const body = rows
|
||||
.map(
|
||||
(r) =>
|
||||
'<tr>' +
|
||||
headers
|
||||
.map((h) => {
|
||||
const raw = h.get ? h.get(r) : r[h.key];
|
||||
return `<td>${htmlEscape(raw ?? '')}</td>`;
|
||||
})
|
||||
.join('') +
|
||||
'</tr>'
|
||||
)
|
||||
.join('');
|
||||
return `<table><thead><tr>${th}</tr></thead><tbody>${body}</tbody></table>`;
|
||||
}
|
||||
|
||||
export function buildLgpdHTML(payload, tenantName) {
|
||||
if (!payload) return '';
|
||||
|
||||
const meta = payload.export_metadata || {};
|
||||
const p = payload.paciente || {};
|
||||
|
||||
const sections = [];
|
||||
|
||||
// ── Cabeçalho LGPD ─────────────────────────────────────────────────────
|
||||
sections.push(`
|
||||
<div class="cover">
|
||||
<h1>Relatório de Dados Pessoais</h1>
|
||||
<p class="lead">
|
||||
Documento gerado em atendimento ao <strong>art. 18, II da LGPD</strong>
|
||||
(portabilidade de dados do titular).
|
||||
</p>
|
||||
<div class="meta">
|
||||
${renderKV('Controlador', tenantName || 'AgênciaPSI')}
|
||||
${renderKV('Gerado em', fmtDate(meta.generated_at))}
|
||||
${renderKV('Fundamento', meta.lgpd_basis)}
|
||||
${renderKV('Versão do formato', meta.format_version)}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// ── Dados pessoais ─────────────────────────────────────────────────────
|
||||
sections.push(renderSectionHeader('1. Dados pessoais do titular'));
|
||||
sections.push(`
|
||||
<div class="grid">
|
||||
${renderKV('Nome completo', p.nome_completo)}
|
||||
${renderKV('Nome social', p.nome_social)}
|
||||
${renderKV('Apelido', p.apelido)}
|
||||
${renderKV('CPF', p.cpf)}
|
||||
${renderKV('RG', p.rg)}
|
||||
${renderKV('Data de nascimento', p.data_nascimento)}
|
||||
${renderKV('Gênero', p.genero)}
|
||||
${renderKV('Estado civil', p.estado_civil)}
|
||||
${renderKV('Profissão', p.profissao)}
|
||||
${renderKV('Escolaridade', p.escolaridade)}
|
||||
${renderKV('Telefone', p.telefone)}
|
||||
${renderKV('E-mail', p.email)}
|
||||
${renderKV('Endereço', [p.endereco_logradouro, p.endereco_numero, p.endereco_bairro, p.endereco_cidade, p.endereco_uf, p.endereco_cep].filter(Boolean).join(', '))}
|
||||
${renderKV('Status atual', p.status)}
|
||||
${renderKV('Data de criação do cadastro', fmtDate(p.created_at))}
|
||||
</div>
|
||||
`);
|
||||
|
||||
// ── Contatos ───────────────────────────────────────────────────────────
|
||||
const contatos = payload.contatos || [];
|
||||
sections.push(renderSectionHeader('2. Contatos', contatos.length));
|
||||
sections.push(
|
||||
renderTable(
|
||||
[
|
||||
{ label: 'Nome', key: 'nome' },
|
||||
{ label: 'Tipo', key: 'tipo' },
|
||||
{ label: 'Relação', key: 'relacao' },
|
||||
{ label: 'Telefone', key: 'telefone' },
|
||||
{ label: 'E-mail', key: 'email' }
|
||||
],
|
||||
contatos
|
||||
)
|
||||
);
|
||||
|
||||
// ── Contatos de apoio ──────────────────────────────────────────────────
|
||||
const apoio = payload.contatos_apoio || [];
|
||||
sections.push(renderSectionHeader('3. Contatos de apoio', apoio.length));
|
||||
sections.push(
|
||||
renderTable(
|
||||
[
|
||||
{ label: 'Nome', key: 'nome' },
|
||||
{ label: 'Vínculo', key: 'vinculo' },
|
||||
{ label: 'Telefone', key: 'telefone' }
|
||||
],
|
||||
apoio
|
||||
)
|
||||
);
|
||||
|
||||
// ── Histórico de status ────────────────────────────────────────────────
|
||||
const status = payload.historico_status || [];
|
||||
sections.push(renderSectionHeader('4. Histórico de status', status.length));
|
||||
sections.push(
|
||||
renderTable(
|
||||
[
|
||||
{ label: 'Data', key: 'alterado_em', get: (r) => fmtDate(r.alterado_em) },
|
||||
{ label: 'Anterior', key: 'status_anterior' },
|
||||
{ label: 'Novo', key: 'status_novo' },
|
||||
{ label: 'Motivo', key: 'motivo' }
|
||||
],
|
||||
status
|
||||
)
|
||||
);
|
||||
|
||||
// ── Eventos de agenda ──────────────────────────────────────────────────
|
||||
const agenda = payload.eventos_agenda || [];
|
||||
sections.push(renderSectionHeader('5. Eventos de agenda', agenda.length));
|
||||
sections.push(
|
||||
renderTable(
|
||||
[
|
||||
{ label: 'Início', key: 'inicio_em', get: (r) => fmtDate(r.inicio_em) },
|
||||
{ label: 'Tipo', key: 'tipo' },
|
||||
{ label: 'Status', key: 'status' },
|
||||
{ label: 'Observações', key: 'observacoes' }
|
||||
],
|
||||
agenda
|
||||
)
|
||||
);
|
||||
|
||||
// ── Registros financeiros ──────────────────────────────────────────────
|
||||
const financeiro = payload.registros_financeiros || [];
|
||||
sections.push(renderSectionHeader('6. Registros financeiros', financeiro.length));
|
||||
sections.push(
|
||||
renderTable(
|
||||
[
|
||||
{ label: 'Criado em', key: 'created_at', get: (r) => fmtDate(r.created_at) },
|
||||
{ label: 'Vencimento', key: 'due_date' },
|
||||
{ label: 'Valor', key: 'amount', get: (r) => fmtBRL(r.amount) },
|
||||
{ label: 'Valor final', key: 'final_amount', get: (r) => fmtBRL(r.final_amount) },
|
||||
{ label: 'Status', key: 'status' },
|
||||
{ label: 'Método', key: 'payment_method' }
|
||||
],
|
||||
financeiro
|
||||
)
|
||||
);
|
||||
|
||||
// ── Documentos ─────────────────────────────────────────────────────────
|
||||
const docs = payload.documentos || [];
|
||||
sections.push(renderSectionHeader('7. Documentos', docs.length));
|
||||
sections.push(
|
||||
renderTable(
|
||||
[
|
||||
{ label: 'Nome', key: 'nome_original' },
|
||||
{ label: 'Tipo', key: 'tipo_documento' },
|
||||
{ label: 'Categoria', key: 'categoria' },
|
||||
{ label: 'Tamanho (bytes)', key: 'tamanho_bytes' },
|
||||
{ label: 'Upload em', key: 'uploaded_at', get: (r) => fmtDate(r.uploaded_at) }
|
||||
],
|
||||
docs
|
||||
)
|
||||
);
|
||||
|
||||
// ── Notificações ───────────────────────────────────────────────────────
|
||||
const notifs = payload.notificacoes_enviadas || [];
|
||||
sections.push(renderSectionHeader('8. Notificações enviadas', notifs.length));
|
||||
sections.push(
|
||||
renderTable(
|
||||
[
|
||||
{ label: 'Data', key: 'created_at', get: (r) => fmtDate(r.created_at) },
|
||||
{ label: 'Canal', key: 'channel' },
|
||||
{ label: 'Destinatário', key: 'recipient_address' },
|
||||
{ label: 'Status', key: 'status' }
|
||||
],
|
||||
notifs
|
||||
)
|
||||
);
|
||||
|
||||
// ── Audit trail ────────────────────────────────────────────────────────
|
||||
const audit = payload.audit_trail || [];
|
||||
sections.push(renderSectionHeader('9. Auditoria de alterações', audit.length));
|
||||
sections.push(
|
||||
renderTable(
|
||||
[
|
||||
{ label: 'Data', key: 'created_at', get: (r) => fmtDate(r.created_at) },
|
||||
{ label: 'Ação', key: 'action' },
|
||||
{ label: 'Campos alterados', key: 'changed_fields', get: (r) => (r.changed_fields || []).join(', ') }
|
||||
],
|
||||
audit
|
||||
)
|
||||
);
|
||||
|
||||
// ── Acessos a documentos ───────────────────────────────────────────────
|
||||
const acessos = payload.acessos_a_documentos || [];
|
||||
sections.push(renderSectionHeader('10. Acessos a documentos', acessos.length));
|
||||
sections.push(
|
||||
renderTable(
|
||||
[
|
||||
{ label: 'Data', key: 'acessado_em', get: (r) => fmtDate(r.acessado_em) },
|
||||
{ label: 'Ação', key: 'acao' }
|
||||
],
|
||||
acessos
|
||||
)
|
||||
);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Relatório LGPD - ${htmlEscape(p.nome_completo || 'paciente')}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; color: #1a1a1a; margin: 0; padding: 20px; font-size: 10px; }
|
||||
h1 { font-size: 20px; margin: 0 0 8px; color: #1e3a8a; }
|
||||
h2 { font-size: 13px; margin: 18px 0 6px; color: #1f2937; border-bottom: 1px solid #e5e7eb; padding-bottom: 3px; }
|
||||
h2 .count { display: inline-block; background: #3b82f6; color: #fff; border-radius: 999px; padding: 0 6px; font-size: 9px; margin-left: 6px; vertical-align: middle; }
|
||||
.cover { margin-bottom: 16px; padding: 12px; background: #f3f4f6; border-left: 4px solid #1e3a8a; border-radius: 4px; }
|
||||
.lead { font-size: 10px; color: #4b5563; margin: 0 0 8px; }
|
||||
.meta .kv, .grid .kv { font-size: 9px; }
|
||||
.kv .k { font-weight: 600; color: #6b7280; }
|
||||
.kv .v { color: #111827; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px 12px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 9px; margin-top: 4px; }
|
||||
th { background: #374151; color: #fff; padding: 4px 6px; text-align: left; font-weight: 600; }
|
||||
td { padding: 3px 6px; border-bottom: 1px solid #e5e7eb; }
|
||||
tr:nth-child(even) td { background: #f9fafb; }
|
||||
.empty { font-style: italic; color: #9ca3af; font-size: 9px; margin: 4px 0 8px; }
|
||||
.footer { margin-top: 24px; font-size: 8px; color: #9ca3af; text-align: center; border-top: 1px solid #e5e7eb; padding-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${sections.join('\n')}
|
||||
<div class="footer">
|
||||
AgênciaPSI · Relatório LGPD · Gerado em ${htmlEscape(fmtDate(meta.generated_at))}
|
||||
· O titular tem direito a requerer a correção de dados incompletos, inexatos ou desatualizados (Art. 18, III).
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function downloadLgpdPDF(payload, tenantName, filename) {
|
||||
const html = buildLgpdHTML(payload, tenantName);
|
||||
await htmlToPdfDownload(html, filename || `lgpd-export-${Date.now()}.pdf`);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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> já 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> já 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> já 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> já 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 só 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user