develop #83

Merged
M-Gabrielly merged 426 commits from develop into main 2025-12-04 04:13:15 +00:00
3 changed files with 416 additions and 674 deletions
Showing only changes of commit 442d11da0c - Show all commits

View File

@ -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);

View File

@ -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 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 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}
/>
)}
</>
);
} }

View File

@ -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 };
} }