8e3c09d1b1
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>
367 lines
12 KiB
Vue
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): só "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>
|