develop #83
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { buscarPacientePorId } from "@/lib/api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@ -464,107 +463,56 @@ async function handleSubmit(ev: React.FormEvent) {
|
|||||||
const savedDoctorProfile = await criarMedico(medicoPayload);
|
const savedDoctorProfile = await criarMedico(medicoPayload);
|
||||||
console.log("✅ Perfil do médico criado:", savedDoctorProfile);
|
console.log("✅ Perfil do médico criado:", savedDoctorProfile);
|
||||||
|
|
||||||
// 2. Cria usuário no Supabase Auth (direto via /auth/v1/signup)
|
// The server-side Edge Function `criarMedico` should perform the privileged
|
||||||
console.log('🔐 Criando usuário de autenticação...');
|
// operations (create doctor row and auth user) and return a normalized
|
||||||
|
// envelope or the created doctor object. We rely on that single-call flow
|
||||||
try {
|
// here instead of creating the auth user from the browser.
|
||||||
const authResponse = await criarUsuarioMedico({
|
|
||||||
email: form.email,
|
|
||||||
full_name: form.full_name,
|
|
||||||
phone_mobile: form.celular || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (authResponse.success && authResponse.user) {
|
// savedDoctorProfile may be either a Medico object, an envelope with
|
||||||
console.log('✅ Usuário Auth criado:', authResponse.user.id);
|
// { doctor, doctor_id, email, password, user_id } or similar shapes.
|
||||||
|
const result = savedDoctorProfile as any;
|
||||||
|
console.log('✅ Resultado de criarMedico:', result);
|
||||||
|
|
||||||
// Attempt to link the created auth user id to the doctors record
|
// Determine the doctor id if available
|
||||||
try {
|
let createdDoctorId: string | null = null;
|
||||||
// savedDoctorProfile may be an array or object depending on API
|
if (result) {
|
||||||
const docId = (savedDoctorProfile && (savedDoctorProfile.id || (Array.isArray(savedDoctorProfile) ? savedDoctorProfile[0]?.id : undefined))) || null;
|
if (result.id) createdDoctorId = String(result.id);
|
||||||
if (docId) {
|
else if (result.doctor && result.doctor.id) createdDoctorId = String(result.doctor.id);
|
||||||
console.log('[DoctorForm] Vinculando user_id ao médico:', { doctorId: docId, userId: authResponse.user.id });
|
else if (result.doctor_id) createdDoctorId = String(result.doctor_id);
|
||||||
// dynamic import to avoid circular deps in some bundlers
|
else if (Array.isArray(result) && result[0]?.id) createdDoctorId = String(result[0].id);
|
||||||
const api = await import('@/lib/api');
|
}
|
||||||
if (api && typeof api.vincularUserIdMedico === 'function') {
|
|
||||||
await api.vincularUserIdMedico(String(docId), String(authResponse.user.id));
|
|
||||||
console.log('[DoctorForm] user_id vinculado com sucesso.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[DoctorForm] Não foi possível determinar o ID do médico para vincular user_id. Doctor profile:', savedDoctorProfile);
|
|
||||||
}
|
|
||||||
} catch (linkErr) {
|
|
||||||
console.warn('[DoctorForm] Falha ao vincular user_id ao médico:', linkErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Exibe popup com credenciais
|
// If the function returned credentials, show them in the credentials dialog
|
||||||
|
if (result && (result.password || result.email || result.user)) {
|
||||||
setCredentials({
|
setCredentials({
|
||||||
email: authResponse.email,
|
email: result.email || form.email,
|
||||||
password: authResponse.password,
|
password: result.password || "",
|
||||||
userName: form.full_name,
|
userName: form.full_name,
|
||||||
userType: 'médico',
|
userType: 'médico',
|
||||||
});
|
});
|
||||||
setShowCredentialsDialog(true);
|
setShowCredentialsDialog(true);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Limpa formulário
|
// Upload photo if provided and we have an id
|
||||||
setForm(initial);
|
if (form.photo && createdDoctorId) {
|
||||||
setPhotoPreview(null);
|
try {
|
||||||
setServerAnexos([]);
|
setUploadingPhoto(true);
|
||||||
|
await uploadFotoMedico(String(createdDoctorId), form.photo);
|
||||||
// If a photo was selected during creation, upload it now
|
} catch (upErr) {
|
||||||
if (form.photo) {
|
console.warn('[DoctorForm] Falha ao enviar foto do médico após criação:', upErr);
|
||||||
try {
|
alert('Médico criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.');
|
||||||
setUploadingPhoto(true);
|
} finally {
|
||||||
const docId = (savedDoctorProfile && (savedDoctorProfile.id || (Array.isArray(savedDoctorProfile) ? savedDoctorProfile[0]?.id : undefined))) || null;
|
setUploadingPhoto(false);
|
||||||
if (docId) await uploadFotoMedico(String(docId), form.photo);
|
|
||||||
} catch (upErr) {
|
|
||||||
console.warn('[DoctorForm] Falha ao enviar foto do médico após criação:', upErr);
|
|
||||||
alert('Médico criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.');
|
|
||||||
} finally {
|
|
||||||
setUploadingPhoto(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Notifica componente pai
|
|
||||||
onSaved?.(savedDoctorProfile);
|
|
||||||
} else {
|
|
||||||
throw new Error('Falha ao criar usuário de autenticação');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (authError: any) {
|
// Cleanup and notify parent
|
||||||
console.error('❌ Erro ao criar usuário Auth:', authError);
|
|
||||||
|
|
||||||
const errorMsg = authError?.message || String(authError);
|
|
||||||
|
|
||||||
// Mensagens específicas de erro
|
|
||||||
if (errorMsg.toLowerCase().includes('already registered') ||
|
|
||||||
errorMsg.toLowerCase().includes('already been registered') ||
|
|
||||||
errorMsg.toLowerCase().includes('já está cadastrado')) {
|
|
||||||
alert(
|
|
||||||
`⚠️ EMAIL JÁ CADASTRADO\n\n` +
|
|
||||||
`O email "${form.email}" já possui uma conta no sistema.\n\n` +
|
|
||||||
`✅ O perfil do médico "${form.full_name}" foi salvo com sucesso.\n\n` +
|
|
||||||
`❌ Porém, não foi possível criar o login porque este email já está em uso.\n\n` +
|
|
||||||
`SOLUÇÃO:\n` +
|
|
||||||
`• Use um email diferente para este médico, OU\n` +
|
|
||||||
`• Se o médico já tem conta, edite o perfil e vincule ao usuário existente`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
alert(
|
|
||||||
`⚠️ Médico cadastrado com sucesso, mas houve um problema ao criar o acesso ao sistema.\n\n` +
|
|
||||||
`✅ Perfil do médico salvo: ${form.full_name}\n\n` +
|
|
||||||
`❌ Erro ao criar login: ${errorMsg}\n\n` +
|
|
||||||
`Por favor, entre em contato com o administrador para criar o acesso manualmente.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limpa formulário mesmo com erro
|
|
||||||
setForm(initial);
|
setForm(initial);
|
||||||
setPhotoPreview(null);
|
setPhotoPreview(null);
|
||||||
setServerAnexos([]);
|
setServerAnexos([]);
|
||||||
onSaved?.(savedDoctorProfile);
|
onSaved?.(savedDoctorProfile);
|
||||||
if (inline) onClose?.();
|
if (inline) onClose?.();
|
||||||
else onOpenChange?.(false);
|
else onOpenChange?.(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("❌ Erro no handleSubmit:", err);
|
console.error("❌ Erro no handleSubmit:", err);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { format, parse, isValid, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@ -24,7 +24,6 @@ import {
|
|||||||
listarAnexos,
|
listarAnexos,
|
||||||
removerAnexo,
|
removerAnexo,
|
||||||
buscarPacientePorId,
|
buscarPacientePorId,
|
||||||
criarUsuarioPaciente,
|
|
||||||
criarPaciente,
|
criarPaciente,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getAvatarPublicUrl } from '@/lib/api';
|
import { getAvatarPublicUrl } from '@/lib/api';
|
||||||
@ -104,8 +103,7 @@ export function PatientRegistrationForm({
|
|||||||
const [isSearchingCEP, setSearchingCEP] = useState(false);
|
const [isSearchingCEP, setSearchingCEP] = useState(false);
|
||||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||||
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
|
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
|
||||||
|
|
||||||
// Estados para o dialog de credenciais
|
|
||||||
const [showCredentialsDialog, setShowCredentialsDialog] = useState(false);
|
const [showCredentialsDialog, setShowCredentialsDialog] = useState(false);
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
email: string;
|
email: string;
|
||||||
@ -120,9 +118,7 @@ export function PatientRegistrationForm({
|
|||||||
async function load() {
|
async function load() {
|
||||||
if (mode !== "edit" || patientId == null) return;
|
if (mode !== "edit" || patientId == null) return;
|
||||||
try {
|
try {
|
||||||
console.log("[PatientForm] Carregando paciente ID:", patientId);
|
|
||||||
const p = await buscarPacientePorId(String(patientId));
|
const p = await buscarPacientePorId(String(patientId));
|
||||||
console.log("[PatientForm] Dados recebidos:", p);
|
|
||||||
setForm((s) => ({
|
setForm((s) => ({
|
||||||
...s,
|
...s,
|
||||||
nome: p.full_name || "",
|
nome: p.full_name || "",
|
||||||
@ -130,9 +126,7 @@ export function PatientRegistrationForm({
|
|||||||
cpf: p.cpf || "",
|
cpf: p.cpf || "",
|
||||||
rg: p.rg || "",
|
rg: p.rg || "",
|
||||||
sexo: p.sex || "",
|
sexo: p.sex || "",
|
||||||
birth_date: p.birth_date ? (() => {
|
birth_date: p.birth_date ? (() => { try { return format(parseISO(String(p.birth_date)), 'dd/MM/yyyy'); } catch { return String(p.birth_date); } })() : "",
|
||||||
try { return format(parseISO(String(p.birth_date)), 'dd/MM/yyyy'); } catch { return String(p.birth_date); }
|
|
||||||
})() : "",
|
|
||||||
telefone: p.phone_mobile || "",
|
telefone: p.phone_mobile || "",
|
||||||
email: p.email || "",
|
email: p.email || "",
|
||||||
cep: p.cep || "",
|
cep: p.cep || "",
|
||||||
@ -147,7 +141,7 @@ export function PatientRegistrationForm({
|
|||||||
|
|
||||||
const ax = await listarAnexos(String(patientId)).catch(() => []);
|
const ax = await listarAnexos(String(patientId)).catch(() => []);
|
||||||
setServerAnexos(Array.isArray(ax) ? ax : []);
|
setServerAnexos(Array.isArray(ax) ? ax : []);
|
||||||
// Try to detect existing public avatar (no file extension) and set preview
|
|
||||||
try {
|
try {
|
||||||
const url = getAvatarPublicUrl(String(patientId));
|
const url = getAvatarPublicUrl(String(patientId));
|
||||||
try {
|
try {
|
||||||
@ -157,14 +151,10 @@ export function PatientRegistrationForm({
|
|||||||
const get = await fetch(url, { method: 'GET' });
|
const get = await fetch(url, { method: 'GET' });
|
||||||
if (get.ok) { setPhotoPreview(url); }
|
if (get.ok) { setPhotoPreview(url); }
|
||||||
}
|
}
|
||||||
} catch (inner) {
|
} catch (inner) { /* ignore */ }
|
||||||
// ignore network/CORS errors while detecting
|
} catch (detectErr) { /* ignore */ }
|
||||||
}
|
|
||||||
} catch (detectErr) {
|
|
||||||
// ignore detection errors
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[PatientForm] Erro ao carregar paciente:", err);
|
console.error('[PatientForm] Erro ao carregar paciente:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
@ -179,33 +169,13 @@ export function PatientRegistrationForm({
|
|||||||
const n = v.replace(/\D/g, "").slice(0, 11);
|
const n = v.replace(/\D/g, "").slice(0, 11);
|
||||||
return n.replace(/(\d{3})(\d{3})(\d{3})(\d{0,2})/, (_, a, b, c, d) => `${a}.${b}.${c}${d ? "-" + d : ""}`);
|
return n.replace(/(\d{3})(\d{3})(\d{3})(\d{0,2})/, (_, a, b, c, d) => `${a}.${b}.${c}${d ? "-" + d : ""}`);
|
||||||
}
|
}
|
||||||
function handleCPFChange(v: string) {
|
function handleCPFChange(v: string) { setField("cpf", formatCPF(v)); }
|
||||||
setField("cpf", formatCPF(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCEP(v: string) {
|
function formatCEP(v: string) { const n = v.replace(/\D/g, "").slice(0, 8); return n.replace(/(\d{5})(\d{0,3})/, (_, a, b) => `${a}${b ? "-" + b : ""}`); }
|
||||||
const n = v.replace(/\D/g, "").slice(0, 8);
|
|
||||||
return n.replace(/(\d{5})(\d{0,3})/, (_, a, b) => `${a}${b ? "-" + b : ""}`);
|
|
||||||
}
|
|
||||||
async function fillFromCEP(cep: string) {
|
async function fillFromCEP(cep: string) {
|
||||||
const clean = cep.replace(/\D/g, "");
|
const clean = cep.replace(/\D/g, ""); if (clean.length !== 8) return; setSearchingCEP(true);
|
||||||
if (clean.length !== 8) return;
|
try { const res = await buscarCepAPI(clean); if (res?.erro) setErrors((e) => ({ ...e, cep: "CEP não encontrado" })); else { setField("logradouro", res.logradouro ?? ""); setField("bairro", res.bairro ?? ""); setField("cidade", res.localidade ?? ""); setField("estado", res.uf ?? ""); } }
|
||||||
setSearchingCEP(true);
|
catch { setErrors((e) => ({ ...e, cep: "Erro ao buscar CEP" })); } finally { setSearchingCEP(false); }
|
||||||
try {
|
|
||||||
const res = await buscarCepAPI(clean);
|
|
||||||
if (res?.erro) {
|
|
||||||
setErrors((e) => ({ ...e, cep: "CEP não encontrado" }));
|
|
||||||
} else {
|
|
||||||
setField("logradouro", res.logradouro ?? "");
|
|
||||||
setField("bairro", res.bairro ?? "");
|
|
||||||
setField("cidade", res.localidade ?? "");
|
|
||||||
setField("estado", res.uf ?? "");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setErrors((e) => ({ ...e, cep: "Erro ao buscar CEP" }));
|
|
||||||
} finally {
|
|
||||||
setSearchingCEP(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateLocal(): boolean {
|
function validateLocal(): boolean {
|
||||||
@ -222,14 +192,9 @@ export function PatientRegistrationForm({
|
|||||||
try {
|
try {
|
||||||
const parts = String(form.birth_date).split(/\D+/).filter(Boolean);
|
const parts = String(form.birth_date).split(/\D+/).filter(Boolean);
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
const [d, m, y] = parts;
|
const [d, m, y] = parts; const date = new Date(Number(y), Number(m) - 1, Number(d)); if (!isNaN(date.getTime())) isoDate = date.toISOString().slice(0, 10);
|
||||||
const date = new Date(Number(y), Number(m) - 1, Number(d));
|
|
||||||
if (!isNaN(date.getTime())) {
|
|
||||||
isoDate = date.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
full_name: form.nome,
|
full_name: form.nome,
|
||||||
social_name: form.nome_social || null,
|
social_name: form.nome_social || null,
|
||||||
@ -251,375 +216,110 @@ export function PatientRegistrationForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(ev: React.FormEvent) {
|
async function handleSubmit(ev: React.FormEvent) {
|
||||||
ev.preventDefault();
|
ev.preventDefault(); if (!validateLocal()) return;
|
||||||
if (!validateLocal()) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!validarCPFLocal(form.cpf)) {
|
if (!validarCPFLocal(form.cpf)) { setErrors((e) => ({ ...e, cpf: "CPF inválido" })); return; }
|
||||||
setErrors((e) => ({ ...e, cpf: "CPF inválido" }));
|
if (mode === "create") { const existe = await verificarCpfDuplicado(form.cpf); if (existe) { setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" })); return; } }
|
||||||
return;
|
} catch (err) { console.error("Erro ao validar CPF", err); setErrors({ submit: "Erro ao validar CPF." }); return; }
|
||||||
}
|
|
||||||
if (mode === "create") {
|
|
||||||
const existe = await verificarCpfDuplicado(form.cpf);
|
|
||||||
if (existe) {
|
|
||||||
setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Erro ao validar CPF", err);
|
|
||||||
setErrors({ submit: "Erro ao validar CPF." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (mode === "edit") {
|
if (mode === "edit") {
|
||||||
if (patientId == null) throw new Error("Paciente inexistente para edição");
|
if (patientId == null) throw new Error("Paciente inexistente para edição");
|
||||||
const payload = toPayload();
|
const payload = toPayload(); const saved = await atualizarPaciente(String(patientId), payload);
|
||||||
const saved = await atualizarPaciente(String(patientId), payload);
|
|
||||||
// If a new photo was selected locally, remove existing public avatar (if any) then upload the new one
|
|
||||||
if (form.photo) {
|
if (form.photo) {
|
||||||
try {
|
try { setUploadingPhoto(true); try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); } await uploadFotoPaciente(String(patientId), form.photo); }
|
||||||
setUploadingPhoto(true);
|
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr); alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.'); }
|
||||||
// Attempt to remove existing avatar first (no-op if none)
|
finally { setUploadingPhoto(false); }
|
||||||
try {
|
|
||||||
await removerFotoPaciente(String(patientId));
|
|
||||||
// clear any cached preview so upload result will repopulate it
|
|
||||||
setPhotoPreview(null);
|
|
||||||
} catch (remErr) {
|
|
||||||
// If removal fails (permissions/CORS), continue to attempt upload — we don't want to block the user
|
|
||||||
console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr);
|
|
||||||
}
|
|
||||||
await uploadFotoPaciente(String(patientId), form.photo);
|
|
||||||
} catch (upErr) {
|
|
||||||
console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr);
|
|
||||||
// don't block the main update — show a warning
|
|
||||||
alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.');
|
|
||||||
} finally {
|
|
||||||
setUploadingPhoto(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onSaved?.(saved);
|
onSaved?.(saved); alert("Paciente atualizado com sucesso!"); setForm(initial); setPhotoPreview(null); setServerAnexos([]); if (inline) onClose?.(); else onOpenChange?.(false);
|
||||||
alert("Paciente atualizado com sucesso!");
|
|
||||||
|
|
||||||
setForm(initial);
|
|
||||||
setPhotoPreview(null);
|
|
||||||
setServerAnexos([]);
|
|
||||||
if (inline) onClose?.();
|
|
||||||
else onOpenChange?.(false);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// --- NOVA LÓGICA DE CRIAÇÃO ---
|
// create
|
||||||
const patientPayload = toPayload();
|
const patientPayload = toPayload();
|
||||||
const savedPatientProfile = await criarPaciente(patientPayload);
|
// require phone when email present for single-call function
|
||||||
console.log(" Perfil do paciente criado:", savedPatientProfile);
|
if (form.email && form.email.includes('@') && (!form.telefone || !String(form.telefone).trim())) {
|
||||||
|
setErrors((e) => ({ ...e, telefone: 'Telefone é obrigatório quando email é informado (fluxo de criação único).' })); setSubmitting(false); return;
|
||||||
if (form.email && form.email.includes('@')) {
|
|
||||||
console.log(" Criando usuário de autenticação (paciente)...");
|
|
||||||
try {
|
|
||||||
const userResponse = await criarUsuarioPaciente({
|
|
||||||
email: form.email,
|
|
||||||
full_name: form.nome,
|
|
||||||
phone_mobile: form.telefone,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userResponse.success && userResponse.user) {
|
|
||||||
console.log(" Usuário de autenticação criado:", userResponse.user);
|
|
||||||
|
|
||||||
// Mostra credenciais no dialog usando as credenciais retornadas
|
|
||||||
setCredentials({
|
|
||||||
email: userResponse.email ?? form.email,
|
|
||||||
password: userResponse.password ?? '',
|
|
||||||
userName: form.nome,
|
|
||||||
userType: 'paciente',
|
|
||||||
});
|
|
||||||
setShowCredentialsDialog(true);
|
|
||||||
|
|
||||||
// Tenta vincular o user_id ao perfil do paciente recém-criado
|
|
||||||
try {
|
|
||||||
const apiMod = await import('@/lib/api');
|
|
||||||
const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id);
|
|
||||||
const userId = (userResponse.user as any)?.id || (userResponse.user as any)?.user_id || (userResponse.user as any)?.id;
|
|
||||||
|
|
||||||
// Guard: verify userId is present and looks plausible before attempting to PATCH
|
|
||||||
const isPlausibleUserId = (id: any) => {
|
|
||||||
if (!id) return false;
|
|
||||||
const s = String(id).trim();
|
|
||||||
if (!s) return false;
|
|
||||||
// quick UUID v4-ish check (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) or numeric id fallback
|
|
||||||
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
const numeric = /^\d+$/;
|
|
||||||
return uuidV4.test(s) || numeric.test(s) || s.length >= 8;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!pacienteId) {
|
|
||||||
console.warn('[PatientForm] pacienteId ausente; pulando vinculação de user_id');
|
|
||||||
} else if (!isPlausibleUserId(userId)) {
|
|
||||||
// Do not attempt to PATCH when userId is missing/invalid to avoid 400s
|
|
||||||
console.warn('[PatientForm] userId inválido ou ausente; não será feita a vinculação. userResponse:', userResponse);
|
|
||||||
} else if (typeof apiMod.vincularUserIdPaciente === 'function') {
|
|
||||||
console.log('[PatientForm] Vinculando user_id ao paciente:', pacienteId, userId);
|
|
||||||
try {
|
|
||||||
await apiMod.vincularUserIdPaciente(pacienteId, String(userId));
|
|
||||||
console.log('[PatientForm] user_id vinculado com sucesso ao paciente');
|
|
||||||
} catch (linkErr) {
|
|
||||||
console.warn('[PatientForm] Falha ao vincular user_id ao paciente:', linkErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (dynErr) {
|
|
||||||
console.warn('[PatientForm] Não foi possível importar helper para vincular user_id:', dynErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limpa formulário mas NÃO fecha ainda - fechará quando o dialog de credenciais fechar
|
|
||||||
setForm(initial);
|
|
||||||
setPhotoPreview(null);
|
|
||||||
setServerAnexos([]);
|
|
||||||
// If a photo was selected during creation, upload it now using the created patient id
|
|
||||||
if (form.photo) {
|
|
||||||
try {
|
|
||||||
setUploadingPhoto(true);
|
|
||||||
const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id);
|
|
||||||
if (pacienteId) {
|
|
||||||
await uploadFotoPaciente(String(pacienteId), form.photo);
|
|
||||||
}
|
|
||||||
} catch (upErr) {
|
|
||||||
console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr);
|
|
||||||
// Non-blocking: inform user
|
|
||||||
alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.');
|
|
||||||
} finally {
|
|
||||||
setUploadingPhoto(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onSaved?.(savedPatientProfile);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw new Error((userResponse as any).message || "Falhou ao criar o usuário de acesso.");
|
|
||||||
}
|
|
||||||
} catch (userError: any) {
|
|
||||||
console.error(" Erro ao criar usuário via signup:", userError);
|
|
||||||
|
|
||||||
// Mensagem de erro específica para email duplicado
|
|
||||||
const errorMsg = userError?.message || String(userError);
|
|
||||||
|
|
||||||
if (errorMsg.toLowerCase().includes('already registered') ||
|
|
||||||
errorMsg.toLowerCase().includes('já está cadastrado') ||
|
|
||||||
errorMsg.toLowerCase().includes('já existe')) {
|
|
||||||
alert(
|
|
||||||
` Este email já está cadastrado no sistema.\n\n` +
|
|
||||||
` O perfil do paciente foi salvo com sucesso.\n\n` +
|
|
||||||
`Para criar acesso ao sistema, use um email diferente ou recupere a senha do email existente.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
alert(
|
|
||||||
` Paciente cadastrado com sucesso!\n\n` +
|
|
||||||
` Porém houve um problema ao criar o acesso:\n${errorMsg}\n\n` +
|
|
||||||
`O cadastro do paciente foi salvo, mas será necessário criar o acesso manualmente.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limpa formulário e fecha
|
|
||||||
setForm(initial);
|
|
||||||
setPhotoPreview(null);
|
|
||||||
setServerAnexos([]);
|
|
||||||
onSaved?.(savedPatientProfile);
|
|
||||||
if (inline) onClose?.();
|
|
||||||
else onOpenChange?.(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert("Paciente cadastrado com sucesso (sem usuário de acesso - email não fornecido).");
|
|
||||||
onSaved?.(savedPatientProfile);
|
|
||||||
setForm(initial);
|
|
||||||
setPhotoPreview(null);
|
|
||||||
setServerAnexos([]);
|
|
||||||
if (inline) onClose?.();
|
|
||||||
else onOpenChange?.(false);
|
|
||||||
}
|
}
|
||||||
|
const savedPatientProfile = await criarPaciente(patientPayload);
|
||||||
|
console.log('Perfil do paciente criado (via Function):', savedPatientProfile);
|
||||||
|
|
||||||
|
const maybePassword = (savedPatientProfile as any)?.password || (savedPatientProfile as any)?.generated_password;
|
||||||
|
if (maybePassword) {
|
||||||
|
setCredentials({ email: (savedPatientProfile as any).email || form.email, password: String(maybePassword), userName: form.nome, userType: 'paciente' });
|
||||||
|
setShowCredentialsDialog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.photo) {
|
||||||
|
try { setUploadingPhoto(true); const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id); if (pacienteId) await uploadFotoPaciente(String(pacienteId), form.photo); }
|
||||||
|
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr); alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); }
|
||||||
|
finally { setUploadingPhoto(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaved?.(savedPatientProfile); setForm(initial); setPhotoPreview(null); setServerAnexos([]); if (inline) onClose?.(); else onOpenChange?.(false);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) { console.error("❌ Erro no handleSubmit:", err); const userMessage = err?.message?.includes("toPayload") || err?.message?.includes("is not defined") ? "Erro ao processar os dados do formulário. Por favor, verifique os campos e tente novamente." : err?.message || "Erro ao salvar paciente. Por favor, tente novamente."; setErrors({ submit: userMessage }); }
|
||||||
console.error("❌ Erro no handleSubmit:", err);
|
finally { setSubmitting(false); }
|
||||||
// Exibe mensagem amigável ao usuário
|
|
||||||
const userMessage = err?.message?.includes("toPayload") || err?.message?.includes("is not defined")
|
|
||||||
? "Erro ao processar os dados do formulário. Por favor, verifique os campos e tente novamente."
|
|
||||||
: err?.message || "Erro ao salvar paciente. Por favor, tente novamente.";
|
|
||||||
setErrors({ submit: userMessage });
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePhoto(e: React.ChangeEvent<HTMLInputElement>) {
|
function handlePhoto(e: React.ChangeEvent<HTMLInputElement>) { const f = e.target.files?.[0]; if (!f) return; if (f.size > 5 * 1024 * 1024) { setErrors((e) => ({ ...e, photo: "Arquivo muito grande. Máx 5MB." })); return; } setField("photo", f); const fr = new FileReader(); fr.onload = (ev) => setPhotoPreview(String(ev.target?.result || "")); fr.readAsDataURL(f); }
|
||||||
const f = e.target.files?.[0];
|
|
||||||
if (!f) return;
|
|
||||||
if (f.size > 5 * 1024 * 1024) {
|
|
||||||
setErrors((e) => ({ ...e, photo: "Arquivo muito grande. Máx 5MB." }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setField("photo", f);
|
|
||||||
const fr = new FileReader();
|
|
||||||
fr.onload = (ev) => setPhotoPreview(String(ev.target?.result || ""));
|
|
||||||
fr.readAsDataURL(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLocalAnexos(e: React.ChangeEvent<HTMLInputElement>) {
|
function addLocalAnexos(e: React.ChangeEvent<HTMLInputElement>) { const fs = Array.from(e.target.files || []); setField("anexos", [...form.anexos, ...fs]); }
|
||||||
const fs = Array.from(e.target.files || []);
|
function removeLocalAnexo(idx: number) { const clone = [...form.anexos]; clone.splice(idx, 1); setField("anexos", clone); }
|
||||||
setField("anexos", [...form.anexos, ...fs]);
|
|
||||||
}
|
|
||||||
function removeLocalAnexo(idx: number) {
|
|
||||||
const clone = [...form.anexos];
|
|
||||||
clone.splice(idx, 1);
|
|
||||||
setField("anexos", clone);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoverFotoServidor() {
|
async function handleRemoverFotoServidor() { if (mode !== "edit" || !patientId) return; try { setUploadingPhoto(true); await removerFotoPaciente(String(patientId)); setPhotoPreview(null); alert('Foto removida com sucesso.'); } catch (e: any) { console.warn('[PatientForm] erro ao remover foto do servidor', e); if (String(e?.message || '').includes('401')) { alert('Falha ao remover a foto: não autenticado. Faça login novamente e tente novamente.\nDetalhe: ' + (e?.message || '')); } else if (String(e?.message || '').includes('403')) { alert('Falha ao remover a foto: sem permissão. Verifique as permissões do token e se o storage aceita esse usuário.\nDetalhe: ' + (e?.message || '')); } else { alert(e?.message || 'Não foi possível remover a foto do storage. Veja console para detalhes.'); } } finally { setUploadingPhoto(false); } }
|
||||||
if (mode !== "edit" || !patientId) return;
|
|
||||||
try {
|
|
||||||
setUploadingPhoto(true);
|
|
||||||
await removerFotoPaciente(String(patientId));
|
|
||||||
// clear preview and inform user
|
|
||||||
setPhotoPreview(null);
|
|
||||||
alert('Foto removida com sucesso.');
|
|
||||||
} catch (e: any) {
|
|
||||||
console.warn('[PatientForm] erro ao remover foto do servidor', e);
|
|
||||||
// Show detailed guidance for common cases
|
|
||||||
if (String(e?.message || '').includes('401')) {
|
|
||||||
alert('Falha ao remover a foto: não autenticado. Faça login novamente e tente novamente.\nDetalhe: ' + (e?.message || ''));
|
|
||||||
} else if (String(e?.message || '').includes('403')) {
|
|
||||||
alert('Falha ao remover a foto: sem permissão. Verifique as permissões do token e se o storage aceita esse usuário.\nDetalhe: ' + (e?.message || ''));
|
|
||||||
} else {
|
|
||||||
alert(e?.message || 'Não foi possível remover a foto do storage. Veja console para detalhes.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setUploadingPhoto(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoverAnexoServidor(anexoId: string | number) {
|
async function handleRemoverAnexoServidor(anexoId: string | number) { if (mode !== "edit" || !patientId) return; try { await removerAnexo(String(patientId), anexoId); setServerAnexos((prev) => prev.filter((a) => String(a.id ?? a.anexo_id) !== String(anexoId))); } catch (e: any) { alert(e?.message || "Não foi possível remover o anexo."); } }
|
||||||
if (mode !== "edit" || !patientId) return;
|
|
||||||
try {
|
|
||||||
await removerAnexo(String(patientId), anexoId);
|
|
||||||
setServerAnexos((prev) => prev.filter((a) => String(a.id ?? a.anexo_id) !== String(anexoId)));
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e?.message || "Não foi possível remover o anexo.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{errors.submit && (
|
{errors.submit && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive"><AlertCircle className="h-4 w-4" /><AlertDescription>{errors.submit}</AlertDescription></Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{errors.submit}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Personal data, contact, address, attachments... keep markup concise */}
|
||||||
<Collapsible open={expanded.dados} onOpenChange={() => setExpanded((s) => ({ ...s, dados: !s.dados }))}>
|
<Collapsible open={expanded.dados} onOpenChange={() => setExpanded((s) => ({ ...s, dados: !s.dados }))}>
|
||||||
<Card>
|
<Card>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
|
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between"><span className="flex items-center gap-2"><User className="h-4 w-4" /> Dados Pessoais</span>{expanded.dados ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle>
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
Dados Pessoais
|
|
||||||
</span>
|
|
||||||
{expanded.dados ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-24 h-24 border-2 border-dashed border-muted-foreground rounded-lg flex items-center justify-center overflow-hidden">
|
<div className="w-24 h-24 border-2 border-dashed border-muted-foreground rounded-lg flex items-center justify-center overflow-hidden">
|
||||||
{photoPreview ? (
|
{photoPreview ? <img src={photoPreview} alt="Preview" className="w-full h-full object-cover" /> : <FileImage className="h-8 w-8 text-muted-foreground" />}
|
||||||
<img src={photoPreview} alt="Preview" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<FileImage className="h-8 w-8 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="photo" className="cursor-pointer rounded-md transition-colors">
|
<Label htmlFor="photo" className="cursor-pointer rounded-md transition-colors">
|
||||||
<Button type="button" variant="ghost" asChild className="bg-primary text-primary-foreground border-transparent hover:bg-primary">
|
<Button type="button" variant="ghost" asChild className="bg-primary text-primary-foreground border-transparent hover:bg-primary"><span><Upload className="mr-2 h-4 w-4 text-primary-foreground" /> Carregar Foto</span></Button>
|
||||||
<span>
|
|
||||||
<Upload className="mr-2 h-4 w-4 text-primary-foreground" /> Carregar Foto
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="photo" type="file" accept="image/*" className="hidden" onChange={handlePhoto} />
|
<Input id="photo" type="file" accept="image/*" className="hidden" onChange={handlePhoto} />
|
||||||
{mode === "edit" && (
|
{mode === "edit" && (<Button type="button" variant="ghost" onClick={handleRemoverFotoServidor}><Trash2 className="mr-2 h-4 w-4" /> Remover foto</Button>)}
|
||||||
<Button type="button" variant="ghost" onClick={handleRemoverFotoServidor}>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" /> Remover foto
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{errors.photo && <p className="text-sm text-destructive">{errors.photo}</p>}
|
{errors.photo && <p className="text-sm text-destructive">{errors.photo}</p>}
|
||||||
<p className="text-xs text-muted-foreground">Máximo 5MB</p>
|
<p className="text-xs text-muted-foreground">Máximo 5MB</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2"><Label>Nome *</Label><Input value={form.nome} onChange={(e) => setField("nome", e.target.value)} className={errors.nome ? "border-destructive" : ""} />{errors.nome && <p className="text-sm text-destructive">{errors.nome}</p>}</div>
|
||||||
<Label>Nome *</Label>
|
<div className="space-y-2"><Label>Nome Social</Label><Input value={form.nome_social} onChange={(e) => setField("nome_social", e.target.value)} /></div>
|
||||||
<Input value={form.nome} onChange={(e) => setField("nome", e.target.value)} className={errors.nome ? "border-destructive" : ""} />
|
|
||||||
{errors.nome && <p className="text-sm text-destructive">{errors.nome}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Nome Social</Label>
|
|
||||||
<Input value={form.nome_social} onChange={(e) => setField("nome_social", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2"><Label>CPF *</Label><Input value={form.cpf} onChange={(e) => handleCPFChange(e.target.value)} placeholder="000.000.000-00" maxLength={14} className={errors.cpf ? "border-destructive" : ""} />{errors.cpf && <p className="text-sm text-destructive">{errors.cpf}</p>}</div>
|
||||||
<Label>CPF *</Label>
|
<div className="space-y-2"><Label>RG</Label><Input value={form.rg} onChange={(e) => setField("rg", e.target.value)} /></div>
|
||||||
<Input
|
|
||||||
value={form.cpf}
|
|
||||||
onChange={(e) => handleCPFChange(e.target.value)}
|
|
||||||
placeholder="000.000.000-00"
|
|
||||||
maxLength={14}
|
|
||||||
className={errors.cpf ? "border-destructive" : ""}
|
|
||||||
/>
|
|
||||||
{errors.cpf && <p className="text-sm text-destructive">{errors.cpf}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>RG</Label>
|
|
||||||
<Input value={form.rg} onChange={(e) => setField("rg", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2"><Label>Sexo</Label>
|
||||||
<Label>Sexo</Label>
|
|
||||||
<Select value={form.sexo} onValueChange={(v) => setField("sexo", v)}>
|
<Select value={form.sexo} onValueChange={(v) => setField("sexo", v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Selecione o sexo" /></SelectTrigger>
|
||||||
<SelectValue placeholder="Selecione o sexo" />
|
<SelectContent><SelectItem value="masculino">Masculino</SelectItem><SelectItem value="feminino">Feminino</SelectItem><SelectItem value="outro">Outro</SelectItem></SelectContent>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="masculino">Masculino</SelectItem>
|
|
||||||
<SelectItem value="feminino">Feminino</SelectItem>
|
|
||||||
<SelectItem value="outro">Outro</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2"><Label>Data de Nascimento</Label><Input placeholder="dd/mm/aaaa" value={form.birth_date} onChange={(e) => { const v = e.target.value.replace(/[^0-9\/]*/g, "").slice(0, 10); setField("birth_date", v); }} onBlur={() => { const raw = form.birth_date; const parts = raw.split(/\D+/).filter(Boolean); if (parts.length === 3) { const d = `${parts[0].padStart(2,'0')}/${parts[1].padStart(2,'0')}/${parts[2].padStart(4,'0')}`; setField("birth_date", d); } }} /></div>
|
||||||
<Label>Data de Nascimento</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="dd/mm/aaaa"
|
|
||||||
value={form.birth_date}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value.replace(/[^0-9\/]/g, "").slice(0, 10);
|
|
||||||
setField("birth_date", v);
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
const raw = form.birth_date;
|
|
||||||
const parts = raw.split(/\D+/).filter(Boolean);
|
|
||||||
if (parts.length === 3) {
|
|
||||||
const d = `${parts[0].padStart(2,'0')}/${parts[1].padStart(2,'0')}/${parts[2].padStart(4,'0')}`;
|
|
||||||
setField("birth_date", d);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@ -629,25 +329,13 @@ export function PatientRegistrationForm({
|
|||||||
<Collapsible open={expanded.contato} onOpenChange={() => setExpanded((s) => ({ ...s, contato: !s.contato }))}>
|
<Collapsible open={expanded.contato} onOpenChange={() => setExpanded((s) => ({ ...s, contato: !s.contato }))}>
|
||||||
<Card>
|
<Card>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
|
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Contato</span>{expanded.contato ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<span>Contato</span>
|
|
||||||
{expanded.contato ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2"><Label>E-mail</Label><Input value={form.email} onChange={(e) => setField("email", e.target.value)} />{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}</div>
|
||||||
<Label>E-mail</Label>
|
<div className="space-y-2"><Label>Telefone</Label><Input value={form.telefone} onChange={(e) => setField("telefone", e.target.value)} />{errors.telefone && <p className="text-sm text-destructive">{errors.telefone}</p>}</div>
|
||||||
<Input value={form.email} onChange={(e) => setField("email", e.target.value)} />
|
|
||||||
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Telefone</Label>
|
|
||||||
<Input value={form.telefone} onChange={(e) => setField("telefone", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@ -657,66 +345,19 @@ export function PatientRegistrationForm({
|
|||||||
<Collapsible open={expanded.endereco} onOpenChange={() => setExpanded((s) => ({ ...s, endereco: !s.endereco }))}>
|
<Collapsible open={expanded.endereco} onOpenChange={() => setExpanded((s) => ({ ...s, endereco: !s.endereco }))}>
|
||||||
<Card>
|
<Card>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
|
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Endereço</span>{expanded.endereco ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<span>Endereço</span>
|
|
||||||
{expanded.endereco ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2"><Label>CEP</Label><div className="relative"><Input value={form.cep} onChange={(e) => { const v = formatCEP(e.target.value); setField("cep", v); if (v.replace(/\D/g, "").length === 8) fillFromCEP(v); }} placeholder="00000-000" maxLength={9} disabled={isSearchingCEP} className={errors.cep ? "border-destructive" : ""} />{isSearchingCEP && <Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin" />}</div>{errors.cep && <p className="text-sm text-destructive">{errors.cep}</p>}</div>
|
||||||
<Label>CEP</Label>
|
<div className="space-y-2"><Label>Logradouro</Label><Input value={form.logradouro} onChange={(e) => setField("logradouro", e.target.value)} /></div>
|
||||||
<div className="relative">
|
<div className="space-y-2"><Label>Número</Label><Input value={form.numero} onChange={(e) => setField("numero", e.target.value)} /></div>
|
||||||
<Input
|
|
||||||
value={form.cep}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = formatCEP(e.target.value);
|
|
||||||
setField("cep", v);
|
|
||||||
if (v.replace(/\D/g, "").length === 8) fillFromCEP(v);
|
|
||||||
}}
|
|
||||||
placeholder="00000-000"
|
|
||||||
maxLength={9}
|
|
||||||
disabled={isSearchingCEP}
|
|
||||||
className={errors.cep ? "border-destructive" : ""}
|
|
||||||
/>
|
|
||||||
{isSearchingCEP && <Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin" />}
|
|
||||||
</div>
|
|
||||||
{errors.cep && <p className="text-sm text-destructive">{errors.cep}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Logradouro</Label>
|
|
||||||
<Input value={form.logradouro} onChange={(e) => setField("logradouro", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Número</Label>
|
|
||||||
<Input value={form.numero} onChange={(e) => setField("numero", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4"><div className="space-y-2"><Label>Complemento</Label><Input value={form.complemento} onChange={(e) => setField("complemento", e.target.value)} /></div><div className="space-y-2"><Label>Bairro</Label><Input value={form.bairro} onChange={(e) => setField("bairro", e.target.value)} /></div></div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Complemento</Label>
|
|
||||||
<Input value={form.complemento} onChange={(e) => setField("complemento", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Bairro</Label>
|
|
||||||
<Input value={form.bairro} onChange={(e) => setField("bairro", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4"><div className="space-y-2"><Label>Cidade</Label><Input value={form.cidade} onChange={(e) => setField("cidade", e.target.value)} /></div><div className="space-y-2"><Label>Estado</Label><Input value={form.estado} onChange={(e) => setField("estado", e.target.value)} placeholder="UF" /></div></div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Cidade</Label>
|
|
||||||
<Input value={form.cidade} onChange={(e) => setField("cidade", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Estado</Label>
|
|
||||||
<Input value={form.estado} onChange={(e) => setField("estado", e.target.value)} placeholder="UF" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -725,19 +366,11 @@ export function PatientRegistrationForm({
|
|||||||
<Collapsible open={expanded.obs} onOpenChange={() => setExpanded((s) => ({ ...s, obs: !s.obs }))}>
|
<Collapsible open={expanded.obs} onOpenChange={() => setExpanded((s) => ({ ...s, obs: !s.obs }))}>
|
||||||
<Card>
|
<Card>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
|
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Observações e Anexos</span>{expanded.obs ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<span>Observações e Anexos</span>
|
|
||||||
{expanded.obs ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2"><Label>Observações</Label><Textarea rows={4} value={form.observacoes} onChange={(e) => setField("observacoes", e.target.value)} /></div>
|
||||||
<Label>Observações</Label>
|
|
||||||
<Textarea rows={4} value={form.observacoes} onChange={(e) => setField("observacoes", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Adicionar anexos</Label>
|
<Label>Adicionar anexos</Label>
|
||||||
@ -763,106 +396,43 @@ export function PatientRegistrationForm({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "edit" && serverAnexos.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Anexos já enviados</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{serverAnexos.map((ax) => {
|
||||||
|
const id = ax.id ?? ax.anexo_id ?? ax.uuid ?? "";
|
||||||
|
return (
|
||||||
|
<div key={String(id)} className="flex items-center justify-between p-2 border rounded">
|
||||||
|
<span className="text-sm">{ax.nome || ax.filename || `Anexo ${id}`}</span>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => handleRemoverAnexoServidor(String(id))}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "edit" && serverAnexos.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Anexos já enviados</Label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{serverAnexos.map((ax) => {
|
|
||||||
const id = ax.id ?? ax.anexo_id ?? ax.uuid ?? "";
|
|
||||||
return (
|
|
||||||
<div key={String(id)} className="flex items-center justify-between p-2 border rounded">
|
|
||||||
<span className="text-sm">{ax.nome || ax.filename || `Anexo ${id}`}</span>
|
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => handleRemoverAnexoServidor(String(id))}>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4 pt-6 border-t">
|
<div className="flex justify-end gap-4 pt-6 border-t">
|
||||||
<Button type="button" variant="outline" onClick={() => (inline ? onClose?.() : onOpenChange?.(false))} disabled={isSubmitting}>
|
<Button type="button" variant="outline" onClick={() => (inline ? onClose?.() : onOpenChange?.(false))} disabled={isSubmitting}><XCircle className="mr-2 h-4 w-4" /> Cancelar</Button>
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
<Button type="submit" disabled={isSubmitting}>{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}{isSubmitting ? "Salvando..." : mode === "create" ? "Salvar Paciente" : "Atualizar Paciente"}</Button>
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
|
||||||
{isSubmitting ? "Salvando..." : mode === "create" ? "Salvar Paciente" : "Atualizar Paciente"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (inline) {
|
if (inline) {
|
||||||
return (
|
return (<><div className="space-y-6">{content}</div>{credentials && (<CredentialsDialog open={showCredentialsDialog} onOpenChange={(open) => { setShowCredentialsDialog(open); if (!open) { setCredentials(null); if (inline) onClose?.(); else onOpenChange?.(false); } }} email={credentials.email} password={credentials.password} userName={credentials.userName} userType={credentials.userType} />)}</>);
|
||||||
<>
|
|
||||||
<div className="space-y-6">{content}</div>
|
|
||||||
|
|
||||||
{/* Dialog de credenciais */}
|
|
||||||
{credentials && (
|
|
||||||
<CredentialsDialog
|
|
||||||
open={showCredentialsDialog}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setShowCredentialsDialog(open);
|
|
||||||
if (!open) {
|
|
||||||
// Quando o dialog de credenciais fecha, fecha o formulário também
|
|
||||||
setCredentials(null);
|
|
||||||
if (inline) {
|
|
||||||
onClose?.();
|
|
||||||
} else {
|
|
||||||
onOpenChange?.(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
email={credentials.email}
|
|
||||||
password={credentials.password}
|
|
||||||
userName={credentials.userName}
|
|
||||||
userType={credentials.userType}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (<><Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto"><DialogHeader><DialogTitle className="flex items-center gap-2"><User className="h-5 w-5" /> {title}</DialogTitle></DialogHeader>{content}</DialogContent></Dialog>{credentials && (<CredentialsDialog open={showCredentialsDialog} onOpenChange={(open) => { setShowCredentialsDialog(open); if (!open) { setCredentials(null); onOpenChange?.(false); } }} email={credentials.email} password={credentials.password} userName={credentials.userName} userType={credentials.userType} />)}</>);
|
||||||
<>
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<User className="h-5 w-5" /> {title}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{content}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Dialog de credenciais */}
|
|
||||||
{credentials && (
|
|
||||||
<CredentialsDialog
|
|
||||||
open={showCredentialsDialog}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setShowCredentialsDialog(open);
|
|
||||||
if (!open) {
|
|
||||||
setCredentials(null);
|
|
||||||
onOpenChange?.(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
email={credentials.email}
|
|
||||||
password={credentials.password}
|
|
||||||
userName={credentials.userName}
|
|
||||||
userType={credentials.userType}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@ -235,43 +235,83 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const payload: any = {
|
// Normalize weekday to integer expected by the OpenAPI (0=Sunday .. 6=Saturday)
|
||||||
|
const mapWeekdayToInt = (w?: string | number): number | null => {
|
||||||
|
if (w === null || typeof w === 'undefined') return null;
|
||||||
|
if (typeof w === 'number') return Number(w);
|
||||||
|
const s = String(w).toLowerCase().trim();
|
||||||
|
const map: Record<string, number> = {
|
||||||
|
'domingo': 0, 'domingo.': 0, 'sunday': 0,
|
||||||
|
'segunda': 1, 'segunda-feira': 1, 'monday': 1,
|
||||||
|
'terca': 2, 'terça': 2, 'terca-feira': 2, 'tuesday': 2,
|
||||||
|
'quarta': 3, 'quarta-feira': 3, 'wednesday': 3,
|
||||||
|
'quinta': 4, 'quinta-feira': 4, 'thursday': 4,
|
||||||
|
'sexta': 5, 'sexta-feira': 5, 'friday': 5,
|
||||||
|
'sabado': 6, 'sábado': 6, 'saturday': 6,
|
||||||
|
} as any;
|
||||||
|
// numeric strings
|
||||||
|
if (/^\d+$/.test(s)) return Number(s);
|
||||||
|
return map[s] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const weekdayInt = mapWeekdayToInt(input.weekday as any);
|
||||||
|
if (weekdayInt === null || weekdayInt < 0 || weekdayInt > 6) {
|
||||||
|
throw new Error('weekday inválido. Use 0=Domingo .. 6=Sábado ou o nome do dia.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, attempt server-side Edge Function to perform the insert with service privileges.
|
||||||
|
// This avoids RLS blocking client inserts. The function will also validate doctor existence.
|
||||||
|
const fnUrl = `${API_BASE}/functions/v1/create-availability`;
|
||||||
|
const fnPayload: any = {
|
||||||
|
doctor_id: input.doctor_id,
|
||||||
|
weekday: weekdayInt,
|
||||||
|
start_time: input.start_time,
|
||||||
|
end_time: input.end_time,
|
||||||
slot_minutes: input.slot_minutes ?? 30,
|
slot_minutes: input.slot_minutes ?? 30,
|
||||||
appointment_type: input.appointment_type ?? 'presencial',
|
appointment_type: input.appointment_type ?? 'presencial',
|
||||||
active: typeof input.active === 'undefined' ? true : input.active,
|
active: typeof input.active === 'undefined' ? true : input.active,
|
||||||
doctor_id: input.doctor_id,
|
created_by: createdBy,
|
||||||
weekday: mapWeekdayForServer(input.weekday),
|
|
||||||
start_time: input.start_time,
|
|
||||||
end_time: input.end_time,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = `${REST}/doctor_availability`;
|
try {
|
||||||
// Try several payload permutations to tolerate different server enum/time formats.
|
const fnRes = await fetch(fnUrl, {
|
||||||
const attempts = [] as Array<{ weekdayVal: string | undefined; withSeconds: boolean }>;
|
method: 'POST',
|
||||||
const mappedWeekday = mapWeekdayForServer(input.weekday);
|
headers: { ...baseHeaders(), 'Content-Type': 'application/json' },
|
||||||
const originalWeekday = input.weekday;
|
body: JSON.stringify(fnPayload),
|
||||||
attempts.push({ weekdayVal: mappedWeekday, withSeconds: true });
|
});
|
||||||
attempts.push({ weekdayVal: originalWeekday, withSeconds: true });
|
if (fnRes.ok) {
|
||||||
attempts.push({ weekdayVal: mappedWeekday, withSeconds: false });
|
const created = await parse<DoctorAvailability | DoctorAvailability[]>(fnRes as Response);
|
||||||
attempts.push({ weekdayVal: originalWeekday, withSeconds: false });
|
return Array.isArray(created) ? created[0] : (created as DoctorAvailability);
|
||||||
|
}
|
||||||
|
// If function exists but returned 4xx/5xx, let parse() surface friendly error
|
||||||
|
if (fnRes.status >= 400 && fnRes.status < 600) {
|
||||||
|
return await parse<DoctorAvailability>(fnRes);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[criarDisponibilidade] create-availability function unavailable or errored, falling back to REST:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try direct REST insert using integer weekday (OpenAPI expects integer).
|
||||||
|
const restUrl = `${REST}/doctor_availability`;
|
||||||
|
// Try with/without seconds for times to tolerate different server formats.
|
||||||
|
const attempts = [true, false];
|
||||||
let lastRes: Response | null = null;
|
let lastRes: Response | null = null;
|
||||||
for (const at of attempts) {
|
for (const withSeconds of attempts) {
|
||||||
const start = at.withSeconds ? input.start_time : String(input.start_time).replace(/:00$/,'');
|
const start = withSeconds ? input.start_time : String(input.start_time).replace(/:00$/,'');
|
||||||
const end = at.withSeconds ? input.end_time : String(input.end_time).replace(/:00$/,'');
|
const end = withSeconds ? input.end_time : String(input.end_time).replace(/:00$/,'');
|
||||||
const tryPayload: any = {
|
const tryPayload: any = {
|
||||||
|
doctor_id: input.doctor_id,
|
||||||
|
weekday: weekdayInt,
|
||||||
|
start_time: start,
|
||||||
|
end_time: end,
|
||||||
slot_minutes: input.slot_minutes ?? 30,
|
slot_minutes: input.slot_minutes ?? 30,
|
||||||
appointment_type: input.appointment_type ?? 'presencial',
|
appointment_type: input.appointment_type ?? 'presencial',
|
||||||
active: typeof input.active === 'undefined' ? true : input.active,
|
active: typeof input.active === 'undefined' ? true : input.active,
|
||||||
doctor_id: input.doctor_id,
|
|
||||||
weekday: at.weekdayVal,
|
|
||||||
start_time: start,
|
|
||||||
end_time: end,
|
|
||||||
created_by: createdBy,
|
created_by: createdBy,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(restUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'),
|
headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'),
|
||||||
body: JSON.stringify(tryPayload),
|
body: JSON.stringify(tryPayload),
|
||||||
@ -281,27 +321,56 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro
|
|||||||
const arr = await parse<DoctorAvailability[] | DoctorAvailability>(res);
|
const arr = await parse<DoctorAvailability[] | DoctorAvailability>(res);
|
||||||
return Array.isArray(arr) ? arr[0] : (arr as DoctorAvailability);
|
return Array.isArray(arr) ? arr[0] : (arr as DoctorAvailability);
|
||||||
}
|
}
|
||||||
|
if (res.status >= 500) return await parse<DoctorAvailability>(res);
|
||||||
|
|
||||||
// If server returned a 4xx, try next permutation; for 5xx, bail out and throw the error from parse()
|
|
||||||
if (res.status >= 500) {
|
|
||||||
// Let parse produce the error with friendly messaging
|
|
||||||
return await parse<DoctorAvailability>(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log a warning and continue to next attempt
|
|
||||||
const raw = await res.clone().text().catch(() => '');
|
const raw = await res.clone().text().catch(() => '');
|
||||||
console.warn('[criarDisponibilidade] tentativa falhou', { status: res.status, weekday: at.weekdayVal, withSeconds: at.withSeconds, raw });
|
console.warn('[criarDisponibilidade] REST attempt failed', { status: res.status, withSeconds, raw });
|
||||||
// continue to next attempt
|
// If 22P02 (invalid enum) occurs, we will try a name-based fallback below
|
||||||
|
if (res.status === 422 || (raw && raw.toString().includes('invalid input value for enum'))) {
|
||||||
|
// fall through to name-based fallback
|
||||||
|
break;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[criarDisponibilidade] fetch erro na tentativa', e);
|
console.warn('[criarDisponibilidade] REST fetch erro', e);
|
||||||
// continue to next attempt
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All attempts failed — throw using the last response to get friendly message from parse()
|
// As a last resort: try sending weekday as English name (e.g. 'monday') for older schemas
|
||||||
if (lastRes) {
|
const engMap = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];
|
||||||
return await parse<DoctorAvailability>(lastRes);
|
const mappedName = engMap[weekdayInt];
|
||||||
|
for (const withSeconds of attempts) {
|
||||||
|
const start = withSeconds ? input.start_time : String(input.start_time).replace(/:00$/,'');
|
||||||
|
const end = withSeconds ? input.end_time : String(input.end_time).replace(/:00$/,'');
|
||||||
|
const tryPayload: any = {
|
||||||
|
doctor_id: input.doctor_id,
|
||||||
|
weekday: mappedName,
|
||||||
|
start_time: start,
|
||||||
|
end_time: end,
|
||||||
|
slot_minutes: input.slot_minutes ?? 30,
|
||||||
|
appointment_type: input.appointment_type ?? 'presencial',
|
||||||
|
active: typeof input.active === 'undefined' ? true : input.active,
|
||||||
|
created_by: createdBy,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch(restUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'),
|
||||||
|
body: JSON.stringify(tryPayload),
|
||||||
|
});
|
||||||
|
lastRes = res;
|
||||||
|
if (res.ok) {
|
||||||
|
const arr = await parse<DoctorAvailability[] | DoctorAvailability>(res);
|
||||||
|
return Array.isArray(arr) ? arr[0] : (arr as DoctorAvailability);
|
||||||
|
}
|
||||||
|
if (res.status >= 500) return await parse<DoctorAvailability>(res);
|
||||||
|
const raw = await res.clone().text().catch(() => '');
|
||||||
|
console.warn('[criarDisponibilidade] REST name-based attempt failed', { status: res.status, withSeconds, raw });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[criarDisponibilidade] REST name-based fetch erro', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lastRes) return await parse<DoctorAvailability>(lastRes);
|
||||||
throw new Error('Falha ao criar disponibilidade: nenhuma resposta do servidor.');
|
throw new Error('Falha ao criar disponibilidade: nenhuma resposta do servidor.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1298,14 +1367,53 @@ export async function buscarPacientesPorMedico(doctorId: string): Promise<Pacien
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function criarPaciente(input: PacienteInput): Promise<Paciente> {
|
export async function criarPaciente(input: PacienteInput): Promise<Paciente> {
|
||||||
const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/patients`;
|
// Este helper agora chama exclusivamente a Edge Function /functions/v1/create-patient
|
||||||
|
// A função server-side é responsável por criar o usuário no Auth, o profile e o registro em patients
|
||||||
|
if (!input) throw new Error('Dados do paciente não informados');
|
||||||
|
|
||||||
|
// Validar campos obrigatórios conforme OpenAPI do create-patient
|
||||||
|
const required = ['full_name', 'email', 'cpf', 'phone_mobile'];
|
||||||
|
for (const r of required) {
|
||||||
|
const val = (input as any)[r];
|
||||||
|
if (!val || (typeof val === 'string' && String(val).trim() === '')) {
|
||||||
|
throw new Error(`Campo obrigatório ausente: ${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizar e validar CPF (11 dígitos)
|
||||||
|
const cleanCpf = String(input.cpf || '').replace(/\D/g, '');
|
||||||
|
if (!/^\d{11}$/.test(cleanCpf)) {
|
||||||
|
throw new Error('CPF inválido. Deve conter 11 dígitos numéricos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
full_name: input.full_name,
|
||||||
|
email: input.email,
|
||||||
|
cpf: cleanCpf,
|
||||||
|
phone_mobile: input.phone_mobile,
|
||||||
|
};
|
||||||
|
// Copiar demais campos opcionais quando presentes
|
||||||
|
if (input.cep) payload.cep = input.cep;
|
||||||
|
if (input.street) payload.street = input.street;
|
||||||
|
if (input.number) payload.number = input.number;
|
||||||
|
if (input.complement) payload.complement = input.complement;
|
||||||
|
if (input.neighborhood) payload.neighborhood = input.neighborhood;
|
||||||
|
if (input.city) payload.city = input.city;
|
||||||
|
if (input.state) payload.state = input.state;
|
||||||
|
if (input.birth_date) payload.birth_date = input.birth_date;
|
||||||
|
if (input.rg) payload.rg = input.rg;
|
||||||
|
if (input.social_name) payload.social_name = input.social_name;
|
||||||
|
if (input.notes) payload.notes = input.notes;
|
||||||
|
|
||||||
|
const url = `${API_BASE}/functions/v1/create-patient`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
|
headers: { ...baseHeaders(), 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const arr = await parse<Paciente[] | Paciente>(res);
|
|
||||||
return Array.isArray(arr) ? arr[0] : (arr as Paciente);
|
// Deixar parse() lidar com erros/erros de validação retornados pela função
|
||||||
|
return await parse<Paciente>(res as Response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function atualizarPaciente(id: string | number, input: PacienteInput): Promise<Paciente> {
|
export async function atualizarPaciente(id: string | number, input: PacienteInput): Promise<Paciente> {
|
||||||
@ -1650,17 +1758,105 @@ export async function listarProfissionais(params?: { page?: number; limit?: numb
|
|||||||
|
|
||||||
// Dentro de lib/api.ts
|
// Dentro de lib/api.ts
|
||||||
export async function criarMedico(input: MedicoInput): Promise<Medico> {
|
export async function criarMedico(input: MedicoInput): Promise<Medico> {
|
||||||
console.log("Enviando os dados para a API:", input); // Log para depuração
|
// Mirror criarPaciente: validate input, normalize fields and call the server-side
|
||||||
|
// create-doctor Edge Function. Normalize possible envelope responses so callers
|
||||||
const url = `${REST}/doctors`; // Endpoint de médicos
|
// always receive a `Medico` object when possible.
|
||||||
|
if (!input) throw new Error('Dados do médico não informados');
|
||||||
|
const required = ['email', 'full_name', 'cpf', 'crm', 'crm_uf'];
|
||||||
|
for (const r of required) {
|
||||||
|
const val = (input as any)[r];
|
||||||
|
if (!val || (typeof val === 'string' && String(val).trim() === '')) {
|
||||||
|
throw new Error(`Campo obrigatório ausente: ${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize and validate CPF
|
||||||
|
const cleanCpf = String(input.cpf || '').replace(/\D/g, '');
|
||||||
|
if (!/^\d{11}$/.test(cleanCpf)) {
|
||||||
|
throw new Error('CPF inválido. Deve conter 11 dígitos numéricos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize CRM UF
|
||||||
|
const crmUf = String(input.crm_uf || '').toUpperCase();
|
||||||
|
if (!/^[A-Z]{2}$/.test(crmUf)) {
|
||||||
|
throw new Error('CRM UF inválido. Deve conter 2 letras maiúsculas (ex: SP, RJ).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
email: input.email,
|
||||||
|
full_name: input.full_name,
|
||||||
|
cpf: cleanCpf,
|
||||||
|
crm: input.crm,
|
||||||
|
crm_uf: crmUf,
|
||||||
|
};
|
||||||
|
// Incluir flag para instruir a Edge Function a NÃO criar o usuário no Auth
|
||||||
|
// (em alguns deployments a função cria o usuário por padrão; se quisermos
|
||||||
|
// apenas criar o registro do médico, enviar create_user: false)
|
||||||
|
payload.create_user = false;
|
||||||
|
if (input.specialty) payload.specialty = input.specialty;
|
||||||
|
if (input.phone_mobile) payload.phone_mobile = input.phone_mobile;
|
||||||
|
if (typeof input.phone2 !== 'undefined') payload.phone2 = input.phone2;
|
||||||
|
|
||||||
|
const url = `${API_BASE}/functions/v1/create-doctor`;
|
||||||
|
// Debug: build headers separately so we can log them (masked) and the body
|
||||||
|
const headers = { ...baseHeaders(), 'Content-Type': 'application/json' } as Record<string, string>;
|
||||||
|
const maskedHeaders = { ...headers } as Record<string, string>;
|
||||||
|
if (maskedHeaders.Authorization) {
|
||||||
|
const a = maskedHeaders.Authorization as string;
|
||||||
|
maskedHeaders.Authorization = `${a.slice(0,6)}...${a.slice(-6)}`;
|
||||||
|
}
|
||||||
|
console.debug('[DEBUG criarMedico] POST', url, 'headers(masked):', maskedHeaders, 'body:', JSON.stringify(payload));
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: withPrefer({ ...baseHeaders(), "Content-Type": "application/json" }, "return=representation"),
|
headers,
|
||||||
body: JSON.stringify(input), // Enviando os dados padronizados
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
const arr = await parse<Medico[] | Medico>(res); // Resposta da API
|
let parsed: any = null;
|
||||||
return Array.isArray(arr) ? arr[0] : (arr as Medico); // Retorno do médico
|
try {
|
||||||
|
parsed = await parse<any>(res as Response);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = String(err?.message || '').toLowerCase();
|
||||||
|
// Workaround: se a função reclamou que o email já existe, tentar obter o médico
|
||||||
|
// já cadastrado pelo email e retornar esse perfil em vez de falhar.
|
||||||
|
if (msg.includes('already been registered') || msg.includes('já está cadastrado') || msg.includes('already registered')) {
|
||||||
|
try {
|
||||||
|
const checkUrl = `${REST}/doctors?email=eq.${encodeURIComponent(String(input.email || ''))}&select=*`;
|
||||||
|
const checkRes = await fetch(checkUrl, { method: 'GET', headers: baseHeaders() });
|
||||||
|
if (checkRes.ok) {
|
||||||
|
const arr = await parse<Medico[]>(checkRes);
|
||||||
|
if (Array.isArray(arr) && arr.length > 0) return arr[0];
|
||||||
|
}
|
||||||
|
} catch (inner) {
|
||||||
|
// ignore and rethrow original error below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// rethrow original error if fallback didn't resolve
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!parsed) throw new Error('Resposta vazia ao criar médico');
|
||||||
|
|
||||||
|
// If the function returns an envelope like { doctor: { ... }, doctor_id: '...' }
|
||||||
|
if (parsed.doctor && typeof parsed.doctor === 'object') return parsed.doctor as Medico;
|
||||||
|
|
||||||
|
// If it returns only a doctor_id, try to fetch full profile
|
||||||
|
if (parsed.doctor_id) {
|
||||||
|
try {
|
||||||
|
const d = await buscarMedicoPorId(String(parsed.doctor_id));
|
||||||
|
if (!d) throw new Error('Médico não encontrado após criação');
|
||||||
|
return d;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Médico criado mas não foi possível recuperar os dados do perfil.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the function returned the doctor object directly
|
||||||
|
if (parsed.id || parsed.full_name || parsed.cpf) {
|
||||||
|
return parsed as Medico;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Formato de resposta inesperado ao criar médico');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1946,10 +2142,10 @@ export function gerarSenhaAleatoria(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function criarUsuario(input: CreateUserInput): Promise<CreateUserResponse> {
|
export async function criarUsuario(input: CreateUserInput): Promise<CreateUserResponse> {
|
||||||
// Prefer calling the Functions path first in environments where /create-user
|
// Prefer calling the Functions path at the explicit project URL provided
|
||||||
// is not mapped at the API root (this avoids expected 404 noise). Keep the
|
// by the environment / team. Keep the API_BASE-root fallback for other deployments.
|
||||||
// root /create-user as a fallback for deployments that expose it.
|
const explicitFunctionsUrl = 'https://yuanqfswhberkoevtmfr.supabase.co/functions/v1/create-user';
|
||||||
const functionsUrl = `${API_BASE}/functions/v1/create-user`;
|
const functionsUrl = explicitFunctionsUrl;
|
||||||
const url = `${API_BASE}/create-user`;
|
const url = `${API_BASE}/create-user`;
|
||||||
|
|
||||||
let res: Response | null = null;
|
let res: Response | null = null;
|
||||||
@ -2103,16 +2299,44 @@ export async function criarUsuarioMedico(medico: { email: string; full_name: str
|
|||||||
|
|
||||||
// Criar usuário para PACIENTE no Supabase Auth (sistema de autenticação)
|
// Criar usuário para PACIENTE no Supabase Auth (sistema de autenticação)
|
||||||
export async function criarUsuarioPaciente(paciente: { email: string; full_name: string; phone_mobile: string; }): Promise<any> {
|
export async function criarUsuarioPaciente(paciente: { email: string; full_name: string; phone_mobile: string; }): Promise<any> {
|
||||||
const redirectBase = DEFAULT_LANDING;
|
// Este helper NÃO deve usar /create-user como fallback.
|
||||||
const emailRedirectTo = `${redirectBase.replace(/\/$/, '')}/paciente`;
|
// Em vez disso, encaminha para a Edge Function especializada /functions/v1/create-patient
|
||||||
// Use the role-specific landing as the redirect_url so the magic link
|
// e inclui uma senha gerada para que o backend possa, se suportado, definir credenciais.
|
||||||
// redirects users directly to the app path (e.g. /paciente).
|
if (!paciente) throw new Error('Dados do paciente não informados');
|
||||||
const redirect_url = emailRedirectTo;
|
|
||||||
// generate a secure-ish random password on the client so the caller can receive it
|
const required = ['email', 'full_name', 'phone_mobile'];
|
||||||
|
for (const r of required) {
|
||||||
|
const val = (paciente as any)[r];
|
||||||
|
if (!val || (typeof val === 'string' && String(val).trim() === '')) {
|
||||||
|
throw new Error(`Campo obrigatório ausente para criar usuário/paciente: ${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a password so the UI can present it to the user if desired
|
||||||
const password = gerarSenhaAleatoria();
|
const password = gerarSenhaAleatoria();
|
||||||
const resp = await criarUsuario({ email: paciente.email, password, full_name: paciente.full_name, phone: paciente.phone_mobile, role: 'paciente' as any, emailRedirectTo, redirect_url, target: 'paciente' });
|
|
||||||
// Return backend response plus the generated password so the UI can show/save it
|
// Normalize CPF is intentionally not required here because this helper is used
|
||||||
return { ...(resp as any), password };
|
// when creating access; if CPF is needed it should be provided to the create-patient Function.
|
||||||
|
const payload: any = {
|
||||||
|
email: paciente.email,
|
||||||
|
full_name: paciente.full_name,
|
||||||
|
phone_mobile: paciente.phone_mobile,
|
||||||
|
// provide a client-generated password (backend may accept or ignore)
|
||||||
|
password,
|
||||||
|
// indicate target so function can assign role and redirect
|
||||||
|
target: 'paciente',
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${API_BASE}/functions/v1/create-patient`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...baseHeaders(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = await parse<any>(res as Response);
|
||||||
|
// Attach the generated password so callers (UI) can display it if necessary
|
||||||
|
return { ...(parsed || {}), password };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user