397 lines
9.7 KiB
Vue
397 lines
9.7 KiB
Vue
<template>
|
|
<Dialog
|
|
v-model:visible="isOpen"
|
|
modal
|
|
:draggable="false"
|
|
:closable="!saving"
|
|
:dismissableMask="!saving"
|
|
:style="{ width: '34rem', maxWidth: '92vw' }"
|
|
pt:mask:class="backdrop-blur-xs"
|
|
@hide="onHide"
|
|
>
|
|
<template #header>
|
|
<div class="flex flex-col gap-2">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div class="text-xl font-semibold">{{ title }}</div>
|
|
<div class="text-sm text-surface-500">
|
|
Crie um paciente rapidamente (nome, e-mail e telefone obrigatórios).
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TOPBAR ACTION -->
|
|
<Button
|
|
v-if="canSee('testMODE')"
|
|
label="Gerar usuário"
|
|
icon="pi pi-user-plus"
|
|
severity="secondary"
|
|
outlined
|
|
:disabled="saving"
|
|
@click="generateUser"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="flex flex-col gap-3">
|
|
<Message v-if="errorMsg" severity="error" :closable="false">
|
|
{{ errorMsg }}
|
|
</Message>
|
|
|
|
<div class="flex flex-col gap-2 mt-2">
|
|
<!-- Nome -->
|
|
<FloatLabel variant="on">
|
|
<IconField>
|
|
<InputIcon class="pi pi-user" />
|
|
<InputText
|
|
id="cr-nome"
|
|
v-model.trim="form.nome_completo"
|
|
class="w-full"
|
|
variant="filled"
|
|
:disabled="saving"
|
|
autocomplete="off"
|
|
autofocus
|
|
@keydown.enter.prevent="submit"
|
|
/>
|
|
</IconField>
|
|
<label for="cr-nome">Nome completo *</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<!-- E-mail -->
|
|
<FloatLabel variant="on">
|
|
<IconField>
|
|
<InputIcon class="pi pi-envelope" />
|
|
<InputText
|
|
id="cr-email"
|
|
v-model.trim="form.email_principal"
|
|
class="w-full"
|
|
variant="filled"
|
|
:disabled="saving"
|
|
inputmode="email"
|
|
autocomplete="off"
|
|
@keydown.enter.prevent="submit"
|
|
/>
|
|
</IconField>
|
|
<label for="cr-email">E-mail *</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<!-- Telefone -->
|
|
<FloatLabel variant="on">
|
|
<IconField>
|
|
<InputIcon class="pi pi-phone" />
|
|
<InputMask
|
|
id="cr-telefone"
|
|
v-model="form.telefone"
|
|
mask="(99) 99999-9999"
|
|
class="w-full"
|
|
variant="filled"
|
|
:disabled="saving"
|
|
@keydown.enter.prevent="submit"
|
|
/>
|
|
</IconField>
|
|
<label for="cr-telefone">Telefone *</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<div class="text-xs text-surface-500">
|
|
Dica: "Gerar usuário" preenche automaticamente com dados fictícios.
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<Button
|
|
label="Cancelar"
|
|
severity="secondary"
|
|
text
|
|
:disabled="saving"
|
|
@click="close"
|
|
/>
|
|
<Button
|
|
label="Salvar"
|
|
:loading="saving"
|
|
:disabled="saving"
|
|
@click="submit"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, reactive, ref, watch } from 'vue'
|
|
import { useRoleGuard } from '@/composables/useRoleGuard'
|
|
|
|
import { useToast } from 'primevue/usetoast'
|
|
|
|
import InputMask from 'primevue/inputmask'
|
|
import Message from 'primevue/message'
|
|
|
|
|
|
import { supabase } from '@/lib/supabase/client'
|
|
const { canSee } = useRoleGuard()
|
|
|
|
/**
|
|
* Lista "curada" de pensadores influentes na psicanálise e seu entorno.
|
|
* Usada para geração rápida de dados fictícios.
|
|
*/
|
|
const PSICANALISE_PENSADORES = Object.freeze([
|
|
{ nome: 'Sigmund Freud' },
|
|
{ nome: 'Jacques Lacan' },
|
|
{ nome: 'Melanie Klein' },
|
|
{ nome: 'Donald Winnicott' },
|
|
{ nome: 'Wilfred Bion' },
|
|
{ nome: 'Sándor Ferenczi' },
|
|
{ nome: 'Anna Freud' },
|
|
{ nome: 'Karl Abraham' },
|
|
{ nome: 'Otto Rank' },
|
|
{ nome: 'Karen Horney' },
|
|
{ nome: 'Erich Fromm' },
|
|
{ nome: 'Michael Balint' },
|
|
{ nome: 'Ronald Fairbairn' },
|
|
{ nome: 'John Bowlby' },
|
|
{ nome: 'André Green' },
|
|
{ nome: 'Jean Laplanche' },
|
|
{ nome: 'Christopher Bollas' },
|
|
{ nome: 'Thomas Ogden' },
|
|
{ nome: 'Jessica Benjamin' },
|
|
{ nome: 'Joyce McDougall' },
|
|
{ nome: 'Peter Fonagy' },
|
|
{ nome: 'Carl Gustav Jung' },
|
|
{ nome: 'Alfred Adler' }
|
|
])
|
|
|
|
// domínio seguro para dados fictícios
|
|
const AUTO_EMAIL_DOMAIN = 'example.com'
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Boolean, default: false },
|
|
title: { type: String, default: 'Cadastro rápido' },
|
|
|
|
tableName: { type: String, default: 'patients' },
|
|
ownerField: { type: String, default: 'owner_id' },
|
|
|
|
// defaults alinhados com seu schema
|
|
nameField: { type: String, default: 'nome_completo' },
|
|
emailField: { type: String, default: 'email_principal' },
|
|
phoneField: { type: String, default: 'telefone' },
|
|
|
|
// multi-tenant (defaults do seu schema)
|
|
tenantField: { type: String, default: 'tenant_id' },
|
|
responsibleMemberField: { type: String, default: 'responsible_member_id' },
|
|
|
|
extraPayload: { type: Object, default: () => ({}) },
|
|
|
|
closeOnCreated: { type: Boolean, default: true },
|
|
resetOnOpen: { type: Boolean, default: true }
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'created'])
|
|
const toast = useToast()
|
|
|
|
const saving = ref(false)
|
|
const touched = ref(false)
|
|
const errorMsg = ref('')
|
|
|
|
const form = reactive({
|
|
nome_completo: '',
|
|
email_principal: '',
|
|
telefone: ''
|
|
})
|
|
|
|
const isOpen = computed({
|
|
get: () => props.modelValue,
|
|
set: (v) => emit('update:modelValue', v)
|
|
})
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(v) => {
|
|
if (v && props.resetOnOpen) reset()
|
|
if (v) {
|
|
touched.value = false
|
|
errorMsg.value = ''
|
|
}
|
|
}
|
|
)
|
|
|
|
function reset () {
|
|
form.nome_completo = ''
|
|
form.email_principal = ''
|
|
form.telefone = ''
|
|
}
|
|
|
|
function close () {
|
|
isOpen.value = false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async function getOwnerId () {
|
|
const { data, error } = await supabase.auth.getUser()
|
|
if (error) throw error
|
|
const user = data?.user
|
|
if (!user?.id) throw new Error('Usuário não encontrado. Faça login novamente.')
|
|
return user.id
|
|
}
|
|
|
|
/**
|
|
* Pega tenant_id + member_id do usuário logado.
|
|
*/
|
|
async function resolveTenantContextOrFail () {
|
|
const { data: authData, error: authError } = await supabase.auth.getUser()
|
|
if (authError) throw authError
|
|
const uid = authData?.user?.id
|
|
if (!uid) throw new Error('Sessão inválida.')
|
|
|
|
const { data, error } = await supabase
|
|
.from('tenant_members')
|
|
.select('id, tenant_id')
|
|
.eq('user_id', uid)
|
|
.eq('status', 'active')
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.single()
|
|
|
|
if (error) throw error
|
|
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found')
|
|
|
|
return { tenantId: data.tenant_id, memberId: data.id }
|
|
}
|
|
|
|
/* ----------------------------
|
|
* Gerador (nome/email/telefone)
|
|
* ---------------------------- */
|
|
function randInt (min, max) {
|
|
return Math.floor(Math.random() * (max - min + 1)) + min
|
|
}
|
|
function pick (arr) {
|
|
return arr[randInt(0, arr.length - 1)]
|
|
}
|
|
function slugify (s) {
|
|
return String(s || '')
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '.')
|
|
.replace(/(^\.)|(\.$)/g, '')
|
|
}
|
|
function randomPhoneBRMasked () {
|
|
const ddd = randInt(11, 99)
|
|
const a = randInt(10000, 99999)
|
|
const b = randInt(1000, 9999)
|
|
return `(${ddd}) ${a}-${b}`
|
|
}
|
|
|
|
function generateUser () {
|
|
if (saving.value) return
|
|
|
|
const p = pick(PSICANALISE_PENSADORES)
|
|
const nome = p?.nome || 'Paciente'
|
|
|
|
const base = slugify(nome) || 'paciente'
|
|
const suffix = randInt(10, 999)
|
|
const email = `${base}.${suffix}@${AUTO_EMAIL_DOMAIN}`
|
|
|
|
form.nome_completo = nome
|
|
form.email_principal = email
|
|
form.telefone = randomPhoneBRMasked()
|
|
|
|
touched.value = true
|
|
errorMsg.value = ''
|
|
|
|
toast.add({
|
|
severity: 'info',
|
|
summary: 'Gerar usuário',
|
|
detail: 'Dados fictícios preenchidos.',
|
|
life: 2200
|
|
})
|
|
}
|
|
|
|
async function submit () {
|
|
touched.value = true
|
|
errorMsg.value = ''
|
|
|
|
const nome = String(form.nome_completo || '').trim()
|
|
const email = String(form.email_principal || '').trim()
|
|
const tel = String(form.telefone || '')
|
|
|
|
if (!nome) return
|
|
if (!email) return
|
|
if (!isValidEmail(email)) return
|
|
if (!tel) return
|
|
if (!isValidPhone(tel)) return
|
|
|
|
saving.value = true
|
|
try {
|
|
const ownerId = await getOwnerId()
|
|
const { tenantId, memberId } = await resolveTenantContextOrFail()
|
|
|
|
// extraPayload antes; tenant/responsible forçados depois (não podem ser sobrescritos sem querer)
|
|
const payload = {
|
|
...props.extraPayload,
|
|
|
|
[props.ownerField]: ownerId,
|
|
[props.tenantField]: tenantId,
|
|
[props.responsibleMemberField]: memberId,
|
|
|
|
[props.nameField]: nome,
|
|
[props.emailField]: email.toLowerCase(),
|
|
[props.phoneField]: normalizePhoneDigits(tel)
|
|
}
|
|
|
|
Object.keys(payload).forEach((k) => {
|
|
if (payload[k] === undefined) delete payload[k]
|
|
})
|
|
|
|
const { data, error } = await supabase
|
|
.from(props.tableName)
|
|
.insert(payload)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'Paciente criado',
|
|
detail: nome,
|
|
life: 2500
|
|
})
|
|
|
|
emit('created', data)
|
|
if (props.closeOnCreated) close()
|
|
} catch (err) {
|
|
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.'
|
|
errorMsg.value = msg
|
|
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Erro ao salvar',
|
|
detail: msg,
|
|
life: 4500
|
|
})
|
|
|
|
console.error('[ComponentCadastroRapido] insert error:', err)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script> |