Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização

This commit is contained in:
Leonardo
2026-03-30 14:08:19 -03:00
parent 0658e2e9bf
commit d088a89fb7
112 changed files with 115867 additions and 5266 deletions
+351
View File
@@ -0,0 +1,351 @@
<!--
|--------------------------------------------------------------------------
| 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)] 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"
>
<!-- 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>
+646
View File
@@ -0,0 +1,646 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI CadastroRapidoMedico.vue
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Dialog de cadastro rápido de médicos / profissionais de referência.
| Usado em PatientsCadastroPage (campo "Encaminhado por") e acessível
| pela futura MedicosCadastroPage.
|
| Props:
| visible (Boolean)
|
| Emits:
| update:visible
| created objeto do médico recém-criado
| selected médico selecionado da lista (para preencher campo no form)
|
| Tabela: public.medicos (ver medicos.sql)
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { digitsOnly, fmtPhone } from '@/utils/validators'
// ─────────────────────────────────────────────────────────
const props = defineProps({
visible: { type: Boolean, default: false },
editId: { type: String, default: null }, // uuid do médico a editar (null = novo)
})
const emit = defineEmits(['update:visible', 'created', 'selected'])
const toast = useToast()
const tenantStore = useTenantStore()
// ─────────────────────────────────────────────────────────
// Auth / tenant
// ─────────────────────────────────────────────────────────
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
}
// ─────────────────────────────────────────────────────────
// Views: 'list' | 'create' | 'edit'
// ─────────────────────────────────────────────────────────
const view = ref('list')
const medicos = ref([])
const loading = ref(false)
const searchTerm = ref('')
const editingId = ref(null) // uuid do médico sendo editado
// Form
const saving = ref(false)
const formErr = ref('')
const showTelProfissional = ref(false)
const showTelPessoal = ref(false)
function resetForm () {
return {
nome: '',
crm: '',
especialidade: '',
especialidade_outra: '',
telefone_profissional: '',
telefone_pessoal: '',
email: '',
clinica: '',
cidade: '',
estado: 'SP',
observacoes: '',
}
}
const form = ref(resetForm())
// ─────────────────────────────────────────────────────────
// Especialidades
// ─────────────────────────────────────────────────────────
const especialidadesOpts = [
{ label: 'Psiquiatria', value: 'Psiquiatria' },
{ label: 'Neurologia', value: 'Neurologia' },
{ label: 'Neuropsiquiatria infantil', value: 'Neuropsiquiatria infantil' },
{ label: 'Clínica geral', value: 'Clínica geral' },
{ label: 'Pediatria', value: 'Pediatria' },
{ label: 'Geriatria', value: 'Geriatria' },
{ label: 'Endocrinologia', value: 'Endocrinologia' },
{ label: 'Psicologia (encaminhador)', value: 'Psicologia (encaminhador)' },
{ label: 'Assistência social', value: 'Assistência social' },
{ label: 'Fonoaudiologia', value: 'Fonoaudiologia' },
{ label: 'Terapia ocupacional', value: 'Terapia ocupacional' },
{ label: 'Fisioterapia', value: 'Fisioterapia' },
{ label: 'Outra', value: '__outra__' },
]
const especialidadeFinal = computed(() =>
form.value.especialidade === '__outra__'
? (form.value.especialidade_outra.trim() || null)
: (form.value.especialidade || null)
)
// ─────────────────────────────────────────────────────────
// Computed
// ─────────────────────────────────────────────────────────
const filteredMedicos = computed(() => {
const q = searchTerm.value.toLowerCase().trim()
if (!q) return medicos.value
return medicos.value.filter(m =>
(m.nome || '').toLowerCase().includes(q) ||
(m.especialidade || '').toLowerCase().includes(q) ||
(m.crm || '').toLowerCase().includes(q) ||
(m.clinica || '').toLowerCase().includes(q)
)
})
// ─────────────────────────────────────────────────────────
// Load
// ─────────────────────────────────────────────────────────
async function loadMedicos () {
loading.value = true
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('medicos')
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
.eq('owner_id', ownerId)
.eq('ativo', true)
.order('nome', { ascending: true })
if (error) throw error
medicos.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médicos.', life: 3500 })
} finally { loading.value = false }
}
watch(() => props.visible, async (v) => {
if (v) {
searchTerm.value = ''
formErr.value = ''
showTelProfissional.value = false
showTelPessoal.value = false
if (props.editId) {
// Abre direto no form de edição com os dados carregados
await loadMedicoForEdit(props.editId)
} else {
view.value = 'list'
loadMedicos()
}
}
})
async function loadMedicoForEdit (id) {
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('medicos').select('*').eq('id', id).eq('owner_id', ownerId).single()
if (error) throw error
form.value = {
nome: data.nome || '',
crm: data.crm || '',
especialidade: data.especialidade || '',
especialidade_outra: '',
telefone_profissional: data.telefone_profissional ? fmtPhone(data.telefone_profissional) : '',
telefone_pessoal: data.telefone_pessoal ? fmtPhone(data.telefone_pessoal) : '',
email: data.email || '',
clinica: data.clinica || '',
cidade: data.cidade || '',
estado: data.estado || 'SP',
observacoes: data.observacoes || '',
}
editingId.value = id
view.value = 'edit'
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médico.', life: 3000 })
view.value = 'list'
loadMedicos()
}
}
// ─────────────────────────────────────────────────────────
// Ações lista
// ─────────────────────────────────────────────────────────
function openCreate () {
form.value = resetForm()
formErr.value = ''
editingId.value = null
showTelProfissional.value = false
showTelPessoal.value = false
view.value = 'create'
}
function backToList () {
view.value = 'list'
formErr.value = ''
editingId.value = null
loadMedicos()
}
function selectMedico (m) {
emit('selected', m)
close()
}
// ─────────────────────────────────────────────────────────
// Salvar
// ─────────────────────────────────────────────────────────
async function saveMedico () {
const nome = String(form.value.nome || '').trim()
if (!nome) { formErr.value = 'Informe o nome do médico.'; return }
if (form.value.especialidade === '__outra__' && !form.value.especialidade_outra.trim()) {
formErr.value = 'Informe a especialidade.'; return
}
saving.value = true; formErr.value = ''
const isUpdate = !!editingId.value
try {
const ownerId = await getOwnerId()
const tenantId = await getTenantId()
const payload = {
owner_id: ownerId,
tenant_id: tenantId,
nome,
crm: String(form.value.crm || '').trim() || null,
especialidade: especialidadeFinal.value,
telefone_profissional: form.value.telefone_profissional ? digitsOnly(form.value.telefone_profissional) : null,
telefone_pessoal: form.value.telefone_pessoal ? digitsOnly(form.value.telefone_pessoal) : null,
email: String(form.value.email || '').trim() || null,
clinica: String(form.value.clinica || '').trim() || null,
cidade: String(form.value.cidade || '').trim() || null,
estado: String(form.value.estado || '').trim() || null,
observacoes: String(form.value.observacoes || '').trim() || null,
ativo: true,
}
let data
if (isUpdate) {
const { data: d, error } = await supabase
.from('medicos').update({ ...payload, updated_at: new Date().toISOString() })
.eq('id', editingId.value).eq('owner_id', ownerId)
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
.single()
if (error) throw error
data = d
toast.add({ severity: 'success', summary: 'Médico atualizado', detail: `Dr(a). ${data.nome} atualizado.`, life: 2500 })
} else {
const { data: d, error } = await supabase
.from('medicos').insert(payload)
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
.single()
if (error) throw error
data = d
toast.add({ severity: 'success', summary: 'Médico cadastrado', detail: `Dr(a). ${data.nome} adicionado.`, life: 2500 })
}
emit(isUpdate ? 'selected' : 'created', data)
emit('selected', data)
close()
} catch (e) {
const msg = e?.message || ''
if (e?.code === '23505' || /duplicate/i.test(msg)) {
formErr.value = 'Já existe um cadastro com este CRM para este profissional.'
} else {
formErr.value = msg || 'Falha ao salvar.'
}
} 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-[50rem]"
: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"
>
<!-- 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-teal-100 text-teal-600 text-[0.8rem] shrink-0">
<i class="pi pi-user-plus"/>
</span>
<div class="min-w-0">
<div class="text-base font-semibold truncate">Médicos &amp; referências</div>
<div class="text-xs opacity-50">
<template v-if="view === 'list'">Selecione ou cadastre um novo profissional</template>
<template v-else-if="editingId">Editar dados do médico</template>
<template v-else>Novo médico / profissional de referência</template>
</div>
</div>
</div>
</div>
</template>
<!--
VIEW: LISTA
-->
<div v-if="view === 'list'" class="flex flex-col -mt-1">
<!-- Busca -->
<div class="mb-3">
<IconField>
<InputIcon class="pi pi-search"/>
<InputText v-model="searchTerm" class="w-full" variant="filled" placeholder="Buscar por nome, especialidade, CRM…"/>
</IconField>
</div>
<!-- Lista -->
<div class="flex flex-col gap-1 max-h-[300px] overflow-y-auto pr-0.5">
<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="!filteredMedicos.length" class="flex flex-col items-center py-8 gap-2 text-center">
<div class="w-12 h-12 rounded-full bg-teal-50 flex items-center justify-center">
<i class="pi pi-user-plus text-xl text-teal-300"/>
</div>
<div class="text-[0.82rem] text-[var(--text-color-secondary)]">
{{ searchTerm ? 'Nenhum médico encontrado.' : 'Nenhum médico cadastrado ainda.' }}
</div>
</div>
<button
v-for="m in filteredMedicos" :key="m.id"
type="button"
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-left border border-transparent hover:bg-[var(--surface-ground)] hover:border-teal-100 transition-all duration-100 w-full group"
@click="selectMedico(m)"
>
<!-- Iniciais -->
<div class="w-9 h-9 rounded-full bg-teal-100 flex items-center justify-center font-black text-[0.75rem] text-teal-700 shrink-0 group-hover:bg-teal-200 transition-colors select-none">
{{ (m.nome||'?').split(' ').filter(Boolean).map(w=>w[0].toUpperCase()).slice(0,2).join('') }}
</div>
<div class="flex-1 min-w-0">
<div class="text-[0.88rem] font-semibold text-[var(--text-color)] truncate leading-tight">
Dr(a). {{ m.nome }}
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">
<template v-if="m.especialidade">{{ m.especialidade }}</template>
<template v-if="m.crm"> · CRM {{ m.crm }}</template>
<template v-if="m.clinica"> · {{ m.clinica }}</template>
<template v-if="m.cidade"> · {{ m.cidade }}<template v-if="m.estado">/{{ m.estado }}</template></template>
</div>
</div>
<i class="pi pi-chevron-right text-[0.68rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-70 shrink-0"/>
</button>
</div>
<div class="border-t border-[var(--surface-border)] mt-3 pt-3">
<Button
label="Cadastrar novo médico"
icon="pi pi-plus"
severity="secondary"
outlined
class="rounded-full w-full"
@click="openCreate"
/>
</div>
</div>
<!--
VIEW: CRIAR
-->
<div v-else class="flex flex-col gap-3.5 -mt-1">
<!-- Voltar -->
<button
type="button"
class="flex items-center gap-1.5 text-[0.77rem] text-[var(--text-color-secondary)] hover:text-teal-600 transition-colors w-fit"
@click="backToList"
>
<i class="pi pi-arrow-left text-[0.72rem]"/> Voltar para a lista
</button>
<!-- Nome + CRM -->
<div class="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_150px]">
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-user"/>
<InputText id="m_nome" v-model="form.nome" class="w-full" variant="filled" autofocus/>
</IconField>
<label for="m_nome">Nome completo *</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<InputText id="m_crm" v-model="form.crm" class="w-full" variant="filled"/>
<label for="m_crm">CRM (ex: 123456/SP)</label>
</FloatLabel>
</div>
</div>
<!-- Especialidade -->
<div>
<FloatLabel variant="on">
<Select
id="m_esp"
v-model="form.especialidade"
:options="especialidadesOpts"
optionLabel="label"
optionValue="value"
class="w-full"
variant="filled"
filter
filterPlaceholder="Buscar especialidade…"
/>
<label for="m_esp">Especialidade</label>
</FloatLabel>
</div>
<!-- Especialidade "Outra" aparece condicionalmente -->
<Transition
enter-active-class="transition-all duration-150 ease-out"
enter-from-class="opacity-0 -translate-y-1"
leave-active-class="transition-all duration-100 ease-in"
leave-to-class="opacity-0 -translate-y-1"
>
<div v-if="form.especialidade === '__outra__'">
<FloatLabel variant="on">
<InputText
id="m_esp_outra"
v-model="form.especialidade_outra"
class="w-full"
variant="filled"
placeholder="Descreva a especialidade"
/>
<label for="m_esp_outra">Qual especialidade? *</label>
</FloatLabel>
</div>
</Transition>
<!-- Divider contatos -->
<div class="flex items-center gap-2">
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Contatos</span>
<div class="flex-1 h-px bg-teal-200/50"/>
</div>
<!-- Telefone profissional máscara normal, olho aparece quando preenchido -->
<div>
<div class="relative">
<InputMask
id="m_tel_prof"
v-model="form.telefone_profissional"
mask="(99) 99999-9999"
:unmask="false"
class="w-full"
:class="form.telefone_profissional ? 'pr-10' : ''"
variant="filled"
placeholder="(00) 00000-0000"
autocomplete="off"
/>
<!-- Olho renderiza quando dígitos preenchidos -->
<button
v-if="form.telefone_profissional?.replace(/\D/g,'').length >= 10"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 transition-colors z-10"
:class="showTelProfissional ? 'text-teal-600' : 'text-[var(--text-color-secondary)] hover:text-teal-600'"
tabindex="-1"
:title="showTelProfissional ? 'Ocultar número' : 'Revelar número completo'"
@click="showTelProfissional = !showTelProfissional"
>
<i :class="showTelProfissional ? 'pi pi-eye-slash' : 'pi pi-eye'" class="text-[0.9rem]"/>
</button>
</div>
<!-- Número revelado abaixo do campo -->
<div
v-if="showTelProfissional && form.telefone_profissional"
class="mt-1.5 flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-teal-50 border border-teal-200/60"
>
<i class="pi pi-phone text-teal-500 text-[0.75rem] shrink-0"/>
<span class="text-[0.82rem] font-semibold text-teal-700 tracking-wide">{{ form.telefone_profissional }}</span>
</div>
<div v-else class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
Número do consultório ou clínica.
</div>
</div>
<!-- Telefone pessoal mesma lógica -->
<div>
<div class="relative">
<InputMask
id="m_tel_pes"
v-model="form.telefone_pessoal"
mask="(99) 99999-9999"
:unmask="false"
class="w-full"
:class="form.telefone_pessoal ? 'pr-10' : ''"
variant="filled"
placeholder="(00) 00000-0000"
autocomplete="off"
/>
<button
v-if="form.telefone_pessoal?.replace(/\D/g,'').length >= 10"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 transition-colors z-10"
:class="showTelPessoal ? 'text-teal-600' : 'text-[var(--text-color-secondary)] hover:text-teal-600'"
tabindex="-1"
:title="showTelPessoal ? 'Ocultar número' : 'Revelar número completo'"
@click="showTelPessoal = !showTelPessoal"
>
<i :class="showTelPessoal ? 'pi pi-eye-slash' : 'pi pi-eye'" class="text-[0.9rem]"/>
</button>
</div>
<div
v-if="showTelPessoal && form.telefone_pessoal"
class="mt-1.5 flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-teal-50 border border-teal-200/60"
>
<i class="pi pi-mobile text-teal-500 text-[0.75rem] shrink-0"/>
<span class="text-[0.82rem] font-semibold text-teal-700 tracking-wide">{{ form.telefone_pessoal }}</span>
</div>
<div v-else class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
Pessoal / WhatsApp toque no olho para revelar após digitar.
</div>
</div>
<!-- Email -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-envelope"/>
<InputText id="m_email" v-model="form.email" class="w-full" variant="filled"/>
</IconField>
<label for="m_email">E-mail profissional</label>
</FloatLabel>
</div>
<!-- Divider localização -->
<div class="flex items-center gap-2">
<span class="text-[0.63rem] font-bold uppercase tracking-widest text-teal-500">Localização</span>
<div class="flex-1 h-px bg-teal-200/50"/>
</div>
<!-- Clínica + Cidade + UF -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-building"/>
<InputText id="m_clinica" v-model="form.clinica" class="w-full" variant="filled"/>
</IconField>
<label for="m_clinica">Clínica / Hospital</label>
</FloatLabel>
</div>
<div class="grid grid-cols-[1fr_90px] gap-3">
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-map-marker"/>
<InputText id="m_cidade" v-model="form.cidade" class="w-full" variant="filled"/>
</IconField>
<label for="m_cidade">Cidade</label>
</FloatLabel>
</div>
<div>
<FloatLabel variant="on">
<InputText id="m_uf" v-model="form.estado" class="w-full" variant="filled"/>
<label for="m_uf">UF</label>
</FloatLabel>
</div>
</div>
<!-- Observações -->
<div>
<FloatLabel variant="on">
<Textarea id="m_obs" v-model="form.observacoes" rows="2" class="w-full" variant="filled"/>
<label for="m_obs">Observações internas</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem] text-[var(--text-color-secondary)] opacity-60">
Ex: aceita WhatsApp, convênios atendidos, melhor horário.
</div>
</div>
<!-- Erro -->
<div v-if="formErr" class="flex items-start gap-1.5 text-[0.82rem] text-red-500 font-medium">
<i class="pi pi-exclamation-circle mt-0.5 shrink-0"/> {{ formErr }}
</div>
</div>
<!-- Footer -->
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button
v-if="view !== 'list'"
label="Cancelar"
severity="secondary"
text
class="rounded-full hover:!text-red-500"
:disabled="saving"
@click="backToList"
/>
<Button
v-else
label="Fechar"
severity="secondary"
text
class="rounded-full hover:!text-red-500"
@click="close"
/>
<Button
v-if="view !== 'list'"
:label="editingId ? 'Salvar alterações' : 'Salvar médico'"
icon="pi pi-check"
class="rounded-full"
:loading="saving"
@click="saveMedico"
/>
</div>
</template>
</Dialog>
</template>
+2 -11
View File
@@ -18,6 +18,7 @@
import { computed, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoleGuard } from '@/composables/useRoleGuard';
import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators';
import { useToast } from 'primevue/usetoast';
@@ -130,18 +131,8 @@ function close() {
function onHide() {}
function isValidEmail(v) {
return /.+@.+\..+/.test(String(v || '').trim());
}
function isValidPhone(v) {
const digits = String(v || '').replace(/\D/g, '');
return digits.length === 10 || digits.length === 11;
}
function normalizePhoneDigits(v) {
const digits = String(v || '').replace(/\D/g, '');
return digits || null;
return sanitizeDigits(v);
}
async function getOwnerId() {
+1 -1
View File
@@ -36,7 +36,7 @@
/>
</g>
</svg>
<h4 class="font-medium text-3xl text-surface-900 dark:text-surface-0">SAKAI</h4>
<h4 class="font-medium text-3xl text-surface-900 dark:text-surface-0">Agência PSI</h4>
</a>
</div>
+1 -1
View File
@@ -46,7 +46,7 @@ function smoothScroll(id) {
/>
</g>
</svg>
<span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">SAKAI</span>
<span class="text-surface-900 dark:text-surface-0 font-medium text-2xl leading-normal mr-20">Agência PSI</span>
</a>
<Button
class="lg:hidden!"
+203
View File
@@ -0,0 +1,203 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/JoditEmailEditor.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { Jodit } from 'jodit/esm/index.js';
import 'jodit/es2021/jodit.min.css';
const props = defineProps({
modelValue: { type: String, default: '' },
minHeight: { type: Number, default: 150 },
// true → toolbar enxuta + botões ▣ de layout para header/footer
layoutButtons: { type: Boolean, default: false },
// URL da logo do tenant usada nos snippets de layout
logoUrl: { type: String, default: null }
});
const emit = defineEmits(['update:modelValue']);
const container = ref(null);
let jodit = null;
let _ignoreChange = false;
let _themeObserver = null;
// ── Dark mode ─────────────────────────────────────────────────
function isDark() {
return document.documentElement.classList.contains('app-dark');
}
// ── Snippets de layout ────────────────────────────────────────
function logoSnippet(url) {
return url
? `<img src="${url}" width="72" height="72" style="display:block;object-fit:contain;border-radius:4px;" alt="Logo" />`
: `<div style="width:72px;height:72px;background:#e5e7eb;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:10px;color:#9ca3af;">[logo]</div>`;
}
function snippetLogoLeft(logo) {
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr>
<td width="88" valign="middle" style="padding-right:16px;">${logoSnippet(logo)}</td>
<td valign="middle"><p style="margin:0;font-size:14px;color:#374151;">Seu texto aqui</p></td>
</tr>
</table>`;
}
function snippetLogoRight(logo) {
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr>
<td valign="middle" style="padding-right:16px;"><p style="margin:0;font-size:14px;color:#374151;">Seu texto aqui</p></td>
<td width="88" valign="middle" style="text-align:right;">${logoSnippet(logo)}</td>
</tr>
</table>`;
}
function snippetLogoCenter(logo) {
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr>
<td align="center" style="padding-bottom:8px;">${logoSnippet(logo)}</td>
</tr>
<tr>
<td align="center"><p style="margin:0;font-size:14px;color:#374151;">Seu texto aqui</p></td>
</tr>
</table>`;
}
// ── Config Jodit ─────────────────────────────────────────────
function buildConfig() {
// Botões customizados de layout (somente nos editores de header/footer)
const layoutExtraButtons = props.layoutButtons
? [
{
name: 'layout-logo-left',
tooltip: 'Logo à esquerda, texto à direita',
text: '▣ Logo Esq.',
exec(editor) {
editor.selection.insertHTML(snippetLogoLeft(props.logoUrl));
}
},
{
name: 'layout-logo-right',
tooltip: 'Logo à direita, texto à esquerda',
text: '▣ Logo Dir.',
exec(editor) {
editor.selection.insertHTML(snippetLogoRight(props.logoUrl));
}
},
{
name: 'layout-logo-center',
tooltip: 'Logo centralizada, texto abaixo',
text: '▣ Logo Centro',
exec(editor) {
editor.selection.insertHTML(snippetLogoCenter(props.logoUrl));
}
}
]
: [];
// Toolbar enxuta para header/footer — sem hr, eraser, source
const layoutButtons = [
'bold', 'italic', 'underline', '|',
'font', 'fontsize', 'brush', '|',
'align', '|',
'link', '|',
'layout-logo-left', 'layout-logo-right', 'layout-logo-center'
];
// Toolbar completa para o corpo do e-mail
const bodyButtons = [
'bold', 'italic', 'underline', 'strikethrough', '|',
'ul', 'ol', '|',
'font', 'fontsize', 'brush', 'paragraph', '|',
'align', '|',
'link', 'table', '|',
'hr', 'eraser', '|',
'source'
];
return {
height: props.minHeight,
language: 'pt_br',
theme: isDark() ? 'dark' : 'default',
toolbarAdaptive: false,
toolbarSticky: false,
showCharsCounter: false,
showWordsCounter: false,
showXPathInStatusbar: false,
disablePlugins: ['about', 'stat'],
buttons: props.layoutButtons ? layoutButtons : bodyButtons,
extraButtons: layoutExtraButtons,
uploader: { insertImageAsBase64URI: false },
filebrowser: { ajax: { url: '' } }
};
}
// ── Init / destroy ────────────────────────────────────────────
function initJodit() {
if (jodit) {
jodit.destruct();
jodit = null;
}
jodit = Jodit.make(container.value, buildConfig());
if (props.modelValue) jodit.value = props.modelValue;
jodit.events.on('change', (content) => {
if (!_ignoreChange) emit('update:modelValue', content);
});
}
// ── Lifecycle ─────────────────────────────────────────────────
onMounted(() => {
initJodit();
// Recria o editor se o tema mudar enquanto o componente estiver montado
_themeObserver = new MutationObserver(() => {
const current = isDark() ? 'dark' : 'default';
if (jodit && jodit.o?.theme !== current) {
const saved = jodit.value;
initJodit();
if (saved) jodit.value = saved;
}
});
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
});
onBeforeUnmount(() => {
_themeObserver?.disconnect();
_themeObserver = null;
jodit?.destruct();
jodit = null;
});
watch(
() => props.modelValue,
(val) => {
if (!jodit) return;
if (jodit.value !== (val ?? '')) {
_ignoreChange = true;
jodit.value = val ?? '';
_ignoreChange = false;
}
}
);
// ── API exposta ───────────────────────────────────────────────
defineExpose({
insertHTML: (html) => jodit?.selection.insertHTML(html)
});
</script>
<template>
<div ref="container" />
</template>
+3 -7
View File
@@ -31,10 +31,8 @@ import { ref, computed } from 'vue';
import Popover from 'primevue/popover';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import PatientCadastroDialog from './PatientCadastroDialog.vue';
const emit = defineEmits(['quick-create']);
const showCadastroDialog = ref(false);
const emit = defineEmits(['quick-create', 'go-complete', 'show', 'hide']);
const toast = useToast();
const popRef = ref(null);
@@ -83,7 +81,7 @@ function onQuickCreate() {
}
function onGoComplete() {
close();
showCadastroDialog.value = true;
emit('go-complete');
}
async function copyLink() {
@@ -114,9 +112,7 @@ defineExpose({ toggle, close });
</script>
<template>
<PatientCadastroDialog v-model="showCadastroDialog" />
<Popover ref="popRef">
<Popover ref="popRef" @show="emit('show')" @hide="emit('hide')">
<div class="flex flex-col min-w-[230px]">
<!-- Cadastro rápido -->
<button class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="onQuickCreate">