Files
agenciapsilmno/src/components/ComponentCadastroRapido.vue
T
Leonardo 8e3c09d1b1 agenda: services nome unico por owner + cadastro in-flow
services: useServices.save e ServiceQuickCreateDialog agora validam nome
unico por owner (ilike, case-insensitive; ignora self no update). Antes
era possivel criar dois servicos com nome igual via paths diferentes.

cadastro in-flow: ComponentCadastroRapido e PatientCadastroDialog ganham
prop hideViewListButton. Quando true (uso dentro de outro fluxo, ex:
cadastrar paciente direto no AgendaEventDialog), esconde "Salvar e ver
pacientes" — navegar pra lista abandonaria o evento em edicao.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:44:27 -03:00

367 lines
12 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ComponentCadastroRapido.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
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';
import InputMask from 'primevue/inputmask';
import Message from 'primevue/message';
import { supabase } from '@/lib/supabase/client';
const { canSee } = useRoleGuard();
const route = useRoute();
const router = useRouter();
const isOnPatientsPage = computed(() => {
const p = String(route.path || '');
return p.includes('/patients') || p.includes('/pacientes');
});
/**
* 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: () => ({}) },
// Pré-preenchimento (usado ao converter número desconhecido em paciente, etc)
initialData: { type: Object, default: () => ({}) },
closeOnCreated: { type: Boolean, default: true },
resetOnOpen: { type: Boolean, default: true },
// Quando true (uso "in-flow", ex: dentro do AgendaEventDialog),
// esconde o botao "Salvar e ver pacientes" — navegar pra lista
// abandonaria o fluxo onde o cadastro foi aberto. Default false
// mantem o botao visivel pra usos standalone (sidebar, menu).
hideViewListButton: { type: Boolean, default: false }
});
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() {
const init = props.initialData || {};
form.nome_completo = init.nome_completo ?? '';
form.email_principal = init.email_principal ?? '';
form.telefone = init.telefone ?? '';
}
function close() {
isOpen.value = false;
}
function onHide() {}
function normalizePhoneDigits(v) {
return sanitizeDigits(v);
}
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
});
}
function patientsListRoute() {
const p = String(route.path || '');
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
}
async function submit(mode = 'only') {
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();
if (mode === 'view') await router.push(patientsListRoute());
} 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>
<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.</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('only')" />
</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('only')" />
</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('only')" />
</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" />
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): "Salvar" / "Salvar e fechar" -->
<Button v-if="isOnPatientsPage" label="Salvar" :loading="saving" :disabled="saving" @click="submit('only')" />
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="saving" :disabled="saving" @click="submit('only')" />
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
<template v-else>
<Button label="Salvar e fechar" severity="secondary" outlined :loading="saving" :disabled="saving" @click="submit('only')" />
<Button label="Salvar e ver pacientes" :loading="saving" :disabled="saving" @click="submit('view')" />
</template>
</div>
</template>
</Dialog>
</template>