Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesCanaisPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<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 router = useRouter();
const toast = useToast();
const tenantStore = useTenantStore();
const userId = ref(null);
const tenantId = ref(null);
const loading = ref(true);
// ── WhatsApp ──────────────────────────────────────────────────
const whatsapp = ref({
configured: false,
status: null, // 'open' | 'close' | 'connecting' | null
checking: false,
credentials: null
});
// ── SMS ───────────────────────────────────────────────────────
const sms = ref({
hasCredits: false,
balance: 0,
isActive: false
});
// ── Email ─────────────────────────────────────────────────────
const email = ref({
templatesCount: 0
});
// ── Computed status cards ─────────────────────────────────────
const channels = computed(() => [
{
key: 'whatsapp',
label: 'WhatsApp',
icon: 'pi pi-whatsapp',
description: 'Envie lembretes e confirmações via WhatsApp automaticamente.',
route: '/configuracoes/whatsapp',
tag: whatsappTag.value,
details: whatsappDetails.value
},
{
key: 'sms',
label: 'SMS',
icon: 'pi pi-comment',
description: 'Envie SMS para pacientes com créditos pré-pagos.',
route: '/configuracoes/sms',
tag: smsTag.value,
details: smsDetails.value
},
{
key: 'email',
label: 'E-mail',
icon: 'pi pi-envelope',
description: 'Personalize os e-mails enviados automaticamente aos pacientes.',
route: '/configuracoes/email-templates',
tag: emailTag.value,
details: emailDetails.value
}
]);
const whatsappTag = computed(() => {
if (whatsapp.value.checking) return { label: 'Verificando...', severity: 'secondary', icon: 'pi pi-spin pi-spinner' };
if (!whatsapp.value.configured) return { label: 'Não configurado', severity: 'secondary', icon: 'pi pi-info-circle' };
switch (whatsapp.value.status) {
case 'open':
return { label: 'Conectado', severity: 'success', icon: 'pi pi-check-circle' };
case 'connecting':
return { label: 'Conectando...', severity: 'warn', icon: 'pi pi-spin pi-spinner' };
default:
return { label: 'Desconectado', severity: 'danger', icon: 'pi pi-times-circle' };
}
});
const whatsappDetails = computed(() => {
if (!whatsapp.value.configured) return 'Configure as credenciais da Evolution API para conectar.';
if (whatsapp.value.status === 'open') return 'Canal ativo e enviando mensagens.';
return 'Canal configurado mas desconectado. Reconecte pelo QR Code.';
});
const smsTag = computed(() => {
if (!sms.value.hasCredits) return { label: 'Sem créditos', severity: 'secondary', icon: 'pi pi-info-circle' };
if (sms.value.balance <= 0) return { label: 'Sem saldo', severity: 'danger', icon: 'pi pi-times-circle' };
if (sms.value.balance <= 10) return { label: `${sms.value.balance} créditos`, severity: 'warn', icon: 'pi pi-exclamation-triangle' };
return { label: `${sms.value.balance} créditos`, severity: 'success', icon: 'pi pi-check-circle' };
});
const smsDetails = computed(() => {
if (!sms.value.hasCredits) return 'Adquira créditos SMS em Recursos Extras para ativar o canal.';
if (sms.value.balance <= 0) return 'Saldo zerado. Os envios de SMS estão pausados.';
return `Saldo de ${sms.value.balance} créditos disponíveis para envio.`;
});
const emailTag = computed(() => {
return { label: 'Ativo', severity: 'success', icon: 'pi pi-check-circle' };
});
const emailDetails = computed(() => {
if (email.value.templatesCount > 0) {
return `${email.value.templatesCount} template(s) personalizados.`;
}
return 'Usando templates padrão. Personalize se desejar.';
});
// ── Load ──────────────────────────────────────────────────────
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 loadWhatsApp() {
if (!tenantId.value) return;
let { data } = await supabase.from('notification_channels').select('credentials, connection_status').eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
// Fallback owner_id
if (!data && userId.value && userId.value !== tenantId.value) {
const fb = await supabase.from('notification_channels').select('credentials, connection_status').eq('owner_id', userId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
data = fb.data;
}
if (data?.credentials) {
whatsapp.value.configured = true;
whatsapp.value.credentials = data.credentials;
// Tenta verificar status real via Evolution API
whatsapp.value.checking = true;
try {
const res = await fetch(`${data.credentials.api_url}/instance/fetchInstances`, {
headers: { apikey: data.credentials.api_key }
});
if (res.ok) {
const instances = await res.json();
const inst = Array.isArray(instances) ? instances.find((i) => i.instance?.instanceName === data.credentials.instance_name) : null;
whatsapp.value.status = inst?.instance?.status || 'close';
} else {
whatsapp.value.status = 'close';
}
} catch {
whatsapp.value.status = 'close';
} finally {
whatsapp.value.checking = false;
}
}
}
async function loadSms() {
if (!tenantId.value) return;
const { data } = await supabase.from('addon_credits').select('balance, is_active').eq('tenant_id', tenantId.value).eq('addon_type', 'sms').eq('is_active', true).maybeSingle();
if (data) {
sms.value.hasCredits = true;
sms.value.balance = data.balance || 0;
sms.value.isActive = data.is_active;
}
}
async function loadEmail() {
if (!tenantId.value) return;
const { count } = await supabase.from('email_templates_tenant').select('id', { count: 'exact', head: true }).eq('tenant_id', tenantId.value);
email.value.templatesCount = count || 0;
}
function goTo(route) {
router.push(route);
}
// ── Init ──────────────────────────────────────────────────────
onMounted(async () => {
await loadUser();
await Promise.all([loadWhatsApp(), loadSms(), loadEmail()]);
loading.value = false;
});
</script>
<template>
<div class="flex flex-col gap-5">
<!-- Header -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-bell text-xl" />
Canais de Notificação
</div>
</template>
<template #subtitle>Visão geral dos canais de comunicação com seus pacientes.</template>
</Card>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-12 text-surface-500"><i class="pi pi-spin pi-spinner mr-2 text-xl" /> Verificando canais...</div>
<!-- Channel Cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="ch in channels" :key="ch.key" class="border border-surface rounded-xl p-5 flex flex-col gap-4 cursor-pointer hover:shadow-md transition-shadow" @click="goTo(ch.route)">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i :class="ch.icon" class="text-xl text-primary" />
</div>
<span class="font-semibold text-lg">{{ ch.label }}</span>
</div>
<Tag :value="ch.tag.label" :severity="ch.tag.severity" :icon="ch.tag.icon" />
</div>
<!-- Descrição -->
<p class="text-sm text-surface-500 m-0">{{ ch.description }}</p>
<!-- Status detalhe -->
<div class="text-xs text-surface-400 mt-auto">
{{ ch.details }}
</div>
<!-- Link -->
<div class="flex justify-end">
<Button label="Configurar" icon="pi pi-arrow-right" iconPos="right" size="small" text @click.stop="goTo(ch.route)" />
</div>
</div>
</div>
<!-- Resumo rápido -->
<Card>
<template #title>Como funciona</template>
<template #content>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 font-semibold">
<i class="pi pi-whatsapp text-green-500" />
WhatsApp
</div>
<p class="text-surface-500 m-0">Conecte via Evolution API e QR Code. Mensagens automáticas de lembrete, confirmação e cancelamento.</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 font-semibold">
<i class="pi pi-comment text-blue-500" />
SMS
</div>
<p class="text-surface-500 m-0">Funciona com créditos pré-pagos. Adquira pacotes em Recursos Extras. Ideal para pacientes sem WhatsApp.</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 font-semibold">
<i class="pi pi-envelope text-orange-500" />
E-mail
</div>
<p class="text-surface-500 m-0">Ativo por padrão. Personalize os templates de e-mail com o visual da sua clínica.</p>
</div>
</div>
</template>
</Card>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -15,474 +15,554 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts';
const toast = useToast()
const tenantStore = useTenantStore()
const toast = useToast();
const tenantStore = useTenantStore();
const { discounts, loading, error: discountsError, load, save, remove } = usePatientDiscounts()
const { discounts, loading, error: discountsError, load, save, remove } = usePatientDiscounts();
const ownerId = ref(null)
const tenantId = ref(null)
const pageLoading = ref(true)
const patients = ref([])
const ownerId = ref(null);
const tenantId = ref(null);
const pageLoading = ref(true);
const patients = ref([]);
// ── Formulário ────────────────────────────────────────────────────────
const emptyForm = () => ({
patient_id: null,
discount_pct: 0,
discount_flat: 0,
reason: '',
active_from: null,
active_to: null,
})
patient_id: null,
discount_pct: 0,
discount_flat: 0,
reason: '',
active_from: null,
active_to: null
});
const newForm = ref(emptyForm())
const addingNew = ref(false)
const savingNew = ref(false)
const newForm = ref(emptyForm());
const addingNew = ref(false);
const savingNew = ref(false);
// ── Edição inline ─────────────────────────────────────────────────────
const editingId = ref(null)
const editForm = ref({})
const savingEdit = ref(false)
const editingId = ref(null);
const editForm = ref({});
const savingEdit = ref(false);
// ── Lookup de nome do paciente ────────────────────────────────────────
const patientMap = computed(() => {
const map = {}
for (const p of patients.value) map[p.id] = p.nome_completo
return map
})
const map = {};
for (const p of patients.value) map[p.id] = p.nome_completo;
return map;
});
function patientName (pid) {
return patientMap.value[pid] || pid || '—'
function patientName(pid) {
return patientMap.value[pid] || pid || '—';
}
// ── Editar ────────────────────────────────────────────────────────────
function startEdit (disc) {
editingId.value = disc.id
editForm.value = {
id: disc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: disc.patient_id,
discount_pct: disc.discount_pct != null ? Number(disc.discount_pct) : 0,
discount_flat: disc.discount_flat != null ? Number(disc.discount_flat) : 0,
reason: disc.reason ?? '',
active_from: disc.active_from ? new Date(disc.active_from) : null,
active_to: disc.active_to ? new Date(disc.active_to) : null,
}
function startEdit(disc) {
editingId.value = disc.id;
editForm.value = {
id: disc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: disc.patient_id,
discount_pct: disc.discount_pct != null ? Number(disc.discount_pct) : 0,
discount_flat: disc.discount_flat != null ? Number(disc.discount_flat) : 0,
reason: disc.reason ?? '',
active_from: disc.active_from ? new Date(disc.active_from) : null,
active_to: disc.active_to ? new Date(disc.active_to) : null
};
}
function cancelEdit () {
editingId.value = null
editForm.value = {}
function cancelEdit() {
editingId.value = null;
editForm.value = {};
}
async function saveEdit () {
if (!editForm.value.discount_pct && !editForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 })
return
}
savingEdit.value = true
try {
await save({
...editForm.value,
discount_pct: editForm.value.discount_pct ?? 0,
discount_flat: editForm.value.discount_flat ?? 0,
reason: editForm.value.reason?.trim() || null,
active_from: editForm.value.active_from ? editForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: editForm.value.active_to ? editForm.value.active_to.toISOString().slice(0, 10) : null,
})
await load(ownerId.value)
cancelEdit()
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Desconto atualizado.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao salvar.', life: 4000 })
} finally {
savingEdit.value = false
}
async function saveEdit() {
if (!editForm.value.discount_pct && !editForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 });
return;
}
savingEdit.value = true;
try {
await save({
...editForm.value,
discount_pct: editForm.value.discount_pct ?? 0,
discount_flat: editForm.value.discount_flat ?? 0,
reason: editForm.value.reason?.trim() || null,
active_from: editForm.value.active_from ? editForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: editForm.value.active_to ? editForm.value.active_to.toISOString().slice(0, 10) : null
});
await load(ownerId.value);
cancelEdit();
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Desconto atualizado.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao salvar.', life: 4000 });
} finally {
savingEdit.value = false;
}
}
// ── Novo desconto ─────────────────────────────────────────────────────
async function saveNew () {
if (!newForm.value.patient_id) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Selecione um paciente.', life: 3000 })
return
}
if (!newForm.value.discount_pct && !newForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 })
return
}
savingNew.value = true
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: newForm.value.patient_id,
discount_pct: newForm.value.discount_pct ?? 0,
discount_flat: newForm.value.discount_flat ?? 0,
reason: newForm.value.reason?.trim() || null,
active_from: newForm.value.active_from ? newForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: newForm.value.active_to ? newForm.value.active_to.toISOString().slice(0, 10) : null,
active: true,
})
await load(ownerId.value)
newForm.value = emptyForm()
addingNew.value = false
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Desconto criado com sucesso.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao criar.', life: 4000 })
} finally {
savingNew.value = false
}
async function saveNew() {
if (!newForm.value.patient_id) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Selecione um paciente.', life: 3000 });
return;
}
if (!newForm.value.discount_pct && !newForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 });
return;
}
savingNew.value = true;
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: newForm.value.patient_id,
discount_pct: newForm.value.discount_pct ?? 0,
discount_flat: newForm.value.discount_flat ?? 0,
reason: newForm.value.reason?.trim() || null,
active_from: newForm.value.active_from ? newForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: newForm.value.active_to ? newForm.value.active_to.toISOString().slice(0, 10) : null,
active: true
});
await load(ownerId.value);
newForm.value = emptyForm();
addingNew.value = false;
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Desconto criado com sucesso.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao criar.', life: 4000 });
} finally {
savingNew.value = false;
}
}
// ── Desativar (soft-delete) ───────────────────────────────────────────
async function confirmRemove (id) {
try {
await remove(id)
toast.add({ severity: 'success', summary: 'Desativado', detail: 'Desconto desativado.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao desativar.', life: 4000 })
}
async function confirmRemove(id) {
try {
await remove(id);
toast.add({ severity: 'success', summary: 'Desativado', detail: 'Desconto desativado.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao desativar.', life: 4000 });
}
}
// ── Helpers de exibição ───────────────────────────────────────────────
function fmtBRL (v) {
if (v == null || v === '' || Number(v) === 0) return null
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
function fmtBRL(v) {
if (v == null || v === '' || Number(v) === 0) return null;
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtPct (v) {
if (v == null || Number(v) === 0) return null
return `${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}%`
function fmtPct(v) {
if (v == null || Number(v) === 0) return null;
return `${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}%`;
}
function fmtDate (v) {
if (!v) return null
const d = new Date(v)
return d.toLocaleDateString('pt-BR')
function fmtDate(v) {
if (!v) return null;
const d = new Date(v);
return d.toLocaleDateString('pt-BR');
}
// ── Mount ─────────────────────────────────────────────────────────────
onMounted(async () => {
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
if (!uid) return
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id;
if (!uid) return;
ownerId.value = uid
tenantId.value = tenantStore.activeTenantId || null
ownerId.value = uid;
tenantId.value = tenantStore.activeTenantId || null;
const [, { data: pData }] = await Promise.all([
load(uid),
supabase
.from('patients')
.select('id, nome_completo')
.eq('owner_id', uid)
.eq('status', 'Ativo')
.order('nome_completo', { ascending: true }),
])
const [, { data: pData }] = await Promise.all([load(uid), supabase.from('patients').select('id, nome_completo').eq('owner_id', uid).eq('status', 'Ativo').order('nome_completo', { ascending: true })]);
patients.value = pData || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
} finally {
pageLoading.value = false
}
})
patients.value = pData || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 });
} finally {
pageLoading.value = false;
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Descontos por Paciente</div>
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
</div>
<div class="cfg-subheader__actions">
<Button
label="Novo desconto"
icon="pi pi-plus"
size="small"
:disabled="pageLoading || addingNew"
class="rounded-full"
@click="
addingNew = true;
cancelEdit();
"
/>
</div>
</div>
<div class="flex flex-col gap-3">
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton width="11rem" height="12px" />
</div>
<div v-for="n in 3" :key="n" class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border)] last:border-b-0">
<div class="flex flex-col gap-2 flex-1">
<Skeleton :width="n % 2 === 0 ? '14rem' : '10rem'" height="11px" />
<Skeleton width="8rem" height="10px" />
</div>
<Skeleton width="4rem" height="1.4rem" border-radius="999px" />
</div>
</div>
<AppLoadingPhrases action="Carregando descontos por paciente..." containerClass="py-6" />
</template>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Descontos por Paciente</div>
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Novo desconto" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
<template v-else>
<!-- Lista + form -->
<div v-if="discounts.length || addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-percentage" /></div>
<span class="cfg-wrap__title">Descontos cadastrados</span>
<span class="cfg-wrap__count">{{ discounts.length }}</span>
</div>
<div class="dsc-list">
<template v-for="disc in discounts" :key="disc.id">
<!-- Edição inline -->
<div v-if="editingId === disc.id" class="dsc-form-row dsc-form-row--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="editForm.patient_id" inputId="edit-patient" :options="patients" optionLabel="nome_completo" optionValue="id" disabled class="w-full" />
<label for="edit-patient">Paciente</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_pct" inputId="edit-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_flat" inputId="edit-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_from" inputId="edit-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_to" inputId="edit-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="editForm.reason" inputId="edit-reason" class="w-full" />
<label for="edit-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
<!-- Leitura -->
<div v-else class="dsc-row">
<div class="dsc-row__info">
<div class="font-semibold text-sm">{{ patientName(disc.patient_id) }}</div>
<div class="flex flex-wrap gap-1.5 mt-1">
<span v-if="fmtPct(disc.discount_pct)" class="dsc-badge">{{ fmtPct(disc.discount_pct) }}</span>
<span v-if="fmtBRL(disc.discount_flat)" class="dsc-badge">{{ fmtBRL(disc.discount_flat) }}</span>
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
<span v-if="disc.active_from || disc.active_to"> {{ fmtDate(disc.active_from) || 'Indefinido' }} {{ fmtDate(disc.active_to) || 'Indefinido' }} </span>
<span v-else>Vigência indefinida</span>
</div>
<div v-if="disc.reason" class="text-xs text-[var(--text-color-secondary)] italic mt-0.5">{{ disc.reason }}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tag :value="disc.active ? 'Ativo' : 'Inativo'" :severity="disc.active ? 'success' : 'secondary'" />
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
text
v-tooltip.top="'Editar'"
@click="
startEdit(disc);
addingNew = false;
"
/>
<Button v-if="disc.active" icon="pi pi-ban" size="small" severity="danger" text v-tooltip.top="'Desativar'" @click="confirmRemove(disc.id)" />
</div>
</div>
</template>
<!-- Form novo desconto -->
<div v-if="addingNew" class="dsc-form-row dsc-form-row--new">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="newForm.patient_id" inputId="new-patient" :options="patients" optionLabel="nome_completo" optionValue="id" filter class="w-full" />
<label for="new-patient">Paciente *</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_pct" inputId="new-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="new-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_flat" inputId="new-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="new-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_from" inputId="new-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_to" inputId="new-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="newForm.reason" inputId="new-reason" class="w-full" />
<label for="new-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button
label="Cancelar"
icon="pi pi-times"
size="small"
severity="secondary"
outlined
class="rounded-full"
@click="
addingNew = false;
newForm = emptyForm();
"
/>
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="savingNew" class="rounded-full" @click="saveNew" />
</div>
</div>
</div>
</div>
<!-- Estado vazio -->
<div v-else class="cfg-empty">
<i class="pi pi-percentage text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum desconto cadastrado ainda.</div>
<Button label="Adicionar primeiro desconto" icon="pi pi-plus" outlined size="small" class="rounded-full" @click="addingNew = true" />
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm"> Descontos ativos são aplicados automaticamente ao adicionar serviços em sessões do paciente correspondente. Você ainda pode ajustá-los manualmente no diálogo de cada evento. </span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton width="11rem" height="12px" />
</div>
<div v-for="n in 3" :key="n" class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border)] last:border-b-0">
<div class="flex flex-col gap-2 flex-1">
<Skeleton :width="n % 2 === 0 ? '14rem' : '10rem'" height="11px" />
<Skeleton width="8rem" height="10px" />
</div>
<Skeleton width="4rem" height="1.4rem" border-radius="999px" />
</div>
</div>
<AppLoadingPhrases action="Carregando descontos por paciente..." containerClass="py-6" />
</template>
<template v-else>
<!-- Lista + form -->
<div v-if="discounts.length || addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-percentage" /></div>
<span class="cfg-wrap__title">Descontos cadastrados</span>
<span class="cfg-wrap__count">{{ discounts.length }}</span>
</div>
<div class="dsc-list">
<template v-for="disc in discounts" :key="disc.id">
<!-- Edição inline -->
<div v-if="editingId === disc.id" class="dsc-form-row dsc-form-row--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="editForm.patient_id" inputId="edit-patient" :options="patients" optionLabel="nome_completo" optionValue="id" disabled class="w-full" />
<label for="edit-patient">Paciente</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_pct" inputId="edit-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_flat" inputId="edit-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_from" inputId="edit-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_to" inputId="edit-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="editForm.reason" inputId="edit-reason" class="w-full" />
<label for="edit-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
<!-- Leitura -->
<div v-else class="dsc-row">
<div class="dsc-row__info">
<div class="font-semibold text-sm">{{ patientName(disc.patient_id) }}</div>
<div class="flex flex-wrap gap-1.5 mt-1">
<span v-if="fmtPct(disc.discount_pct)" class="dsc-badge">{{ fmtPct(disc.discount_pct) }}</span>
<span v-if="fmtBRL(disc.discount_flat)" class="dsc-badge">{{ fmtBRL(disc.discount_flat) }}</span>
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
<span v-if="disc.active_from || disc.active_to">
{{ fmtDate(disc.active_from) || 'Indefinido' }} {{ fmtDate(disc.active_to) || 'Indefinido' }}
</span>
<span v-else>Vigência indefinida</span>
</div>
<div v-if="disc.reason" class="text-xs text-[var(--text-color-secondary)] italic mt-0.5">{{ disc.reason }}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tag :value="disc.active ? 'Ativo' : 'Inativo'" :severity="disc.active ? 'success' : 'secondary'" />
<Button icon="pi pi-pencil" size="small" severity="secondary" text v-tooltip.top="'Editar'" @click="startEdit(disc); addingNew = false" />
<Button v-if="disc.active" icon="pi pi-ban" size="small" severity="danger" text v-tooltip.top="'Desativar'" @click="confirmRemove(disc.id)" />
</div>
</div>
</template>
<!-- Form novo desconto -->
<div v-if="addingNew" class="dsc-form-row dsc-form-row--new">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="newForm.patient_id" inputId="new-patient" :options="patients" optionLabel="nome_completo" optionValue="id" filter class="w-full" />
<label for="new-patient">Paciente *</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_pct" inputId="new-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="new-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_flat" inputId="new-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="new-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_from" inputId="new-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_to" inputId="new-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="newForm.reason" inputId="new-reason" class="w-full" />
<label for="new-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="savingNew" class="rounded-full" @click="saveNew" />
</div>
</div>
</div>
</div>
<!-- Estado vazio -->
<div v-else class="cfg-empty">
<i class="pi pi-percentage text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum desconto cadastrado ainda.</div>
<Button label="Adicionar primeiro desconto" icon="pi pi-plus" outlined size="small" class="rounded-full" @click="addingNew = true" />
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm">
Descontos ativos são aplicados automaticamente ao adicionar serviços em sessões do paciente correspondente.
Você ainda pode ajustá-los manualmente no diálogo de cada evento.
</span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
</template>
<style scoped>
/* ── Subheader degradê ────────────────────────────── */
.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;
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;
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;
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;
}
.cfg-subheader__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.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; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.8rem;
}
.cfg-wrap__title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text-color);
flex: 1;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); flex: 1; }
.cfg-wrap__count {
font-size: 0.7rem; font-weight: 700;
background: var(--primary-color,#6366f1); color: #fff;
padding: 1px 8px; border-radius: 999px; flex-shrink: 0;
font-size: 0.7rem;
font-weight: 700;
background: var(--primary-color, #6366f1);
color: #fff;
padding: 1px 8px;
border-radius: 999px;
flex-shrink: 0;
}
/* ── Lista de descontos ───────────────────────────── */
.dsc-list { display: flex; flex-direction: column; }
.dsc-list {
display: flex;
flex-direction: column;
}
/* Linha de leitura */
.dsc-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
transition: background 0.1s; flex-wrap: wrap;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
transition: background 0.1s;
flex-wrap: wrap;
}
.dsc-row:last-child {
border-bottom: none;
}
.dsc-row:hover {
background: var(--surface-hover);
}
.dsc-row__info {
flex: 1;
min-width: 0;
}
.dsc-row:last-child { border-bottom: none; }
.dsc-row:hover { background: var(--surface-hover); }
.dsc-row__info { flex: 1; min-width: 0; }
/* Badge de valor */
.dsc-badge {
font-size: 0.75rem; font-weight: 600;
color: var(--primary-color,#6366f1);
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
padding: 0.15rem 0.5rem; border-radius: 6px;
white-space: nowrap;
font-size: 0.75rem;
font-weight: 600;
color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color, #6366f1) 10%, transparent);
padding: 0.15rem 0.5rem;
border-radius: 6px;
white-space: nowrap;
}
/* Form de adição/edição */
.dsc-form-row {
padding: 1rem;
border-bottom: 1px solid var(--surface-border);
padding: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.dsc-form-row:last-child {
border-bottom: none;
}
.dsc-form-row:last-child { border-bottom: none; }
.dsc-form-row--editing {
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-left: 3px solid color-mix(in srgb, var(--primary-color,#6366f1) 50%, transparent);
background: color-mix(in srgb, var(--primary-color, #6366f1) 3%, var(--surface-card));
border-left: 3px solid color-mix(in srgb, var(--primary-color, #6366f1) 50%, transparent);
}
.dsc-form-row--new {
background: var(--surface-ground);
border-top: 1px dashed var(--surface-border);
border-bottom: none;
background: var(--surface-ground);
border-top: 1px dashed var(--surface-border);
border-bottom: none;
}
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2.5rem 1rem;
text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px;
background: var(--surface-ground);
}
</style>
</style>
File diff suppressed because it is too large Load Diff
@@ -15,351 +15,368 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { useFinancialExceptions } from '@/features/agenda/composables/useFinancialExceptions'
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
import { useFinancialExceptions } from '@/features/agenda/composables/useFinancialExceptions';
const toast = useToast()
const tenantStore = useTenantStore()
const toast = useToast();
const tenantStore = useTenantStore();
const { exceptions, loading, error: exceptionsError, load, save } = useFinancialExceptions()
const { exceptions, loading, error: exceptionsError, load, save } = useFinancialExceptions();
const ownerId = ref(null)
const tenantId = ref(null)
const pageLoading = ref(true)
const ownerId = ref(null);
const tenantId = ref(null);
const pageLoading = ref(true);
// Tipos de exceção fixos
const exceptionTypes = [
{ value: 'patient_no_show', label: 'Paciente não compareceu' },
{ value: 'patient_cancellation', label: 'Cancelamento pelo paciente' },
{ value: 'professional_cancellation', label: 'Cancelamento pelo profissional' },
]
{ value: 'patient_no_show', label: 'Paciente não compareceu' },
{ value: 'patient_cancellation', label: 'Cancelamento pelo paciente' },
{ value: 'professional_cancellation', label: 'Cancelamento pelo profissional' }
];
// Opções de modo de cobrança
const chargeModeOptions = [
{ value: 'none', label: 'Não cobrar' },
{ value: 'full', label: 'Sessão completa' },
{ value: 'fixed_fee', label: 'Taxa fixa' },
{ value: 'percentage', label: 'Percentual da sessão' },
]
{ value: 'none', label: 'Não cobrar' },
{ value: 'full', label: 'Sessão completa' },
{ value: 'fixed_fee', label: 'Taxa fixa' },
{ value: 'percentage', label: 'Percentual da sessão' }
];
// Severidade do badge por charge_mode
const chargeModeSeverity = {
none: 'secondary',
full: 'danger',
fixed_fee: 'warn',
percentage: 'info',
}
none: 'secondary',
full: 'danger',
fixed_fee: 'warn',
percentage: 'info'
};
// Lookup: para cada exception_type, o registro ativo (owner > clínica)
// Prioridade: registro próprio do owner > registro global (owner_id IS NULL)
function recordFor (type) {
const own = exceptions.value.find(e => e.exception_type === type && e.owner_id !== null)
const global = exceptions.value.find(e => e.exception_type === type && e.owner_id === null)
return own ?? global ?? null
function recordFor(type) {
const own = exceptions.value.find((e) => e.exception_type === type && e.owner_id !== null);
const global = exceptions.value.find((e) => e.exception_type === type && e.owner_id === null);
return own ?? global ?? null;
}
function isGlobalRecord (rec) {
return rec?.owner_id === null
function isGlobalRecord(rec) {
return rec?.owner_id === null;
}
// Texto descritivo do charge_mode
function chargeModeLabel (mode) {
return chargeModeOptions.find(o => o.value === mode)?.label ?? mode ?? '—'
function chargeModeLabel(mode) {
return chargeModeOptions.find((o) => o.value === mode)?.label ?? mode ?? '—';
}
function fmtBRL (v) {
if (v == null || v === '') return '—'
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
function fmtBRL(v) {
if (v == null || v === '') return '—';
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function summaryFor (rec) {
if (!rec) return 'Não configurado (padrão: não cobrar)'
switch (rec.charge_mode) {
case 'none': return 'Não cobrar'
case 'full': return 'Cobrar sessão completa'
case 'fixed_fee': return `Taxa fixa: ${fmtBRL(rec.charge_value)}`
case 'percentage': return `${rec.charge_pct ?? 0}% da sessão`
default: return '—'
}
function summaryFor(rec) {
if (!rec) return 'Não configurado (padrão: não cobrar)';
switch (rec.charge_mode) {
case 'none':
return 'Não cobrar';
case 'full':
return 'Cobrar sessão completa';
case 'fixed_fee':
return `Taxa fixa: ${fmtBRL(rec.charge_value)}`;
case 'percentage':
return `${rec.charge_pct ?? 0}% da sessão`;
default:
return '—';
}
}
// Edição inline
const editingType = ref(null)
const editForm = ref({})
const savingEdit = ref(false)
const editingType = ref(null);
const editForm = ref({});
const savingEdit = ref(false);
function startEdit (type) {
const rec = recordFor(type)
editingType.value = type
editForm.value = {
id: rec?.id ?? null,
exception_type: type,
charge_mode: rec?.charge_mode ?? 'none',
charge_value: rec?.charge_value != null ? Number(rec.charge_value) : null,
charge_pct: rec?.charge_pct != null ? Number(rec.charge_pct) : null,
min_hours_notice: rec?.min_hours_notice != null ? Number(rec.min_hours_notice) : null,
}
function startEdit(type) {
const rec = recordFor(type);
editingType.value = type;
editForm.value = {
id: rec?.id ?? null,
exception_type: type,
charge_mode: rec?.charge_mode ?? 'none',
charge_value: rec?.charge_value != null ? Number(rec.charge_value) : null,
charge_pct: rec?.charge_pct != null ? Number(rec.charge_pct) : null,
min_hours_notice: rec?.min_hours_notice != null ? Number(rec.min_hours_notice) : null
};
}
function cancelEdit () {
editingType.value = null
editForm.value = {}
function cancelEdit() {
editingType.value = null;
editForm.value = {};
}
async function saveEdit () {
savingEdit.value = true
try {
await save({
id: editForm.value.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
exception_type: editForm.value.exception_type,
charge_mode: editForm.value.charge_mode,
charge_value: editForm.value.charge_mode === 'fixed_fee' ? (editForm.value.charge_value ?? null) : null,
charge_pct: editForm.value.charge_mode === 'percentage' ? (editForm.value.charge_pct ?? null) : null,
min_hours_notice: editForm.value.exception_type === 'patient_cancellation'
? (editForm.value.min_hours_notice ?? null)
: null,
})
await load(ownerId.value)
cancelEdit()
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: exceptionsError.value || 'Falha ao salvar.', life: 4000 })
} finally {
savingEdit.value = false
}
async function saveEdit() {
savingEdit.value = true;
try {
await save({
id: editForm.value.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
exception_type: editForm.value.exception_type,
charge_mode: editForm.value.charge_mode,
charge_value: editForm.value.charge_mode === 'fixed_fee' ? (editForm.value.charge_value ?? null) : null,
charge_pct: editForm.value.charge_mode === 'percentage' ? (editForm.value.charge_pct ?? null) : null,
min_hours_notice: editForm.value.exception_type === 'patient_cancellation' ? (editForm.value.min_hours_notice ?? null) : null
});
await load(ownerId.value);
cancelEdit();
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: exceptionsError.value || 'Falha ao salvar.', life: 4000 });
} finally {
savingEdit.value = false;
}
}
// Computed auxiliares usados no template
const showChargeValue = computed(() => editForm.value.charge_mode === 'fixed_fee')
const showChargePct = computed(() => editForm.value.charge_mode === 'percentage')
const showMinHours = computed(() => editForm.value.exception_type === 'patient_cancellation')
const showChargeValue = computed(() => editForm.value.charge_mode === 'fixed_fee');
const showChargePct = computed(() => editForm.value.charge_mode === 'percentage');
const showMinHours = computed(() => editForm.value.exception_type === 'patient_cancellation');
// Mount
onMounted(async () => {
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
if (!uid) return
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id;
if (!uid) return;
ownerId.value = uid
tenantId.value = tenantStore.activeTenantId || null
ownerId.value = uid;
tenantId.value = tenantStore.activeTenantId || null;
await load(uid)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
} finally {
pageLoading.value = false
}
})
await load(uid);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 });
} finally {
pageLoading.value = false;
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-exclamation-triangle" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Exceções Financeiras</div>
<div class="cfg-subheader__sub">Defina o que cobrar em cancelamentos, faltas e situações excepcionais</div>
</div>
</div>
<div class="flex flex-col gap-3">
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div v-for="n in 3" :key="n" class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton :width="n === 1 ? '13rem' : n === 2 ? '11rem' : '15rem'" height="12px" />
<Skeleton width="4rem" height="1.4rem" border-radius="999px" class="ml-auto" />
</div>
<div class="px-4 py-3">
<Skeleton width="16rem" height="10px" />
</div>
</div>
<AppLoadingPhrases action="Carregando exceções financeiras..." containerClass="py-6" />
</template>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-exclamation-triangle" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Exceções Financeiras</div>
<div class="cfg-subheader__sub">Defina o que cobrar em cancelamentos, faltas e situações excepcionais</div>
</div>
<template v-else>
<!-- Um card por tipo de exceção -->
<div v-for="et in exceptionTypes" :key="et.value" class="cfg-wrap">
<!-- Cabeçalho do card -->
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-exclamation-triangle" /></div>
<span class="cfg-wrap__title">{{ et.label }}</span>
<div class="ml-auto flex items-center gap-2 shrink-0">
<template v-if="recordFor(et.value)">
<Tag :value="chargeModeLabel(recordFor(et.value)?.charge_mode)" :severity="chargeModeSeverity[recordFor(et.value)?.charge_mode] ?? 'secondary'" />
<Tag v-if="isGlobalRecord(recordFor(et.value))" value="Regra da clínica" severity="secondary" />
</template>
<Button v-if="editingType !== et.value && !isGlobalRecord(recordFor(et.value))" label="Configurar" icon="pi pi-cog" size="small" severity="secondary" outlined class="rounded-full" @click="startEdit(et.value)" />
</div>
</div>
<!-- Modo leitura -->
<div v-if="editingType !== et.value" class="exc-read">
<template v-if="recordFor(et.value)">
<div class="text-sm text-[var(--text-color-secondary)]">
{{ summaryFor(recordFor(et.value)) }}
<span v-if="et.value === 'patient_cancellation' && recordFor(et.value)?.min_hours_notice"> cobrar apenas se cancelado com menos de {{ recordFor(et.value).min_hours_notice }}h de antecedência </span>
</div>
</template>
<div v-else class="text-sm text-[var(--text-color-secondary)] italic">Não configurado comportamento padrão: não cobrar.</div>
</div>
<!-- Modo edição -->
<div v-else class="exc-edit">
<!-- Modo de cobrança -->
<div>
<label class="exc-label">Modo de cobrança</label>
<SelectButton v-model="editForm.charge_mode" :options="chargeModeOptions" optionLabel="label" optionValue="value" class="flex-wrap mt-1" />
</div>
<div class="grid grid-cols-12 gap-3">
<!-- Taxa fixa -->
<div v-if="showChargeValue" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_value" inputId="edit-charge-value" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-charge-value">Taxa fixa (R$)</label>
</FloatLabel>
</div>
<!-- Percentual -->
<div v-if="showChargePct" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_pct" inputId="edit-charge-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-charge-pct">Percentual da sessão (%)</label>
</FloatLabel>
</div>
<!-- Antecedência mínima -->
<div v-if="showMinHours" class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
<InputNumber v-model="editForm.min_hours_notice" inputId="edit-min-hours" :min="0" :max="720" suffix=" h" fluid showButtons />
<label for="edit-min-hours">Cobrar se cancelado com menos de X horas (vazio = sempre)</label>
</FloatLabel>
<small class="text-[var(--text-color-secondary)] opacity-70 mt-1 block text-xs"> Deixe em branco para cobrar independentemente da antecedência. </small>
</div>
</div>
<!-- Botões na linha separada -->
<div class="flex gap-2 justify-end mt-1">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm"> Estas configurações definem o comportamento padrão de cobrança. Você pode ajustá-las individualmente em cada evento na agenda. </span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div v-for="n in 3" :key="n" class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton :width="n === 1 ? '13rem' : n === 2 ? '11rem' : '15rem'" height="12px" />
<Skeleton width="4rem" height="1.4rem" border-radius="999px" class="ml-auto" />
</div>
<div class="px-4 py-3">
<Skeleton width="16rem" height="10px" />
</div>
</div>
<AppLoadingPhrases action="Carregando exceções financeiras..." containerClass="py-6" />
</template>
<template v-else>
<!-- Um card por tipo de exceção -->
<div v-for="et in exceptionTypes" :key="et.value" class="cfg-wrap">
<!-- Cabeçalho do card -->
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-exclamation-triangle" /></div>
<span class="cfg-wrap__title">{{ et.label }}</span>
<div class="ml-auto flex items-center gap-2 shrink-0">
<template v-if="recordFor(et.value)">
<Tag
:value="chargeModeLabel(recordFor(et.value)?.charge_mode)"
:severity="chargeModeSeverity[recordFor(et.value)?.charge_mode] ?? 'secondary'"
/>
<Tag v-if="isGlobalRecord(recordFor(et.value))" value="Regra da clínica" severity="secondary" />
</template>
<Button
v-if="editingType !== et.value && !isGlobalRecord(recordFor(et.value))"
label="Configurar"
icon="pi pi-cog"
size="small"
severity="secondary"
outlined
class="rounded-full"
@click="startEdit(et.value)"
/>
</div>
</div>
<!-- Modo leitura -->
<div v-if="editingType !== et.value" class="exc-read">
<template v-if="recordFor(et.value)">
<div class="text-sm text-[var(--text-color-secondary)]">
{{ summaryFor(recordFor(et.value)) }}
<span v-if="et.value === 'patient_cancellation' && recordFor(et.value)?.min_hours_notice">
cobrar apenas se cancelado com menos de {{ recordFor(et.value).min_hours_notice }}h de antecedência
</span>
</div>
</template>
<div v-else class="text-sm text-[var(--text-color-secondary)] italic">
Não configurado comportamento padrão: não cobrar.
</div>
</div>
<!-- Modo edição -->
<div v-else class="exc-edit">
<!-- Modo de cobrança -->
<div>
<label class="exc-label">Modo de cobrança</label>
<SelectButton
v-model="editForm.charge_mode"
:options="chargeModeOptions"
optionLabel="label"
optionValue="value"
class="flex-wrap mt-1"
/>
</div>
<div class="grid grid-cols-12 gap-3">
<!-- Taxa fixa -->
<div v-if="showChargeValue" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_value" inputId="edit-charge-value" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-charge-value">Taxa fixa (R$)</label>
</FloatLabel>
</div>
<!-- Percentual -->
<div v-if="showChargePct" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_pct" inputId="edit-charge-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-charge-pct">Percentual da sessão (%)</label>
</FloatLabel>
</div>
<!-- Antecedência mínima -->
<div v-if="showMinHours" class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
<InputNumber v-model="editForm.min_hours_notice" inputId="edit-min-hours" :min="0" :max="720" suffix=" h" fluid showButtons />
<label for="edit-min-hours">Cobrar se cancelado com menos de X horas (vazio = sempre)</label>
</FloatLabel>
<small class="text-[var(--text-color-secondary)] opacity-70 mt-1 block text-xs">
Deixe em branco para cobrar independentemente da antecedência.
</small>
</div>
</div>
<!-- Botões na linha separada -->
<div class="flex gap-2 justify-end mt-1">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm">
Estas configurações definem o comportamento padrão de cobrança. Você pode
ajustá-las individualmente em cada evento na agenda.
</span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
</template>
<style scoped>
/* ── Subheader degradê ────────────────────────────── */
.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;
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;
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;
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;
}
.cfg-subheader__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.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; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground); flex-wrap: wrap;
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
flex-wrap: wrap;
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.8rem;
}
.cfg-wrap__title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text-color);
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
/* ── Leitura ──────────────────────────────────────── */
.exc-read {
padding: 0.75rem 1rem;
padding: 0.75rem 1rem;
}
/* ── Edição ───────────────────────────────────────── */
.exc-edit {
padding: 1rem;
display: flex; flex-direction: column; gap: 1rem;
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
background: color-mix(in srgb, var(--primary-color, #6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color, #6366f1) 40%, transparent);
}
/* ── Label ────────────────────────────────────────── */
.exc-label {
display: block; font-size: 0.75rem; font-weight: 600;
color: var(--text-color-secondary); margin-bottom: 0.375rem;
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary);
margin-bottom: 0.375rem;
}
</style>
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,402 +15,467 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { useServices } from '@/features/agenda/composables/useServices'
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
import { useServices } from '@/features/agenda/composables/useServices';
const toast = useToast()
const tenantStore = useTenantStore()
const toast = useToast();
const tenantStore = useTenantStore();
const { services, loading, error: servicesError, load, save, toggle, remove } = useServices()
const { services, loading, error: servicesError, load, save, toggle, remove } = useServices();
const ownerId = ref(null)
const tenantId = ref(null)
const slotMode = ref('fixed')
const pageLoading = ref(true)
const ownerId = ref(null);
const tenantId = ref(null);
const slotMode = ref('fixed');
const pageLoading = ref(true);
const isDynamic = computed(() => slotMode.value === 'dynamic')
const isDynamic = computed(() => slotMode.value === 'dynamic');
// Formulário novo serviço
const emptyForm = () => ({ name: '', description: '', price: null, duration_min: null })
const emptyForm = () => ({ name: '', description: '', price: null, duration_min: null });
const newForm = ref(emptyForm())
const addingNew = ref(false)
const savingNew = ref(false)
const newForm = ref(emptyForm());
const addingNew = ref(false);
const savingNew = ref(false);
// Edição inline
const editingId = ref(null)
const editForm = ref({})
const savingEdit = ref(false)
const editingId = ref(null);
const editForm = ref({});
const savingEdit = ref(false);
function startEdit (svc) {
editingId.value = svc.id
editForm.value = {
id: svc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: svc.name,
description: svc.description ?? '',
price: svc.price != null ? Number(svc.price) : null,
duration_min: svc.duration_min ?? null,
}
function startEdit(svc) {
editingId.value = svc.id;
editForm.value = {
id: svc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: svc.name,
description: svc.description ?? '',
price: svc.price != null ? Number(svc.price) : null,
duration_min: svc.duration_min ?? null
};
}
function cancelEdit () {
editingId.value = null
editForm.value = {}
function cancelEdit() {
editingId.value = null;
editForm.value = {};
}
async function saveEdit () {
if (!editForm.value.name?.trim() || editForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 })
return
}
savingEdit.value = true
try {
await save({
...editForm.value,
name: editForm.value.name.trim(),
description: editForm.value.description?.trim() || null,
duration_min: isDynamic.value ? (editForm.value.duration_min ?? null) : null,
})
await load(ownerId.value)
cancelEdit()
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Serviço atualizado.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao salvar.', life: 4000 })
} finally {
savingEdit.value = false
}
async function saveEdit() {
if (!editForm.value.name?.trim() || editForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 });
return;
}
savingEdit.value = true;
try {
await save({
...editForm.value,
name: editForm.value.name.trim(),
description: editForm.value.description?.trim() || null,
duration_min: isDynamic.value ? (editForm.value.duration_min ?? null) : null
});
await load(ownerId.value);
cancelEdit();
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Serviço atualizado.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao salvar.', life: 4000 });
} finally {
savingEdit.value = false;
}
}
async function saveNew () {
if (!newForm.value.name?.trim() || newForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 })
return
}
savingNew.value = true
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: newForm.value.name.trim(),
description: newForm.value.description?.trim() || null,
price: newForm.value.price,
duration_min: isDynamic.value ? (newForm.value.duration_min ?? null) : null,
})
await load(ownerId.value)
newForm.value = emptyForm()
addingNew.value = false
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Serviço criado com sucesso.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao criar.', life: 4000 })
} finally {
savingNew.value = false
}
async function saveNew() {
if (!newForm.value.name?.trim() || newForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 });
return;
}
savingNew.value = true;
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: newForm.value.name.trim(),
description: newForm.value.description?.trim() || null,
price: newForm.value.price,
duration_min: isDynamic.value ? (newForm.value.duration_min ?? null) : null
});
await load(ownerId.value);
newForm.value = emptyForm();
addingNew.value = false;
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Serviço criado com sucesso.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao criar.', life: 4000 });
} finally {
savingNew.value = false;
}
}
async function toggleService (svc) {
try {
await toggle(svc.id, !svc.active)
toast.add({ severity: 'success', summary: svc.active ? 'Desativado' : 'Ativado', detail: `Serviço ${svc.active ? 'desativado' : 'ativado'}.`, life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao atualizar.', life: 4000 })
}
async function toggleService(svc) {
try {
await toggle(svc.id, !svc.active);
toast.add({ severity: 'success', summary: svc.active ? 'Desativado' : 'Ativado', detail: `Serviço ${svc.active ? 'desativado' : 'ativado'}.`, life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao atualizar.', life: 4000 });
}
}
async function confirmRemove (id) {
try {
await remove(id)
toast.add({ severity: 'success', summary: 'Removido', detail: 'Serviço removido permanentemente.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao remover.', life: 4000 })
}
async function confirmRemove(id) {
try {
await remove(id);
toast.add({ severity: 'success', summary: 'Removido', detail: 'Serviço removido permanentemente.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao remover.', life: 4000 });
}
}
function fmtBRL (v) {
if (v == null || v === '') return '—'
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
function fmtBRL(v) {
if (v == null || v === '') return '—';
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
onMounted(async () => {
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
if (!uid) return
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id;
if (!uid) return;
ownerId.value = uid
tenantId.value = tenantStore.activeTenantId || null
ownerId.value = uid;
tenantId.value = tenantStore.activeTenantId || null;
const { data: cfg } = await supabase
.from('agenda_configuracoes')
.select('slot_mode')
.eq('owner_id', uid)
.maybeSingle()
const { data: cfg } = await supabase.from('agenda_configuracoes').select('slot_mode').eq('owner_id', uid).maybeSingle();
slotMode.value = cfg?.slot_mode ?? 'fixed'
slotMode.value = cfg?.slot_mode ?? 'fixed';
await load(uid)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
} finally {
pageLoading.value = false
}
})
await load(uid);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 });
} finally {
pageLoading.value = false;
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Precificação</div>
<div class="cfg-subheader__sub">Valor padrão da sessão e preços por tipo de compromisso</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Novo serviço" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
</div>
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton width="9rem" height="12px" />
<div class="flex flex-col gap-3">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Precificação</div>
<div class="cfg-subheader__sub">Valor padrão da sessão e preços por tipo de compromisso</div>
</div>
<div class="cfg-subheader__actions">
<Button
label="Novo serviço"
icon="pi pi-plus"
size="small"
:disabled="pageLoading || addingNew"
class="rounded-full"
@click="
addingNew = true;
cancelEdit();
"
/>
</div>
</div>
<div v-for="n in 3" :key="n" class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border)] last:border-b-0">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<div class="flex flex-col gap-2 flex-1">
<Skeleton :width="n === 1 ? '10rem' : n === 2 ? '8rem' : '12rem'" height="11px" />
<Skeleton width="6rem" height="10px" />
</div>
<Skeleton width="3.5rem" height="1.4rem" border-radius="999px" />
</div>
</div>
<AppLoadingPhrases action="Carregando serviços e precificação..." containerClass="py-6" />
</template>
<template v-else>
<Message v-if="isDynamic" severity="info" :closable="false">
<span class="text-sm">Modo <b>dinâmico</b> ativo a duração da sessão é definida pelo serviço selecionado.</span>
</Message>
<!-- Form novo serviço -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
<span class="cfg-wrap__title">Novo serviço</span>
</div>
<div class="svc-form">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="newForm.name" inputId="new-name" class="w-full" />
<label for="new-name">Nome *</label>
</FloatLabel>
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton width="9rem" height="12px" />
</div>
<div v-for="n in 3" :key="n" class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border)] last:border-b-0">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<div class="flex flex-col gap-2 flex-1">
<Skeleton :width="n === 1 ? '10rem' : n === 2 ? '8rem' : '12rem'" height="11px" />
<Skeleton width="6rem" height="10px" />
</div>
<Skeleton width="3.5rem" height="1.4rem" border-radius="999px" />
</div>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="newForm.price" inputId="new-price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label for="new-price">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.duration_min" inputId="new-duration" :min="1" :max="480" fluid />
<label for="new-duration">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="newForm.description" inputId="new-desc" class="w-full" />
<label for="new-desc">Descrição (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-if="!services.length && !addingNew" class="cfg-empty">
<i class="pi pi-tag text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum serviço cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo serviço" para começar.</div>
</div>
<!-- Lista de serviços -->
<div v-for="svc in services" :key="svc.id" class="cfg-wrap" :class="{ 'opacity-60': !svc.active }">
<!-- Modo leitura: head clicável -->
<template v-if="editingId !== svc.id">
<div class="svc-row">
<div class="svc-row__icon">
<i class="pi pi-tag" />
</div>
<div class="svc-row__info">
<div class="font-semibold text-sm">{{ svc.name }}</div>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-[var(--text-color-secondary)]">
<span class="font-semibold text-[var(--primary-color)]">{{ fmtBRL(svc.price) }}</span>
<span v-if="svc.duration_min">{{ svc.duration_min }} min</span>
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Tag :value="svc.active ? 'Ativo' : 'Inativo'" :severity="svc.active ? 'success' : 'secondary'" />
<Button
:icon="svc.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="svc.active ? 'secondary' : 'success'"
outlined size="small"
v-tooltip.top="svc.active ? 'Desativar' : 'Ativar'"
@click="toggleService(svc)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
</div>
</div>
<AppLoadingPhrases action="Carregando serviços e precificação..." containerClass="py-6" />
</template>
<!-- Modo edição -->
<template v-else>
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-pencil" /></div>
<span class="cfg-wrap__title">Editar {{ svc.name }}</span>
</div>
<div class="svc-form svc-form--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="editForm.name" :inputId="`edit-name-${svc.id}`" class="w-full" />
<label :for="`edit-name-${svc.id}`">Nome *</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="editForm.price" :inputId="`edit-price-${svc.id}`" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label :for="`edit-price-${svc.id}`">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.duration_min" :inputId="`edit-dur-${svc.id}`" :min="1" :max="480" fluid />
<label :for="`edit-dur-${svc.id}`">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="editForm.description" :inputId="`edit-desc-${svc.id}`" class="w-full" />
<label :for="`edit-desc-${svc.id}`">Descrição (opcional)</label>
</FloatLabel>
</div>
<Message v-if="isDynamic" severity="info" :closable="false">
<span class="text-sm">Modo <b>dinâmico</b> ativo a duração da sessão é definida pelo serviço selecionado.</span>
</Message>
<!-- Form novo serviço -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
<span class="cfg-wrap__title">Novo serviço</span>
</div>
<div class="svc-form">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="newForm.name" inputId="new-name" class="w-full" />
<label for="new-name">Nome *</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="newForm.price" inputId="new-price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label for="new-price">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.duration_min" inputId="new-duration" :min="1" :max="480" fluid />
<label for="new-duration">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="newForm.description" inputId="new-desc" class="w-full" />
<label for="new-desc">Descrição (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
@click="
addingNew = false;
newForm = emptyForm();
"
/>
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
</div>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
<!-- Lista vazia -->
<div v-if="!services.length && !addingNew" class="cfg-empty">
<i class="pi pi-tag text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum serviço cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo serviço" para começar.</div>
</div>
</div>
<!-- Lista de serviços -->
<div v-for="svc in services" :key="svc.id" class="cfg-wrap" :class="{ 'opacity-60': !svc.active }">
<!-- Modo leitura: head clicável -->
<template v-if="editingId !== svc.id">
<div class="svc-row">
<div class="svc-row__icon">
<i class="pi pi-tag" />
</div>
<div class="svc-row__info">
<div class="font-semibold text-sm">{{ svc.name }}</div>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-[var(--text-color-secondary)]">
<span class="font-semibold text-[var(--primary-color)]">{{ fmtBRL(svc.price) }}</span>
<span v-if="svc.duration_min">{{ svc.duration_min }} min</span>
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Tag :value="svc.active ? 'Ativo' : 'Inativo'" :severity="svc.active ? 'success' : 'secondary'" />
<Button :icon="svc.active ? 'pi pi-eye-slash' : 'pi pi-eye'" :severity="svc.active ? 'secondary' : 'success'" outlined size="small" v-tooltip.top="svc.active ? 'Desativar' : 'Ativar'" @click="toggleService(svc)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
</div>
</div>
</template>
<!-- Modo edição -->
<template v-else>
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-pencil" /></div>
<span class="cfg-wrap__title">Editar {{ svc.name }}</span>
</div>
<div class="svc-form svc-form--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="editForm.name" :inputId="`edit-name-${svc.id}`" class="w-full" />
<label :for="`edit-name-${svc.id}`">Nome *</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="editForm.price" :inputId="`edit-price-${svc.id}`" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label :for="`edit-price-${svc.id}`">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.duration_min" :inputId="`edit-dur-${svc.id}`" :min="1" :max="480" fluid />
<label :for="`edit-dur-${svc.id}`">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="editForm.description" :inputId="`edit-desc-${svc.id}`" class="w-full" />
<label :for="`edit-desc-${svc.id}`">Descrição (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
</div>
</div>
</template>
</div>
<Message severity="info" :closable="false">
<span class="text-sm">Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.</span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
<Message severity="info" :closable="false">
<span class="text-sm">Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.</span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
</div>
</template>
<style scoped>
/* ── Subheader degradê ────────────────────────────── */
.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;
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;
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;
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;
}
.cfg-subheader__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.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; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.8rem;
}
.cfg-wrap__title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text-color);
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
/* ── Linha de leitura do serviço ──────────────────── */
.svc-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem; flex-wrap: wrap;
transition: background 0.1s;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
flex-wrap: wrap;
transition: background 0.1s;
}
.svc-row:hover {
background: var(--surface-hover);
}
.svc-row:hover { background: var(--surface-hover); }
.svc-row__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.78rem;
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 10%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.78rem;
}
.svc-row__info {
flex: 1;
min-width: 0;
}
.svc-row__info { flex: 1; min-width: 0; }
/* ── Form (novo + edição) ─────────────────────────── */
.svc-form {
padding: 1rem;
display: flex; flex-direction: column; gap: 0.75rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.svc-form--editing {
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
background: color-mix(in srgb, var(--primary-color, #6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color, #6366f1) 40%, transparent);
}
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2.5rem 1rem;
text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px;
background: var(--surface-ground);
}
</style>
</style>
@@ -0,0 +1,256 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesRecursosExtrasPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const tenantStore = useTenantStore();
// Contexto
const userId = ref(null);
const tenantId = ref(null);
const loading = ref(true);
// Produtos disponíveis
const products = ref([]);
// Saldos do tenant
const creditsByType = ref({}); // { sms: { balance, total_purchased, ... }, email: { ... } }
// Dialog de interesse
const interestDialog = ref(false);
const selectedProduct = ref(null);
// Helpers
function formatPrice(cents) {
if (!cents) return 'Grátis';
return (cents / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function pricePerUnit(product) {
if (!product.credits_amount || !product.price_cents) return '';
const per = product.price_cents / product.credits_amount / 100;
return `R$ ${per.toFixed(2)} / crédito`;
}
function getBalance(addonType) {
return creditsByType.value[addonType]?.balance ?? 0;
}
function typeIcon(type) {
const map = { sms: 'pi pi-comment', email: 'pi pi-envelope', server: 'pi pi-server', domain: 'pi pi-globe' };
return map[type] || 'pi pi-box';
}
function typeLabel(type) {
const map = { sms: 'SMS', email: 'E-mail', server: 'Servidor', domain: 'Domínio' };
return map[type] || type;
}
// Load
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 loadProducts() {
const { data } = await supabase.from('addon_products').select('*').eq('is_active', true).eq('is_visible', true).is('deleted_at', null).order('addon_type').order('sort_order');
if (data) products.value = data;
}
async function loadCredits() {
if (!tenantId.value) return;
const { data } = await supabase.from('addon_credits').select('addon_type, balance, total_purchased, total_consumed, expires_at').eq('tenant_id', tenantId.value).eq('is_active', true);
if (data) {
const map = {};
for (const c of data) map[c.addon_type] = c;
creditsByType.value = map;
}
}
// Interesse
function openInterest(product) {
selectedProduct.value = product;
interestDialog.value = true;
}
function confirmInterest() {
toast.add({
severity: 'success',
summary: 'Interesse registrado!',
detail: `Entre em contato com o suporte para adquirir "${selectedProduct.value?.name}". Em breve teremos compra online!`,
life: 6000
});
interestDialog.value = false;
}
// Produtos agrupados por tipo
const productsByType = computed(() => {
const map = {};
for (const p of products.value) {
if (!map[p.addon_type]) map[p.addon_type] = [];
map[p.addon_type].push(p);
}
return map;
});
// Recursos futuros (estáticos)
const futureAddons = [
{ type: 'server', name: 'Servidor Dedicado', desc: 'Infraestrutura exclusiva para sua clínica.', icon: 'pi pi-server' },
{ type: 'email', name: 'E-mail Avançado', desc: 'Domínio personalizado e caixa de e-mail profissional.', icon: 'pi pi-envelope' },
{ type: 'domain', name: 'Domínio Personalizado', desc: 'Acesse a plataforma pelo domínio da sua clínica.', icon: 'pi pi-globe' }
];
// Init
onMounted(async () => {
await loadUser();
await Promise.all([loadProducts(), loadCredits()]);
loading.value = false;
});
</script>
<template>
<div class="flex flex-col gap-5">
<!-- Header -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-box text-xl" />
Recursos Extras
</div>
</template>
<template #subtitle>Amplie as funcionalidades da sua clínica com recursos adicionais.</template>
</Card>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-8 text-surface-500"><i class="pi pi-spin pi-spinner mr-2" /> Carregando recursos...</div>
<template v-else>
<!-- Produtos por tipo -->
<div v-for="(items, type) in productsByType" :key="type">
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
<i :class="typeIcon(type)" />
{{ typeLabel(type) }}
<Tag v-if="getBalance(type) > 0" :value="`Saldo: ${getBalance(type)}`" severity="success" class="ml-2" />
<Tag v-else value="Sem créditos" severity="secondary" class="ml-2" />
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<Card v-for="product in items" :key="product.id" class="shadow-sm hover:shadow-md transition-shadow">
<template #title>
<div class="flex items-center gap-2 text-base">
<i :class="product.icon || typeIcon(product.addon_type)" />
{{ product.name }}
</div>
</template>
<template #content>
<p class="text-sm text-surface-500 mb-3">{{ product.description }}</p>
<div class="flex flex-col gap-1 mb-4">
<span class="text-2xl font-bold text-primary">{{ formatPrice(product.price_cents) }}</span>
<span v-if="product.credits_amount" class="text-xs text-surface-400"> {{ product.credits_amount }} créditos · {{ pricePerUnit(product) }} </span>
</div>
<Button label="Tenho interesse" icon="pi pi-shopping-cart" size="small" class="w-full" @click="openInterest(product)" />
</template>
</Card>
</div>
</div>
<!-- Sem produtos -->
<Message v-if="!products.length" severity="info" :closable="false"> Nenhum recurso extra disponível no momento. </Message>
<!-- Recursos futuros (Em breve) -->
<h3 class="text-lg font-semibold mt-4 mb-3 flex items-center gap-2">
<i class="pi pi-sparkles" />
Em breve
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card v-for="addon in futureAddons" :key="addon.type" class="opacity-60">
<template #title>
<div class="flex items-center gap-2 text-base">
<i :class="addon.icon" />
{{ addon.name }}
<Tag value="Em breve" severity="secondary" class="ml-auto" />
</div>
</template>
<template #content>
<p class="text-sm text-surface-500">{{ addon.desc }}</p>
</template>
</Card>
</div>
</template>
<!-- Dialog de interesse -->
<Dialog
v-model:visible="interestDialog"
modal
:draggable="false"
:closable="true"
:dismissableMask="true"
maximizable
class="dc-dialog w-[36rem]"
: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' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">Adquirir recurso</div>
<div class="text-xs opacity-50">Registre seu interesse neste recurso</div>
</div>
</div>
</div>
</template>
<div v-if="selectedProduct" class="flex flex-col gap-3">
<p>
Você deseja adquirir <strong>{{ selectedProduct.name }}</strong
>?
</p>
<p class="text-2xl font-bold text-primary">{{ formatPrice(selectedProduct.price_cents) }}</p>
<Message severity="info" :closable="false" class="text-sm"> A compra online estará disponível em breve. Por enquanto, entre em contato com o suporte ou administrador para ativar seus créditos. </Message>
</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="interestDialog = false" />
<Button label="Registrar interesse" icon="pi pi-check" class="rounded-full" @click="confirmInterest" />
</div>
</template>
</Dialog>
</div>
</template>
@@ -0,0 +1,497 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesSmsPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const tenantStore = useTenantStore();
// Contexto
const userId = ref(null);
const tenantId = ref(null);
const loading = ref(true);
// Saldo de créditos
const credits = ref(null);
const hasCredits = computed(() => credits.value !== null);
const balance = computed(() => credits.value?.balance ?? 0);
const totalPurchased = computed(() => credits.value?.total_purchased ?? 0);
const totalConsumed = computed(() => credits.value?.total_consumed ?? 0);
const balanceSeverity = computed(() => {
if (!hasCredits.value || balance.value <= 0) return 'danger';
if (balance.value <= (credits.value?.low_balance_threshold || 10)) return 'warn';
return 'success';
});
const balanceIcon = computed(() => {
if (balance.value <= 0) return 'pi pi-times-circle';
if (balance.value <= (credits.value?.low_balance_threshold || 10)) return 'pi pi-exclamation-triangle';
return 'pi pi-check-circle';
});
// Transações recentes
const transactions = ref([]);
const txLoading = ref(false);
// Logs de envio
const recentLogs = ref([]);
const logsLoading = ref(false);
//
// Templates SMS
//
const templates = ref([]);
const templatesLoading = ref(false);
const templateSaving = ref({});
// Labels para exibição
const EVENT_TYPE_LABELS = {
lembrete_sessao: 'Lembrete',
confirmacao_sessao: 'Confirmação',
cancelamento_sessao: 'Cancelamento',
reagendamento: 'Reagendamento',
cobranca_pendente: 'Financeiro',
boas_vindas_paciente: 'Boas-vindas',
intake_recebido: 'Triagem',
intake_aprovado: 'Triagem',
intake_rejeitado: 'Triagem'
};
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'
};
// Referências dos textareas por template key
const textareaRefs = ref({});
function setTextareaRef(key, el) {
if (el) textareaRefs.value[key] = el;
}
async function loadTemplates() {
if (!tenantId.value) return;
templatesLoading.value = true;
try {
// 1. Busca templates globais do SaaS (tenant_id IS NULL)
const { data: globals, error: gErr } = await supabase
.from('notification_templates')
.select('*')
.is('tenant_id', null)
.eq('channel', 'sms')
.eq('is_default', true)
.eq('is_active', true)
.is('deleted_at', null)
.order('domain')
.order('event_type');
if (gErr) throw gErr;
// 2. Busca customizações do tenant
const { data: customs, error: cErr } = await supabase.from('notification_templates').select('*').eq('tenant_id', tenantId.value).eq('channel', 'sms').is('deleted_at', null);
if (cErr) throw cErr;
const customMap = {};
for (const c of customs || []) customMap[c.key] = c;
// 3. Mescla: para cada global, verifica se o tenant tem customização
templates.value = (globals || []).map((g) => {
const custom = customMap[g.key];
return {
key: g.key,
domain: g.domain,
event_type: g.event_type,
label: EVENT_TYPE_LABELS[g.event_type] || g.event_type,
type: EVENT_TYPE_LABELS[g.event_type] || g.event_type,
type_severity: EVENT_SEVERITY[g.event_type] || 'secondary',
variables: g.variables || [],
default_body: g.body_text,
// Se tenant customizou, usa o texto dele; senão usa o global
id: custom?.id || null,
body_text: custom?.body_text || g.body_text,
is_custom: !!custom
};
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar templates', detail: e.message, life: 4000 });
} finally {
templatesLoading.value = false;
}
}
function insertVariable(templateKey, variable) {
const snippet = `{{${variable}}}`;
const tpl = templates.value.find((t) => t.key === templateKey);
if (!tpl) return;
const taWrapper = textareaRefs.value[templateKey];
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 || templateSaving.value[tpl.key]) return;
templateSaving.value[tpl.key] = true;
try {
if (tpl.id) {
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text }).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: tpl.body_text, 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: 'sms',
key: tpl.key,
domain: tpl.domain,
event_type: tpl.event_type,
body_text: tpl.body_text,
variables: tpl.variables,
is_active: true,
is_default: false
})
.select('id')
.single();
if (error) throw error;
tpl.id = data.id;
}
tpl.is_custom = true;
}
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar template', detail: e.message, life: 5000 });
} finally {
templateSaving.value[tpl.key] = false;
}
}
function isTemplateModified(tpl) {
return tpl.default_body ? tpl.body_text !== tpl.default_body : false;
}
function confirmRestoreTemplate(tpl) {
if (!tpl.default_body) return;
confirm.require({
group: 'headless',
message: `Restaurar "${tpl.label}" para o texto original definido pelo administrador?`,
header: 'Restaurar template',
icon: 'pi-undo',
accept: () => {
tpl.body_text = tpl.default_body;
}
});
}
// Load
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 loadCredits() {
if (!tenantId.value) return;
const { data, error } = await supabase.from('addon_credits').select('*').eq('tenant_id', tenantId.value).eq('addon_type', 'sms').eq('is_active', true).maybeSingle();
if (!error && data) credits.value = data;
}
async function loadTransactions() {
if (!tenantId.value) return;
txLoading.value = true;
const { data } = await supabase
.from('addon_transactions')
.select('id, type, amount, balance_after, description, payment_method, created_at')
.eq('tenant_id', tenantId.value)
.eq('addon_type', 'sms')
.order('created_at', { ascending: false })
.limit(15);
txLoading.value = false;
if (data) transactions.value = data;
}
async function loadLogs() {
if (!tenantId.value) return;
logsLoading.value = true;
const { data } = await supabase
.from('notification_logs')
.select('id, template_key, recipient_address, status, failure_reason, sent_at, failed_at, created_at')
.eq('tenant_id', tenantId.value)
.eq('channel', 'sms')
.order('created_at', { ascending: false })
.limit(10);
logsLoading.value = false;
if (data) recentLogs.value = data;
}
// Helpers
function txTypeLabel(type) {
const map = { purchase: 'Compra', consume: 'Consumo', adjustment: 'Ajuste', refund: 'Reembolso', expiration: 'Expiração' };
return map[type] || type;
}
function txTypeSeverity(type) {
const map = { purchase: 'success', consume: 'secondary', adjustment: 'info', refund: 'warn', expiration: 'danger' };
return map[type] || 'secondary';
}
function logStatusSeverity(status) {
if (status === 'sent') return 'success';
if (status === 'failed') return 'danger';
return 'secondary';
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
}
function goToRecursosExtras() {
router.push('/configuracoes/recursos-extras');
}
// Init
onMounted(async () => {
await loadUser();
await Promise.all([loadCredits(), loadTransactions(), loadLogs(), loadTemplates()]);
loading.value = false;
});
</script>
<template>
<div class="flex flex-col gap-5">
<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>
<!-- Saldo Card -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-comment text-xl" />
Créditos SMS
</div>
</template>
<template #subtitle>Seus créditos para envio de SMS aos pacientes.</template>
<template #content>
<div v-if="loading" class="flex items-center gap-2 text-surface-500"><i class="pi pi-spin pi-spinner" /> Carregando...</div>
<div v-else class="flex flex-col gap-4">
<!-- Saldo principal -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<i :class="balanceIcon" :style="{ color: balanceSeverity === 'success' ? 'var(--p-green-500)' : balanceSeverity === 'warn' ? 'var(--p-yellow-500)' : 'var(--p-red-500)' }" class="text-2xl" />
<span class="text-4xl font-bold">{{ balance }}</span>
<span class="text-surface-500 text-sm">créditos disponíveis</span>
</div>
</div>
<!-- Estatísticas -->
<div v-if="hasCredits" class="flex gap-6 text-sm text-surface-500">
<div class="flex flex-col">
<span class="font-semibold text-surface-700">{{ totalPurchased }}</span>
<span>Total comprado</span>
</div>
<div class="flex flex-col">
<span class="font-semibold text-surface-700">{{ totalConsumed }}</span>
<span>Total consumido</span>
</div>
</div>
<!-- Alerta saldo baixo -->
<Message v-if="hasCredits && balance <= 0" severity="error" :closable="false"> Sem créditos SMS. Os lembretes por SMS estão pausados. Adquira mais créditos para reativar. </Message>
<Message v-else-if="hasCredits && balance <= (credits?.low_balance_threshold || 10)" severity="warn" :closable="false"> Saldo baixo! Restam apenas {{ balance }} créditos SMS. </Message>
<!-- Sem créditos ainda -->
<Message v-if="!hasCredits" severity="info" :closable="false"> Você ainda não possui créditos SMS. Adquira um pacote em Recursos Extras. </Message>
<Button label="Adquirir créditos SMS" icon="pi pi-shopping-cart" @click="goToRecursosExtras" class="w-fit" />
</div>
</template>
</Card>
<!-- -->
<!-- Templates de mensagem SMS -->
<!-- -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-file-edit text-xl" />
Mensagens SMS
</div>
</template>
<template #subtitle>Personalize as mensagens enviadas por SMS aos seus pacientes. Os textos padrão funcionam edite apenas se quiser personalizar.</template>
<template #content>
<!-- Skeleton loading -->
<template v-if="templatesLoading">
<div v-for="n in 4" :key="n" class="border border-surface rounded-xl p-4 mb-3">
<div class="flex items-center gap-2 mb-3">
<Skeleton width="5rem" height="1.4rem" borderRadius="999px" />
<Skeleton width="10rem" height="1rem" />
</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>
<!-- Cards de templates -->
<div v-else class="flex flex-col gap-4">
<div v-for="tpl in templates" :key="tpl.key" class="border border-surface rounded-xl p-4 flex flex-col gap-3">
<!-- Header do card -->
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">{{ tpl.label }}</span>
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
</div>
<!-- Editor Jodit -->
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="4" auto-resize class="w-full text-sm" />
<!-- Variáveis clicáveis -->
<div class="flex flex-col gap-1.5">
<span class="text-xs text-surface-500">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>
<!-- Ações -->
<div class="flex items-center gap-2 justify-end">
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
</div>
</div>
</div>
</template>
</Card>
<!-- Histórico de transações -->
<Card>
<template #title>
<div class="flex items-center justify-between">
<span>Histórico de créditos</span>
<Button icon="pi pi-refresh" text rounded size="small" @click="loadTransactions" :loading="txLoading" />
</div>
</template>
<template #content>
<DataTable :value="transactions" :loading="txLoading" size="small" stripedRows emptyMessage="Nenhuma transação encontrada.">
<Column field="created_at" header="Data" style="width: 140px">
<template #body="{ data }">{{ formatDate(data.created_at) }}</template>
</Column>
<Column field="type" header="Tipo" style="width: 110px">
<template #body="{ data }">
<Tag :value="txTypeLabel(data.type)" :severity="txTypeSeverity(data.type)" />
</template>
</Column>
<Column field="amount" header="Qtd" style="width: 80px">
<template #body="{ data }">
<span :class="data.amount > 0 ? 'text-green-500 font-semibold' : 'text-red-500'"> {{ data.amount > 0 ? '+' : '' }}{{ data.amount }} </span>
</template>
</Column>
<Column field="balance_after" header="Saldo" style="width: 80px" />
<Column field="description" header="Descrição" />
</DataTable>
</template>
</Card>
<!-- Últimos envios -->
<Card>
<template #title>
<div class="flex items-center justify-between">
<span>Últimos envios SMS</span>
<Button icon="pi pi-refresh" text rounded size="small" @click="loadLogs" :loading="logsLoading" />
</div>
</template>
<template #content>
<DataTable :value="recentLogs" :loading="logsLoading" size="small" stripedRows emptyMessage="Nenhum registro de SMS encontrado.">
<Column field="created_at" header="Data" style="width: 140px">
<template #body="{ data }">{{ formatDate(data.sent_at || data.failed_at || data.created_at) }}</template>
</Column>
<Column field="template_key" header="Template" />
<Column field="recipient_address" header="Destinatário" />
<Column field="status" header="Status" style="width: 100px">
<template #body="{ data }">
<Tag :value="data.status" :severity="logStatusSeverity(data.status)" />
</template>
</Column>
<Column field="failure_reason" header="Erro" style="max-width: 200px">
<template #body="{ data }">
<span class="text-sm text-red-500 truncate block" :title="data.failure_reason">{{ data.failure_reason || '—' }}</span>
</template>
</Column>
</DataTable>
</template>
</Card>
</div>
</template>
@@ -0,0 +1,787 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesWhatsappPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// Contexto
const userId = ref(null);
const tenantId = ref(null); // tenant_id real (da tabela tenants)
const activeTab = ref(0);
async function loadUser() {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return;
userId.value = user.id;
// Usar o tenantId do store (tabela tenants), fallback para user.id
tenantId.value = tenantStore.activeTenantId || user.id;
}
//
// ABA 1 Conexão WhatsApp
//
const credentials = ref({ api_url: '', api_key: '', instance_name: '' });
const hasCredentials = ref(false);
const connectionStatus = ref(null); // 'open' | 'close' | 'connecting' | null
const connectionLoading = ref(false);
// QR Code
const qrDialog = ref(false);
const qrCodeBase64 = ref(null);
const qrLoading = ref(false);
const qrCountdown = ref(0);
let qrTimer = null;
let isMounted = true;
const connectionTag = computed(() => {
if (connectionLoading.value) return { label: 'Verificando...', severity: 'secondary' };
if (!hasCredentials.value) return { label: 'Não configurado', severity: 'secondary' };
switch (connectionStatus.value) {
case 'open':
return { label: 'Conectado', severity: 'success' };
case 'connecting':
return { label: 'Conectando...', severity: 'warn' };
default:
return { label: 'Desconectado', severity: 'danger' };
}
});
// Carregar credenciais do banco busca por tenant_id (consistente com SaaS)
// com fallback para owner_id (caso tenantId == userId)
async function loadCredentials() {
if (!tenantId.value) return;
// Tentar por tenant_id primeiro (como o SaaS salva)
let { data, error } = await supabase.from('notification_channels').select('*').eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
// Fallback: buscar por owner_id (cenário legado ou tenant solo)
if (!data && userId.value && userId.value !== tenantId.value) {
const fallback = await supabase.from('notification_channels').select('*').eq('owner_id', userId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
data = fallback.data;
error = fallback.error;
}
if (error) {
toast.add({ severity: 'error', summary: 'Erro ao carregar credenciais', detail: error.message, life: 4000 });
return;
}
if (data?.credentials) {
credentials.value = {
api_url: data.credentials.api_url || '',
api_key: data.credentials.api_key || '',
instance_name: data.credentials.instance_name || ''
};
hasCredentials.value = true;
}
}
// Verificar status da conexão via Evolution API
async function checkConnectionStatus() {
if (!hasCredentials.value) return;
connectionLoading.value = true;
try {
const res = await fetch(`${credentials.value.api_url}/instance/fetchInstances`, {
headers: { apikey: credentials.value.api_key }
});
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';
} catch (e) {
connectionStatus.value = 'close';
toast.add({
severity: 'warn',
summary: 'Não foi possível conectar à Evolution API',
detail: 'Verifique a URL e a chave de API.',
life: 5000
});
} finally {
connectionLoading.value = false;
}
}
// Buscar QR Code para conectar
async function fetchQrCode() {
if (!isMounted) return;
qrLoading.value = true;
qrCodeBase64.value = null;
clearQrTimer();
try {
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;
if (!base64) {
// Instância pode já estar conectada
if (data?.instance?.status === 'open') {
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.');
}
qrCodeBase64.value = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
startQrCountdown();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar QR Code', detail: e.message, life: 5000 });
} finally {
qrLoading.value = false;
}
}
function startQrCountdown() {
qrCountdown.value = 30;
qrTimer = setInterval(() => {
qrCountdown.value--;
if (qrCountdown.value <= 0) {
clearQrTimer();
fetchQrCode();
}
}, 1000);
}
function clearQrTimer() {
if (qrTimer) {
clearInterval(qrTimer);
qrTimer = null;
}
qrCountdown.value = 0;
}
function openQrDialog() {
qrDialog.value = true;
fetchQrCode();
}
function closeQrDialog() {
qrDialog.value = false;
clearQrTimer();
qrCodeBase64.value = null;
// Verificar se conectou depois de fechar o dialog
checkConnectionStatus();
}
//
// ABA 2 Templates de mensagem
//
const templates = ref([]);
const templatesLoading = ref(false);
const templateSaving = ref({});
// Labels para exibição por event_type
const EVENT_TYPE_LABELS = {
lembrete_sessao: 'Lembrete',
confirmacao_sessao: 'Confirmação',
cancelamento_sessao: 'Cancelamento',
reagendamento: 'Reagendamento',
cobranca_pendente: 'Financeiro',
boas_vindas_paciente: 'Boas-vindas',
intake_recebido: 'Triagem',
intake_aprovado: 'Triagem'
};
const EVENT_SEVERITY = {
lembrete_sessao: 'info',
confirmacao_sessao: 'success',
cancelamento_sessao: 'danger',
reagendamento: 'warn',
cobranca_pendente: 'warning',
boas_vindas_paciente: 'success',
intake_recebido: 'info',
intake_aprovado: 'success'
};
// Label amigável a partir da key (ex: 'session.lembrete.whatsapp' 'Lembrete de sessão')
function keyToLabel(key) {
const parts = key.replace('.whatsapp', '').split('.');
const map = {
'session.lembrete': 'Lembrete de sessão (24h antes)',
'session.lembrete_2h': 'Lembrete de sessão (2h antes)',
'session.confirmacao': 'Confirmação de agendamento',
'session.cancelamento': 'Sessão cancelada',
'session.reagendamento': 'Sessão reagendada',
'cobranca.pendente': 'Cobrança pendente',
'sistema.boas_vindas': 'Boas-vindas ao paciente'
};
return map[parts.slice(0, 2).join('.')] || key;
}
// Referências dos textareas para inserção no cursor
const textareaRefs = ref({});
function setTextareaRef(key, el) {
if (el) textareaRefs.value[key] = el;
}
async function loadTemplates() {
if (!tenantId.value) return;
templatesLoading.value = true;
try {
// 1. Busca templates globais do SaaS (tenant_id IS NULL)
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;
// 2. Busca customizações do tenant
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;
// 3. Mescla: para cada global, verifica se o tenant tem customização
templates.value = (globals || []).map((g) => {
const custom = customMap[g.key];
return {
key: g.key,
domain: g.domain,
event_type: g.event_type,
label: keyToLabel(g.key),
type: EVENT_TYPE_LABELS[g.event_type] || g.event_type,
type_severity: EVENT_SEVERITY[g.event_type] || 'secondary',
variables: g.variables || [],
default_body: g.body_text,
id: custom?.id || null,
body_text: custom?.body_text || g.body_text,
is_custom: !!custom
};
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar templates', detail: e.message, life: 4000 });
} finally {
templatesLoading.value = false;
}
}
// Inserir variável no textarea na posição do cursor
function insertVariable(templateKey, variable) {
const snippet = `{{${variable}}}`;
const tpl = templates.value.find((t) => t.key === templateKey);
if (!tpl) return;
const textarea = textareaRefs.value[templateKey]?.$el?.querySelector('textarea');
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = tpl.body_text;
tpl.body_text = text.substring(0, start) + snippet + text.substring(end);
nextTick(() => {
textarea.focus();
textarea.setSelectionRange(start + snippet.length, start + snippet.length);
});
} else {
// Fallback: adicionar ao final
tpl.body_text = (tpl.body_text || '') + snippet;
}
}
// Salvar template individual
async function saveTemplate(tpl) {
if (!tenantId.value || templateSaving.value[tpl.key]) return;
templateSaving.value[tpl.key] = true;
try {
if (tpl.id) {
// Atualizar existente
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text }).eq('id', tpl.id);
if (error) throw error;
} else {
// Verificar se já existe um registro ativo para esta key
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) {
// Já existe (criado por outra sessão) atualizar
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text, is_active: true }).eq('id', existing.id);
if (error) throw error;
tpl.id = existing.id;
} else {
// Inserir novo
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: tpl.body_text,
variables: tpl.variables,
is_active: true,
is_default: false
})
.select('id')
.single();
if (error) throw error;
tpl.id = data.id;
}
tpl.is_custom = true;
}
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar template', detail: e.message, life: 5000 });
} finally {
templateSaving.value[tpl.key] = false;
}
}
// Verificar se template difere do padrão global
function isTemplateModified(tpl) {
return tpl.default_body ? tpl.body_text !== tpl.default_body : false;
}
// Restaurar template para o padrão global
function confirmRestoreTemplate(tpl) {
if (!tpl.default_body) return;
confirm.require({
group: 'headless',
message: `Restaurar "${tpl.label}" para o texto original definido pelo administrador?`,
header: 'Restaurar template',
icon: 'pi-undo',
accept: async () => {
tpl.body_text = tpl.default_body;
if (tpl.id) {
await saveTemplate(tpl);
}
}
});
}
//
// ABA 3 Logs de envio
//
const logs = ref([]);
const logsLoading = ref(false);
const logsFilter = ref('todos');
const logsPage = ref(1);
const logsPerPage = 20;
const logsTotal = ref(0);
const FILTER_OPTIONS = [
{ label: 'Todos', value: 'todos' },
{ label: 'Enviado', value: 'sent' },
{ label: 'Falhou', value: 'failed' }
];
// Mapear keys para nomes amigáveis (dinâmico a partir dos templates carregados)
function friendlyTemplateKey(key) {
const tpl = templates.value.find((t) => t.key === key || t.event_type === key);
return tpl?.label || key || '—';
}
function statusTag(status) {
switch (status) {
case 'sent':
return { label: 'Enviado', severity: 'success' };
case 'failed':
return { label: 'Falhou', severity: 'danger' };
case 'pending':
return { label: 'Pendente', severity: 'warn' };
default:
return { label: status || '—', severity: 'secondary' };
}
}
function formatDate(dt) {
if (!dt) return '—';
const d = new Date(dt);
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
async function loadLogs() {
if (!tenantId.value) return;
logsLoading.value = true;
try {
let query = supabase.from('notification_logs').select('*', { count: 'exact' }).eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').order('created_at', { ascending: false });
if (logsFilter.value !== 'todos') {
query = query.eq('status', logsFilter.value);
}
const from = (logsPage.value - 1) * logsPerPage;
const to = from + logsPerPage - 1;
query = query.range(from, to);
const { data, error, count } = await query;
if (error) throw error;
logs.value = data || [];
logsTotal.value = count || 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar logs', detail: e.message, life: 4000 });
} finally {
logsLoading.value = false;
}
}
function onFilterChange(val) {
logsFilter.value = val;
logsPage.value = 1;
loadLogs();
}
function onPageChange(event) {
logsPage.value = event.page + 1;
loadLogs();
}
//
// Inicialização
//
onMounted(async () => {
await loadUser();
await Promise.all([loadCredentials(), loadTemplates(), loadLogs()]);
if (hasCredentials.value) await checkConnectionStatus();
});
onBeforeUnmount(() => {
isMounted = false;
clearQrTimer();
});
</script>
<template>
<div class="flex flex-col gap-4">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-comments" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">WhatsApp</div>
<div class="cfg-subheader__sub">Configure a integração e os templates de mensagem do WhatsApp</div>
</div>
<div class="cfg-subheader__actions">
<Tag :value="connectionTag.label" :severity="connectionTag.severity" class="text-xs" />
</div>
</div>
<!-- Abas -->
<Tabs :value="activeTab" @update:value="activeTab = $event">
<TabList>
<Tab :value="0"><i class="pi pi-link mr-2" />Conexão</Tab>
<Tab :value="1"><i class="pi pi-file-edit mr-2" />Templates</Tab>
<Tab :value="2"><i class="pi pi-list mr-2" />Logs de envio</Tab>
</TabList>
<TabPanels>
<!-- ABA 1 Conexão -->
<TabPanel :value="0">
<div class="flex flex-col gap-4 pt-3">
<!-- Sem credenciais WhatsApp não configurado pelo admin -->
<div v-if="!hasCredentials" class="border border-[var(--surface-border)] rounded-lg p-6 bg-[var(--surface-card)] text-center">
<div class="grid place-items-center w-14 h-14 rounded-full bg-gray-100 text-gray-400 mx-auto mb-3">
<i class="pi pi-comments text-2xl" />
</div>
<div class="font-semibold text-sm mb-1">WhatsApp ainda não configurado</div>
<p class="text-sm text-[var(--text-color-secondary)] m-0 max-w-md mx-auto">
A integração com o WhatsApp precisa ser ativada pela equipe de suporte. Entre em contato para que possamos configurar o envio automático de mensagens para você.
</p>
</div>
<!-- Com credenciais: status + QR Code -->
<template v-else>
<!-- Status da conexão -->
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)]">
<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" :class="connectionStatus === 'open' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500'">
<i class="pi pi-comments text-lg" />
</div>
<div>
<div class="font-semibold text-sm">Status da conexão</div>
<Tag :value="connectionTag.label" :severity="connectionTag.severity" class="text-xs mt-1" />
</div>
</div>
<div class="flex gap-2">
<Button :label="connectionStatus === 'open' ? 'Reconectar' : 'Conectar WhatsApp'" icon="pi pi-qrcode" size="small" @click="openQrDialog" />
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="connectionLoading" v-tooltip.bottom="'Verificar status'" @click="checkConnectionStatus" />
</div>
</div>
</div>
<!-- Instruções simples para o terapeuta -->
<div v-if="connectionStatus !== 'open'" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-info-circle text-[var(--primary-color)] mt-0.5" />
<div class="text-sm text-[var(--text-color-secondary)]">
<strong class="text-[var(--text-color)]">Como conectar:</strong>
clique em <strong>"Conectar WhatsApp"</strong>, abra o WhatsApp no seu celular, em <strong>Configurações > Aparelhos conectados > Conectar aparelho</strong>
e escaneie o QR Code que aparecerá na tela.
</div>
</div>
</template>
</div>
</TabPanel>
<!-- ABA 2 Templates -->
<TabPanel :value="1">
<div class="flex flex-col gap-3 pt-3">
<!-- Skeleton loading -->
<template v-if="templatesLoading">
<div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-3">
<Skeleton width="5rem" height="1.4rem" border-radius="999px" />
<Skeleton width="10rem" height="1rem" />
</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" border-radius="999px" />
</div>
</div>
</template>
<!-- Cards de templates -->
<div v-else v-for="tpl in templates" :key="tpl.key" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<!-- Header do card -->
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">{{ tpl.label }}</span>
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
</div>
<!-- Textarea editável -->
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="5" auto-resize class="w-full text-sm font-mono" />
<!-- Variáveis clicáveis -->
<div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button v-for="v in tpl.variables" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)" />
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-2 justify-end">
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
</div>
</div>
</div>
</TabPanel>
<!-- ABA 3 Logs -->
<TabPanel :value="2">
<div class="flex flex-col gap-3 pt-3">
<!-- Filtros -->
<div class="flex items-center gap-2 flex-wrap">
<Button
v-for="opt in FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
size="small"
:severity="logsFilter === opt.value ? 'primary' : 'secondary'"
:outlined="logsFilter !== opt.value"
@click="onFilterChange(opt.value)"
/>
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="logsLoading" v-tooltip.bottom="'Atualizar'" class="ml-auto" @click="loadLogs" />
</div>
<!-- Tabela -->
<DataTable :value="logs" :loading="logsLoading" responsive-layout="scroll" striped-rows class="text-sm">
<Column field="created_at" header="Data/hora" style="min-width: 140px">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column field="recipient_address" header="Destinatário" style="min-width: 140px" />
<Column field="template_key" header="Template" style="min-width: 160px">
<template #body="{ data }">
{{ friendlyTemplateKey(data.template_key) }}
</template>
</Column>
<Column field="status" header="Status" style="min-width: 100px">
<template #body="{ data }">
<Tag :value="statusTag(data.status).label" :severity="statusTag(data.status).severity" class="text-[0.7rem]" />
</template>
</Column>
<Column field="failure_reason" header="Erro" style="min-width: 160px">
<template #body="{ data }">
<span v-if="data.failure_reason" v-tooltip.top="data.failure_reason" class="text-xs text-[var(--text-color-secondary)] truncate block max-w-[200px]">
{{ data.failure_reason }}
</span>
<span v-else class="text-xs text-[var(--text-color-secondary)] opacity-40"></span>
</template>
</Column>
<template #empty>
<div class="text-center py-6 text-sm text-[var(--text-color-secondary)]">Nenhum log de envio encontrado.</div>
</template>
</DataTable>
<!-- Paginação -->
<Paginator v-if="logsTotal > logsPerPage" :rows="logsPerPage" :totalRecords="logsTotal" :first="(logsPage - 1) * logsPerPage" @page="onPageChange" />
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Dialog QR Code -->
<Dialog
v-model:visible="qrDialog"
modal
:draggable="false"
:closable="!qrLoading"
:dismissableMask="!qrLoading"
maximizable
class="dc-dialog w-[36rem]"
: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' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
@hide="closeQrDialog"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">Conectar WhatsApp</div>
<div class="text-xs opacity-50">Escaneie o QR Code para conectar</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col items-center gap-4 py-2">
<p class="text-sm text-[var(--text-color-secondary)] text-center m-0">Escaneie o QR Code abaixo com o WhatsApp do seu celular para conectar.</p>
<!-- Loading -->
<div v-if="qrLoading" class="flex flex-col items-center gap-3 py-6">
<ProgressSpinner style="width: 48px; height: 48px" />
<span class="text-xs text-[var(--text-color-secondary)]">Gerando QR Code...</span>
</div>
<!-- QR Code -->
<div v-else-if="qrCodeBase64" class="flex flex-col items-center gap-3">
<img :src="qrCodeBase64" alt="QR Code WhatsApp" class="w-64 h-64 rounded-lg border border-[var(--surface-border)]" />
<div class="flex items-center gap-2 text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-clock" />
<span
>Atualiza automaticamente em <strong>{{ qrCountdown }}s</strong></span
>
</div>
</div>
<!-- Erro / sem QR -->
<div v-else class="text-center py-6">
<i class="pi pi-exclamation-circle text-3xl text-[var(--text-color-secondary)] opacity-40 mb-2" />
<p class="text-sm text-[var(--text-color-secondary)] m-0">Não foi possível gerar o QR Code.</p>
</div>
<Button label="Atualizar QR Code" icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="qrLoading" @click="fetchQrCode" />
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Fechar" severity="secondary" text class="rounded-full" @click="closeQrDialog" />
</div>
</template>
</Dialog>
<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>
</div>
</template>
<style scoped>
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
.cfg-subheader__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
</style>