86311ef305
Sprints 04-29 + 04-30 acumuladas. - MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/ Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed. - MelissaEmbed: wrapper generico que injeta layout-variant=melissa e remove cromos pra reaproveitar Pages tradicionais. - 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes, Conversas, Embed, Grupos, Medicos, Recorrencias, Tags. - Dialog blueprint atualizado: bg-gray-100 (hardcoded light) -> bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em 9 arquivos. Anti-pattern documentado. - PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name), toggle vertical/abas com persist localStorage, sticky margin-top. - Surface picker no popover do MelissaLayout (8 swatches). - useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos. - Migration: status agenda remarcado/confirmado. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
352 lines
15 KiB
Vue
352 lines
15 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI — CadastroRapidoConvenio.vue
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Componente de seleção e cadastro rápido de convênios.
|
|
| Usado dentro do PatientsCadastroPage na seção "Clínico & origem".
|
|
|
|
|
| Props:
|
|
| modelValue (String|null) — id do insurance_plan selecionado
|
|
| visible (Boolean) — controla visibilidade do dialog
|
|
|
|
|
| Emits:
|
|
| update:modelValue — string id selecionado
|
|
| update:visible — fecha o dialog
|
|
| selected — { id, name, notes, default_value } do plano escolhido
|
|
|
|
|
| Tabela: public.insurance_plans
|
|
| id uuid, owner_id uuid, tenant_id uuid,
|
|
| name text, notes text, default_value numeric, active boolean
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { ref, computed, watch } from 'vue'
|
|
import { useToast } from 'primevue/usetoast'
|
|
import { supabase } from '@/lib/supabase/client'
|
|
import { useTenantStore } from '@/stores/tenantStore'
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
const props = defineProps({
|
|
modelValue: { type: String, default: null }, // id selecionado
|
|
visible: { type: Boolean, default: false },
|
|
})
|
|
const emit = defineEmits(['update:modelValue', 'update:visible', 'selected'])
|
|
|
|
const toast = useToast()
|
|
const tenantStore = useTenantStore()
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Auth / tenant helpers
|
|
// ─────────────────────────────────────────────────────────
|
|
async function getOwnerId () {
|
|
const { data, error } = await supabase.auth.getUser()
|
|
if (error) throw error
|
|
const uid = data?.user?.id
|
|
if (!uid) throw new Error('Sessão inválida.')
|
|
return uid
|
|
}
|
|
async function getTenantId () {
|
|
const tid = tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
|
if (tid) return tid
|
|
const ownerId = await getOwnerId()
|
|
const { data, error } = await supabase
|
|
.from('tenant_members').select('tenant_id')
|
|
.eq('user_id', ownerId).eq('status', 'active')
|
|
.order('created_at', { ascending: false }).limit(1).single()
|
|
if (error) throw error
|
|
return data?.tenant_id
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Estado
|
|
// ─────────────────────────────────────────────────────────
|
|
const plans = ref([])
|
|
const loading = ref(false)
|
|
const searchTerm = ref('')
|
|
|
|
// Form de criação
|
|
const showForm = ref(false)
|
|
const saving = ref(false)
|
|
const formErr = ref('')
|
|
const newPlan = ref({ name: '', notes: '', default_value: '' })
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Computed
|
|
// ─────────────────────────────────────────────────────────
|
|
const filteredPlans = computed(() => {
|
|
const q = searchTerm.value.toLowerCase().trim()
|
|
if (!q) return plans.value
|
|
return plans.value.filter(p =>
|
|
p.name.toLowerCase().includes(q) ||
|
|
(p.notes||'').toLowerCase().includes(q)
|
|
)
|
|
})
|
|
|
|
const selectedPlan = computed(() =>
|
|
plans.value.find(p => p.id === props.modelValue) || null
|
|
)
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Load
|
|
// ─────────────────────────────────────────────────────────
|
|
async function loadPlans () {
|
|
loading.value = true
|
|
try {
|
|
const ownerId = await getOwnerId()
|
|
const { data, error } = await supabase
|
|
.from('insurance_plans')
|
|
.select('id, name, notes, default_value, active')
|
|
.eq('owner_id', ownerId)
|
|
.eq('active', true)
|
|
.order('name', { ascending: true })
|
|
if (error) throw error
|
|
plans.value = data || []
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar convênios.', life: 3500 })
|
|
} finally { loading.value = false }
|
|
}
|
|
|
|
watch(() => props.visible, (v) => {
|
|
if (v) { loadPlans(); showForm.value = false; searchTerm.value = ''; formErr.value = '' }
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Selecionar
|
|
// ─────────────────────────────────────────────────────────
|
|
function selectPlan (plan) {
|
|
emit('update:modelValue', plan.id)
|
|
emit('selected', plan)
|
|
close()
|
|
}
|
|
function clearSelection () {
|
|
emit('update:modelValue', null)
|
|
emit('selected', null)
|
|
close()
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Criar
|
|
// ─────────────────────────────────────────────────────────
|
|
function openForm () {
|
|
formErr.value = ''
|
|
newPlan.value = { name: '', notes: '', default_value: '' }
|
|
showForm.value = true
|
|
}
|
|
function cancelForm () {
|
|
showForm.value = false
|
|
formErr.value = ''
|
|
}
|
|
async function savePlan () {
|
|
const name = String(newPlan.value.name || '').trim()
|
|
if (!name) { formErr.value = 'Informe o nome do convênio.'; return }
|
|
saving.value = true; formErr.value = ''
|
|
try {
|
|
const ownerId = await getOwnerId()
|
|
const tenantId = await getTenantId()
|
|
const payload = {
|
|
owner_id: ownerId,
|
|
tenant_id: tenantId,
|
|
name,
|
|
notes: String(newPlan.value.notes || '').trim() || null,
|
|
default_value: newPlan.value.default_value !== '' ? Number(newPlan.value.default_value) : null,
|
|
active: true,
|
|
}
|
|
const { data, error } = await supabase
|
|
.from('insurance_plans').insert(payload)
|
|
.select('id, name, notes, default_value, active').single()
|
|
if (error) throw error
|
|
plans.value = [...plans.value, data].sort((a, b) => a.name.localeCompare(b.name))
|
|
toast.add({ severity: 'success', summary: 'Convênio criado', detail: `"${data.name}" adicionado.`, life: 2500 })
|
|
selectPlan(data)
|
|
} catch (e) {
|
|
const msg = e?.message || ''
|
|
formErr.value = /duplicate/i.test(msg) ? 'Já existe um convênio com esse nome.' : (msg || 'Falha ao criar.')
|
|
} finally { saving.value = false }
|
|
}
|
|
|
|
function close () { emit('update:visible', false) }
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog
|
|
:visible="visible"
|
|
@update:visible="$emit('update:visible', $event)"
|
|
modal
|
|
:draggable="false"
|
|
:closable="!saving"
|
|
:dismissableMask="!saving"
|
|
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)] bg-[var(--surface-ground)]' },
|
|
content: { class: '!p-3' },
|
|
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
|
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
|
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
|
}"
|
|
pt:mask:class="backdrop-blur-xs"
|
|
>
|
|
<!-- Header -->
|
|
<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">
|
|
<span class="flex items-center justify-center w-7 h-7 rounded-lg bg-blue-100 text-blue-600 text-[0.8rem] shrink-0">
|
|
<i class="pi pi-shield"/>
|
|
</span>
|
|
<div class="min-w-0">
|
|
<div class="text-base font-semibold truncate">Convênios</div>
|
|
<div class="text-xs opacity-50">Selecione ou cadastre um novo</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Corpo -->
|
|
<div class="flex flex-col gap-0">
|
|
|
|
<!-- Selecionado atualmente -->
|
|
<div v-if="selectedPlan && !showForm" class="flex items-center gap-2 mb-3 p-2.5 rounded-lg bg-blue-50 border border-blue-200/60">
|
|
<i class="pi pi-check-circle text-blue-500 shrink-0"/>
|
|
<span class="text-[0.82rem] font-semibold text-blue-700 flex-1 truncate">{{ selectedPlan.name }}</span>
|
|
<button type="button" class="text-[0.7rem] text-blue-400 hover:text-blue-600 underline" @click="clearSelection">remover</button>
|
|
</div>
|
|
|
|
<!-- Form de criação inline -->
|
|
<Transition
|
|
enter-active-class="transition-all duration-200 ease-out"
|
|
enter-from-class="opacity-0 -translate-y-2"
|
|
leave-active-class="transition-all duration-150 ease-in"
|
|
leave-to-class="opacity-0 -translate-y-2"
|
|
>
|
|
<div v-if="showForm" class="mb-4 p-3.5 rounded-xl border border-blue-200/60 bg-blue-50/60">
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<span class="text-[0.7rem] font-bold uppercase tracking-widest text-blue-500">Novo convênio</span>
|
|
<div class="flex-1 h-px bg-blue-200/50"/>
|
|
</div>
|
|
<div class="flex flex-col gap-3">
|
|
<div>
|
|
<FloatLabel variant="on">
|
|
<InputText id="cn_name" v-model="newPlan.name" class="w-full" variant="filled" autofocus @keydown.enter="savePlan"/>
|
|
<label for="cn_name">Nome do convênio *</label>
|
|
</FloatLabel>
|
|
<div class="mt-1 text-[0.65rem] text-blue-500/80">Ex: Unimed, Amil, Bradesco Saúde.</div>
|
|
</div>
|
|
<div>
|
|
<FloatLabel variant="on">
|
|
<InputText id="cn_notes" v-model="newPlan.notes" class="w-full" variant="filled" @keydown.enter="savePlan"/>
|
|
<label for="cn_notes">Observações (opcional)</label>
|
|
</FloatLabel>
|
|
</div>
|
|
<div>
|
|
<FloatLabel variant="on">
|
|
<IconField>
|
|
<InputIcon class="pi pi-dollar"/>
|
|
<InputNumber
|
|
id="cn_value"
|
|
v-model="newPlan.default_value"
|
|
class="w-full"
|
|
variant="filled"
|
|
:min="0"
|
|
:minFractionDigits="2"
|
|
:maxFractionDigits="2"
|
|
locale="pt-BR"
|
|
placeholder="0,00"
|
|
/>
|
|
</IconField>
|
|
<label for="cn_value">Valor padrão da sessão (opcional)</label>
|
|
</FloatLabel>
|
|
<div class="mt-1 text-[0.65rem] text-blue-500/80">Pré-preenchido ao criar sessão com este convênio.</div>
|
|
</div>
|
|
<div v-if="formErr" class="text-[0.8rem] text-red-500 flex items-center gap-1.5">
|
|
<i class="pi pi-exclamation-circle shrink-0"/>{{ formErr }}
|
|
</div>
|
|
<div class="flex gap-2 pt-1">
|
|
<Button label="Cancelar" severity="secondary" text class="flex-1 rounded-full hover:!text-red-500" @click="cancelForm"/>
|
|
<Button label="Salvar convênio" icon="pi pi-check" class="flex-1 rounded-full" :loading="saving" @click="savePlan"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Busca -->
|
|
<div v-if="!showForm" class="mb-3">
|
|
<IconField>
|
|
<InputIcon class="pi pi-search"/>
|
|
<InputText v-model="searchTerm" class="w-full" variant="filled" placeholder="Buscar convênio…"/>
|
|
</IconField>
|
|
</div>
|
|
|
|
<!-- Lista -->
|
|
<div v-if="!showForm" class="flex flex-col gap-1 max-h-[280px] overflow-y-auto pr-1">
|
|
|
|
<div v-if="loading" class="flex items-center justify-center py-8 gap-2 text-[var(--text-color-secondary)]">
|
|
<i class="pi pi-spin pi-spinner"/> Carregando…
|
|
</div>
|
|
|
|
<div v-else-if="!filteredPlans.length" class="text-center py-8">
|
|
<i class="pi pi-shield text-3xl text-[var(--text-color-secondary)] opacity-30 block mb-2"/>
|
|
<div class="text-[0.82rem] text-[var(--text-color-secondary)]">
|
|
{{ searchTerm ? 'Nenhum convênio encontrado.' : 'Nenhum convênio cadastrado ainda.' }}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
v-for="plan in filteredPlans" :key="plan.id"
|
|
type="button"
|
|
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-left border transition-all duration-100 w-full group"
|
|
:class="modelValue === plan.id
|
|
? 'bg-blue-500/10 border-blue-300/50 text-blue-700'
|
|
: 'border-transparent hover:bg-[var(--surface-ground)] text-[var(--text-color)]'"
|
|
@click="selectPlan(plan)"
|
|
>
|
|
<span
|
|
class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 text-[0.8rem] font-bold transition-colors"
|
|
:class="modelValue === plan.id
|
|
? 'bg-blue-200 text-blue-700'
|
|
: 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)] group-hover:bg-blue-100 group-hover:text-blue-600'"
|
|
>
|
|
{{ plan.name.slice(0,2).toUpperCase() }}
|
|
</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-[0.88rem] font-semibold leading-tight truncate">{{ plan.name }}</div>
|
|
<div v-if="plan.notes" class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">{{ plan.notes }}</div>
|
|
</div>
|
|
<div v-if="plan.default_value" class="text-[0.75rem] font-semibold text-emerald-600 shrink-0">
|
|
R$ {{ Number(plan.default_value).toLocaleString('pt-BR', { minimumFractionDigits: 2 }) }}
|
|
</div>
|
|
<i v-if="modelValue === plan.id" class="pi pi-check text-blue-500 shrink-0"/>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Botão cadastrar novo -->
|
|
<div v-if="!showForm && !loading" class="border-t border-[var(--surface-border)] mt-3 pt-3">
|
|
<Button
|
|
label="Cadastrar novo convênio"
|
|
icon="pi pi-plus"
|
|
severity="secondary"
|
|
outlined
|
|
class="rounded-full w-full"
|
|
@click="openForm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<template #footer>
|
|
<div class="flex items-center justify-end gap-2 px-3 py-3">
|
|
<Button
|
|
label="Fechar"
|
|
severity="secondary"
|
|
text
|
|
class="rounded-full hover:!text-red-500"
|
|
:disabled="saving"
|
|
@click="close"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
</template>
|