diff --git a/susconecta/app/login-paciente/page.tsx b/susconecta/app/login-paciente/page.tsx index c41150e..2d125ab 100644 --- a/susconecta/app/login-paciente/page.tsx +++ b/susconecta/app/login-paciente/page.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation' import Link from 'next/link' import { useAuth } from '@/hooks/useAuth' import { sendMagicLink } from '@/lib/api' +import { ENV_CONFIG } from '@/lib/env-config' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -76,6 +77,84 @@ export default function LoginPacientePage() { } } + // --- Auto-cadastro (client-side) --- + const [showRegister, setShowRegister] = useState(false) + const [reg, setReg] = useState({ email: '', full_name: '', phone_mobile: '', cpf: '', birth_date: '' }) + const [regLoading, setRegLoading] = useState(false) + const [regError, setRegError] = useState('') + const [regSuccess, setRegSuccess] = useState('') + + function cleanCpf(cpf: string) { + return String(cpf || '').replace(/\D/g, '') + } + + function validateCPF(cpfRaw: string) { + const cpf = cleanCpf(cpfRaw) + if (!/^\d{11}$/.test(cpf)) return false + if (/^([0-9])\1+$/.test(cpf)) return false + const digits = cpf.split('').map((d) => Number(d)) + const calc = (len: number) => { + let sum = 0 + for (let i = 0; i < len; i++) sum += digits[i] * (len + 1 - i) + const v = (sum * 10) % 11 + return v === 10 ? 0 : v + } + return calc(9) === digits[9] && calc(10) === digits[10] + } + + const handleRegister = async (e?: React.FormEvent) => { + if (e) e.preventDefault() + setRegError('') + setRegSuccess('') + + // client-side validation + if (!reg.email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(reg.email)) return setRegError('Email inválido') + if (!reg.full_name || reg.full_name.trim().length < 3) return setRegError('Nome deve ter ao menos 3 caracteres') + if (!reg.phone_mobile || !/^\d{10,11}$/.test(reg.phone_mobile)) return setRegError('Telefone inválido (10-11 dígitos)') + if (!reg.cpf || !/^\d{11}$/.test(cleanCpf(reg.cpf))) return setRegError('CPF deve conter 11 dígitos') + if (!validateCPF(reg.cpf)) return setRegError('CPF inválido') + + setRegLoading(true) + try { + const url = `${ENV_CONFIG.SUPABASE_URL}/functions/v1/register-patient` + const body = { + email: reg.email, + full_name: reg.full_name, + phone_mobile: reg.phone_mobile, + cpf: cleanCpf(reg.cpf), + // always include redirect to patient landing as requested + redirect_url: 'https://mediconecta-app-liart.vercel.app/' + } as any + if (reg.birth_date) body.birth_date = reg.birth_date + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' }, + body: JSON.stringify(body), + }) + + const json = await res.json().catch(() => null) + if (res.ok) { + setRegSuccess(json?.message ?? 'Cadastro realizado com sucesso! Verifique seu email para acessar a plataforma.') + // clear form but keep email for convenience + setReg({ ...reg, full_name: '', phone_mobile: '', cpf: '', birth_date: '' }) + } else if (res.status === 400) { + setRegError(json?.error ?? json?.message ?? 'Dados inválidos') + } else if (res.status === 409) { + setRegError(json?.error ?? 'CPF ou email já cadastrado') + } else if (res.status === 429) { + setRegError(json?.error ?? 'Rate limit excedido. Tente novamente mais tarde.') + } else { + setRegError(json?.error ?? json?.message ?? `Erro (${res.status})`) + } + } catch (err: any) { + console.error('[REGISTER PACIENTE] erro', err) + setRegError(err?.message ?? String(err)) + } finally { + setRegLoading(false) + } + } + return (
@@ -167,6 +246,53 @@ export default function LoginPacientePage() {
+
+
Ainda não tem conta?
+ + {showRegister && ( + + + Auto-cadastro de Paciente + + +
+
+ + setReg({...reg, full_name: e.target.value})} required /> +
+
+ + setReg({...reg, email: e.target.value})} required /> +
+
+ + setReg({...reg, phone_mobile: e.target.value.replace(/\D/g,'')})} placeholder="11999998888" required /> +
+
+ + setReg({...reg, cpf: e.target.value.replace(/\D/g,'')})} placeholder="12345678901" required /> +
+
+ + setReg({...reg, birth_date: e.target.value})} /> +
+ + {regError && ( + {regError} + )} + {regSuccess && ( + {regSuccess} + )} + +
+ + +
+
+
+
+ )} +
diff --git a/susconecta/components/forms/doctor-registration-form.tsx b/susconecta/components/forms/doctor-registration-form.tsx index 7c29f30..51530d7 100644 --- a/susconecta/components/forms/doctor-registration-form.tsx +++ b/susconecta/components/forms/doctor-registration-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { parse } from 'date-fns'; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -429,18 +430,35 @@ function setField(k: T, v: FormData[T]) { } function toPayload(): MedicoInput { - // Converte dd/MM/yyyy para ISO (yyyy-MM-dd) se possível + // Converte data de nascimento para ISO (yyyy-MM-dd) tentando vários formatos let isoDate: string | null = null; try { - const parts = String(form.data_nascimento).split(/\D+/).filter(Boolean); - if (parts.length === 3) { - 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 raw = String(form.data_nascimento || '').trim(); + if (raw) { + const formats = ['dd/MM/yyyy', 'dd-MM-yyyy', 'yyyy-MM-dd', 'MM/dd/yyyy']; + for (const f of formats) { + try { + const d = parse(raw, f, new Date()); + if (!isNaN(d.getTime())) { + isoDate = d.toISOString().slice(0, 10); + break; + } + } catch (e) { + // ignore and try next + } + } + if (!isoDate) { + const parts = raw.split(/\D+/).filter(Boolean); + if (parts.length === 3) { + 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); + } } } - } catch {} + } catch (err) { + console.debug('[DoctorForm] parse data_nascimento failed:', form.data_nascimento, err); + } return { user_id: null, @@ -512,9 +530,42 @@ async function handleSubmit(ev: React.FormEvent) { console.log("Enviando os dados para a API:", medicoPayload); // 1. Cria o perfil do médico na tabela doctors - const savedDoctorProfile = await criarMedico(medicoPayload); + let savedDoctorProfile: any = await criarMedico(medicoPayload); console.log("✅ Perfil do médico criado:", savedDoctorProfile); + // Fallback: some create flows don't persist optional fields like birth_date/cep/sexo. + // If the returned object is missing those but our payload included them, + // attempt a PATCH (atualizarMedico) to force persistence, mirroring the edit flow. + try { + const resultAny = savedDoctorProfile as any; + let createdDoctorId: string | null = null; + if (resultAny) { + if (resultAny.id) createdDoctorId = String(resultAny.id); + else if (resultAny.doctor && resultAny.doctor.id) createdDoctorId = String(resultAny.doctor.id); + else if (resultAny.doctor_id) createdDoctorId = String(resultAny.doctor_id); + else if (Array.isArray(resultAny) && resultAny[0]?.id) createdDoctorId = String(resultAny[0].id); + } + + const missing: string[] = []; + if (createdDoctorId) { + if (!resultAny?.birth_date && medicoPayload.birth_date) missing.push('birth_date'); + if (!resultAny?.cep && medicoPayload.cep) missing.push('cep'); + // creation payload uses form.sexo (not medicoPayload.sex/sexo), so check form + if (!(resultAny?.sex || resultAny?.sexo) && form.sexo) missing.push('sex'); + } + + if (createdDoctorId && missing.length) { + console.debug('[DoctorForm] create returned without fields, attempting PATCH fallback for:', missing); + const patched = await atualizarMedico(String(createdDoctorId), medicoPayload).catch((e) => { console.warn('[DoctorForm] fallback PATCH failed:', e); return null; }); + if (patched) { + console.debug('[DoctorForm] fallback PATCH result:', patched); + savedDoctorProfile = patched; + } + } + } catch (e) { + console.warn('[DoctorForm] error during fallback PATCH:', e); + } + // The server-side Edge Function `criarMedico` should perform the privileged // operations (create doctor row and auth user) and return a normalized // envelope or the created doctor object. We rely on that single-call flow diff --git a/susconecta/components/forms/patient-registration-form.tsx b/susconecta/components/forms/patient-registration-form.tsx index 31cd412..c362189 100644 --- a/susconecta/components/forms/patient-registration-form.tsx +++ b/susconecta/components/forms/patient-registration-form.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { format, parseISO } from "date-fns"; +import { format, parseISO, parse } from "date-fns"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -190,11 +190,35 @@ export function PatientRegistrationForm({ function toPayload(): PacienteInput { let isoDate: string | null = null; try { - const parts = String(form.birth_date).split(/\D+/).filter(Boolean); - if (parts.length === 3) { - 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 raw = String(form.birth_date || '').trim(); + if (raw) { + // Try common formats first + const formats = ['dd/MM/yyyy', 'dd-MM-yyyy', 'yyyy-MM-dd', 'MM/dd/yyyy']; + for (const f of formats) { + try { + const d = parse(raw, f, new Date()); + if (!isNaN(d.getTime())) { + isoDate = d.toISOString().slice(0, 10); + break; + } + } catch (e) { + // ignore and try next format + } + } + + // Fallback: split numeric parts (handles 'dd mm yyyy' or 'ddmmyyyy' with separators) + if (!isoDate) { + const parts = raw.split(/\D+/).filter(Boolean); + if (parts.length === 3) { + 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); + } + } } - } catch {} + } catch (err) { + console.debug('[PatientForm] parse birth_date failed:', form.birth_date, err); + } return { full_name: form.nome, social_name: form.nome_social || null, @@ -236,13 +260,42 @@ export function PatientRegistrationForm({ } else { // create const patientPayload = toPayload(); + // Debug helper: log the exact payload being sent to criarPaciente so + // we can inspect whether `sex`, `birth_date` and `cep` are present + // before the network request. This helps diagnose backends that + // ignore alternate field names or strip optional fields. + console.debug('[PatientForm] payload before criarPaciente:', patientPayload); // require phone when email present for single-call function 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; } - const savedPatientProfile = await criarPaciente(patientPayload); + let savedPatientProfile: any = await criarPaciente(patientPayload); console.log('Perfil do paciente criado (via Function):', savedPatientProfile); + // Fallback: some backend create flows (create-user-with-password) do not + // persist optional patient fields like sex/cep/birth_date. The edit flow + // (atualizarPaciente) writes directly to the patients table and works. + // To make create behave like edit, attempt a PATCH right after create + // when any of those fields are missing from the returned object. + try { + const pacienteId = savedPatientProfile?.id || savedPatientProfile?.patient_id || savedPatientProfile?.user_id; + const missing: string[] = []; + if (!savedPatientProfile?.sex && patientPayload.sex) missing.push('sex'); + if (!savedPatientProfile?.cep && patientPayload.cep) missing.push('cep'); + if (!savedPatientProfile?.birth_date && patientPayload.birth_date) missing.push('birth_date'); + + if (pacienteId && missing.length) { + console.debug('[PatientForm] criando paciente: campos faltando no retorno do create, tentando PATCH fallback:', missing); + const patched = await atualizarPaciente(String(pacienteId), patientPayload).catch((e) => { console.warn('[PatientForm] fallback PATCH falhou:', e); return null; }); + if (patched) { + console.debug('[PatientForm] fallback PATCH result:', patched); + savedPatientProfile = patched; + } + } + } catch (e) { + console.warn('[PatientForm] erro ao tentar fallback PATCH:', e); + } + 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' }); diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index 71758e6..46fdbd1 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -125,6 +125,7 @@ export type Medico = { // ...existing code... export type MedicoInput = { user_id?: string | null; + sexo?: string | null; crm: string; crm_uf: string; specialty: string; @@ -1526,6 +1527,29 @@ export async function criarPaciente(input: PacienteInput): Promise { if (input.social_name) payload.social_name = input.social_name; if (input.notes) payload.notes = input.notes; + // Add compatibility aliases so different backend schemas accept these fields + try { + if (input.cep) { + const cleanedCep = String(input.cep).replace(/\D/g, ''); + if (cleanedCep) { + payload.postal_code = cleanedCep; + payload.zip = cleanedCep; + } + } + } catch (e) { /* ignore */ } + + if (input.birth_date) { + payload.date_of_birth = input.birth_date; + payload.dob = input.birth_date; + payload.birthdate = input.birth_date; + } + + if (input.sex) { + payload.sex = input.sex; + payload.sexo = input.sex; + payload.gender = input.sex; + } + // Call the create-user-with-password endpoint (try functions path then root) const fnUrls = [ `${API_BASE}/functions/v1/create-user-with-password`, @@ -1987,7 +2011,25 @@ export async function criarMedico(input: MedicoInput): Promise { 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 as any).sexo) payload.sexo = (input as any).sexo; + if ((input as any).sexo) payload.gender = (input as any).sexo; if (input.rg) payload.rg = input.rg; + // compatibility aliases + try { + if ((input as any).cep) { + const cleaned = String((input as any).cep).replace(/\D/g, ''); + if (cleaned) { payload.postal_code = cleaned; payload.zip = cleaned; } + } + } catch (e) {} + if ((input as any).birth_date) { + payload.date_of_birth = (input as any).birth_date; + payload.dob = (input as any).birth_date; + payload.birthdate = (input as any).birth_date; + } + if ((input as any).sexo) { + payload.sexo = (input as any).sexo; + payload.gender = (input as any).sexo; + } const fnUrls = [ `${API_BASE}/functions/v1/create-user-with-password`,