From 8258fac83ce2595dd87f294d921cb6692eedfa36 Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Wed, 3 Sep 2025 21:13:13 -0300 Subject: [PATCH 01/15] feat: implement patient recorder --- susconecta/app/dashboard/pacientes/page.tsx | 64 +- susconecta/app/layout.tsx | 19 +- .../forms/patient-registration-form.tsx | 1646 +++++++++++++++++ susconecta/components/header.tsx | 16 +- susconecta/package-lock.json | 712 ++----- susconecta/package.json | 10 +- ...-doctor-in-white-coat-smiling-confiden.png | Bin 1132739 -> 0 bytes ...-working-on-laptop-in-modern-office-en.jpg | Bin 90125 -> 0 bytes 8 files changed, 1850 insertions(+), 617 deletions(-) create mode 100644 susconecta/components/forms/patient-registration-form.tsx delete mode 100644 susconecta/public/professional-doctor-in-white-coat-smiling-confiden.png delete mode 100644 susconecta/public/professional-working-on-laptop-in-modern-office-en.jpg diff --git a/susconecta/app/dashboard/pacientes/page.tsx b/susconecta/app/dashboard/pacientes/page.tsx index d35bbd1..b2ea805 100644 --- a/susconecta/app/dashboard/pacientes/page.tsx +++ b/susconecta/app/dashboard/pacientes/page.tsx @@ -16,7 +16,20 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" -import { Search, Filter, Plus, MoreHorizontal, Calendar, Gift, Eye, Edit, Trash2, CalendarPlus } from "lucide-react" +import { + Search, + Filter, + Plus, + MoreHorizontal, + Calendar, + Gift, + Eye, + Edit, + Trash2, + CalendarPlus, + ArrowLeft, +} from "lucide-react" +import { PatientRegistrationForm } from "@/components/forms/patient-registration-form" const patients = [ { @@ -88,9 +101,11 @@ const patients = [ export default function PacientesPage() { const [searchTerm, setSearchTerm] = useState("") - const [selectedConvenio, setSelectedConvenio] = useState("all") // Updated default value to "all" + const [selectedConvenio, setSelectedConvenio] = useState("all") const [showVipOnly, setShowVipOnly] = useState(false) const [showBirthdays, setShowBirthdays] = useState(false) + const [showPatientForm, setShowPatientForm] = useState(false) + const [editingPatient, setEditingPatient] = useState(null) const [advancedFilters, setAdvancedFilters] = useState({ city: "", state: "", @@ -108,12 +123,10 @@ export default function PacientesPage() { const matchesConvenio = selectedConvenio === "all" || patient.convenio === selectedConvenio const matchesVip = !showVipOnly || patient.isVip - // Check if patient has birthday this month const currentMonth = new Date().getMonth() + 1 const patientBirthMonth = new Date(patient.birthday).getMonth() + 1 const matchesBirthday = !showBirthdays || patientBirthMonth === currentMonth - // Advanced filters const matchesCity = !advancedFilters.city || patient.city.toLowerCase().includes(advancedFilters.city.toLowerCase()) const matchesState = !advancedFilters.state || patient.state.toLowerCase().includes(advancedFilters.state.toLowerCase()) @@ -145,39 +158,67 @@ export default function PacientesPage() { const handleViewDetails = (patientId: number) => { console.log("[v0] Ver detalhes do paciente:", patientId) - // TODO: Navigate to patient details page } const handleEditPatient = (patientId: number) => { console.log("[v0] Editar paciente:", patientId) - // TODO: Navigate to edit patient form + setEditingPatient(patientId) + setShowPatientForm(true) } const handleScheduleAppointment = (patientId: number) => { console.log("[v0] Marcar consulta para paciente:", patientId) - // TODO: Open appointment scheduling modal } const handleDeletePatient = (patientId: number) => { console.log("[v0] Excluir paciente:", patientId) - // TODO: Show confirmation dialog and delete patient + } + + const handleAddPatient = () => { + setEditingPatient(null) + setShowPatientForm(true) + } + + const handleFormClose = () => { + setShowPatientForm(false) + setEditingPatient(null) + } + + if (showPatientForm) { + return ( +
+
+ +
+

+ {editingPatient ? "Editar Paciente" : "Cadastrar Novo Paciente"} +

+

+ {editingPatient ? "Atualize as informações do paciente" : "Preencha os dados do novo paciente"} +

+
+
+ + +
+ ) } return (
- {/* Header */}

Pacientes

Gerencie as informações de seus pacientes

-
- {/* Filters */}
@@ -290,7 +331,6 @@ export default function PacientesPage() {
- {/* Table */}
diff --git a/susconecta/app/layout.tsx b/susconecta/app/layout.tsx index 853cc31..f127de6 100644 --- a/susconecta/app/layout.tsx +++ b/susconecta/app/layout.tsx @@ -1,26 +1,13 @@ import type React from "react" import type { Metadata } from "next" -import { Geist, Geist_Mono } from "next/font/google" import "./globals.css" -const geistSans = Geist({ - subsets: ["latin"], - display: "swap", - variable: "--font-geist-sans", -}) - -const geistMono = Geist_Mono({ - subsets: ["latin"], - display: "swap", - variable: "--font-geist-mono", -}) - export const metadata: Metadata = { title: "SUSConecta - Conectando Pacientes e Profissionais de Saúde", description: "Plataforma inovadora que conecta pacientes e médicos de forma prática, segura e humanizada. Experimente o futuro dos agendamentos médicos.", keywords: "saúde, médicos, pacientes, agendamento, telemedicina, SUS", - generator: 'v0.app' + generator: 'v0.app' } export default function RootLayout({ @@ -29,8 +16,8 @@ export default function RootLayout({ children: React.ReactNode }) { return ( - - {children} + + {children} ) } diff --git a/susconecta/components/forms/patient-registration-form.tsx b/susconecta/components/forms/patient-registration-form.tsx new file mode 100644 index 0000000..ceff417 --- /dev/null +++ b/susconecta/components/forms/patient-registration-form.tsx @@ -0,0 +1,1646 @@ +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Checkbox } from "@/components/ui/checkbox" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + Upload, + ChevronDown, + ChevronUp, + X, + FileText, + User, + Phone, + MapPin, + FileImage, + Save, + XCircle, + AlertCircle, + Loader2, +} from "lucide-react" + +interface PatientFormData { + // Dados pessoais + photo: File | null + nome: string + nomeSocial: string + cpf: string + rg: string + outroDocumento: string + numeroDocumento: string + sexo: string + dataNascimento: string + etnia: string + raca: string + naturalidade: string + nacionalidade: string + profissao: string + estadoCivil: string + nomeMae: string + profissaoMae: string + nomePai: string + profissaoPai: string + nomeResponsavel: string + cpfResponsavel: string + nomeEsposo: string + rnGuiaConvenio: boolean + codigoLegado: string + + // Observações e anexos + observacoes: string + anexos: File[] + + // Contato + email: string + celular: string + telefone1: string + telefone2: string + + // Endereço + cep: string + logradouro: string + numero: string + complemento: string + bairro: string + cidade: string + estado: string + referencia: string +} + +interface PatientRegistrationFormProps { + open?: boolean + onOpenChange?: (open: boolean) => void + patientData?: PatientFormData | null + patientId?: number | null + mode?: "create" | "edit" + onClose?: () => void + inline?: boolean +} + +export function PatientRegistrationForm({ + open = true, + onOpenChange, + patientData = null, + patientId = null, + mode = "create", + onClose, + inline = false, +}: PatientRegistrationFormProps) { + const initialFormData: PatientFormData = { + photo: null, + nome: "", + nomeSocial: "", + cpf: "", + rg: "", + outroDocumento: "", + numeroDocumento: "", + sexo: "", + dataNascimento: "", + etnia: "", + raca: "", + naturalidade: "", + nacionalidade: "Brasileira", + profissao: "", + estadoCivil: "", + nomeMae: "", + profissaoMae: "", + nomePai: "", + profissaoPai: "", + nomeResponsavel: "", + cpfResponsavel: "", + nomeEsposo: "", + rnGuiaConvenio: false, + codigoLegado: "", + observacoes: "", + anexos: [], + email: "", + celular: "", + telefone1: "", + telefone2: "", + cep: "", + logradouro: "", + numero: "", + complemento: "", + bairro: "", + cidade: "", + estado: "", + referencia: "", + } + + const [formData, setFormData] = useState(patientData || initialFormData) + const [expandedSections, setExpandedSections] = useState({ + dadosPessoais: true, + observacoes: false, + contato: false, + endereco: false, + }) + + const [photoPreview, setPhotoPreview] = useState(null) + const [isLoadingCep, setIsLoadingCep] = useState(false) + const [errors, setErrors] = useState>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + useEffect(() => { + if (patientId && mode === "edit") { + // TODO: Fetch patient data by ID + console.log("[v0] Loading patient data for ID:", patientId) + // For now, use mock data or existing patientData + if (patientData) { + setFormData(patientData) + if (patientData.photo) { + const reader = new FileReader() + reader.onload = (e) => setPhotoPreview(e.target?.result as string) + reader.readAsDataURL(patientData.photo) + } + } + } else if (mode === "create") { + setFormData(initialFormData) + setPhotoPreview(null) + } + }, [patientId, patientData, mode]) + + const toggleSection = (section: keyof typeof expandedSections) => { + setExpandedSections((prev) => ({ + ...prev, + [section]: !prev[section], + })) + } + + const handleInputChange = (field: keyof PatientFormData, value: string | boolean | File | File[]) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })) + + // Clear error when user starts typing + if (errors[field]) { + setErrors((prev) => { + const newErrors = { ...prev } + delete newErrors[field] + return newErrors + }) + } + } + + const formatCPF = (value: string) => { + const numbers = value.replace(/\D/g, "") + return numbers.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4") + } + + const handleCPFChange = (value: string) => { + const formatted = formatCPF(value) + handleInputChange("cpf", formatted) + } + + const formatPhone = (value: string) => { + const numbers = value.replace(/\D/g, "") + if (numbers.length <= 10) { + return numbers.replace(/(\d{2})(\d{4})(\d{4})/, "($1) $2-$3") + } + return numbers.replace(/(\d{2})(\d{5})(\d{4})/, "($1) $2-$3") + } + + const formatCellPhone = (value: string) => { + const numbers = value.replace(/\D/g, "") + return numbers.replace(/(\d{2})(\d{5})(\d{4})/, "+55 ($1) $2-$3") + } + + const formatCEP = (value: string) => { + const numbers = value.replace(/\D/g, "") + return numbers.replace(/(\d{5})(\d{3})/, "$1-$2") + } + + const handlePhotoUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + if (file.size > 5 * 1024 * 1024) { + // 5MB limit + setErrors((prev) => ({ ...prev, photo: "Arquivo muito grande. Máximo 5MB." })) + return + } + + handleInputChange("photo", file) + const reader = new FileReader() + reader.onload = (e) => { + setPhotoPreview(e.target?.result as string) + } + reader.readAsDataURL(file) + } + } + + const handleAnexoUpload = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []) + + const validFiles = files.filter((file) => { + if (file.size > 10 * 1024 * 1024) { + // 10MB limit per file + setErrors((prev) => ({ ...prev, anexos: `Arquivo ${file.name} muito grande. Máximo 10MB por arquivo.` })) + return false + } + return true + }) + + handleInputChange("anexos", [...formData.anexos, ...validFiles]) + } + + const removeAnexo = (index: number) => { + const newAnexos = formData.anexos.filter((_, i) => i !== index) + handleInputChange("anexos", newAnexos) + } + + const validateCPF = (cpf: string) => { + const cleanCPF = cpf.replace(/\D/g, "") + if (cleanCPF.length !== 11) return false + + // Check for known invalid CPFs + if (/^(\d)\1{10}$/.test(cleanCPF)) return false + + let sum = 0 + for (let i = 0; i < 9; i++) { + sum += Number.parseInt(cleanCPF.charAt(i)) * (10 - i) + } + let remainder = (sum * 10) % 11 + if (remainder === 10 || remainder === 11) remainder = 0 + if (remainder !== Number.parseInt(cleanCPF.charAt(9))) return false + + sum = 0 + for (let i = 0; i < 10; i++) { + sum += Number.parseInt(cleanCPF.charAt(i)) * (11 - i) + } + remainder = (sum * 10) % 11 + if (remainder === 10 || remainder === 11) remainder = 0 + return remainder === Number.parseInt(cleanCPF.charAt(10)) + } + + const searchCEP = async (cep: string) => { + const cleanCEP = cep.replace(/\D/g, "") + if (cleanCEP.length !== 8) return + + setIsLoadingCep(true) + try { + const response = await fetch(`https://viacep.com.br/ws/${cleanCEP}/json/`) + const data = await response.json() + + if (data.erro) { + setErrors((prev) => ({ ...prev, cep: "CEP não encontrado" })) + } else { + handleInputChange("logradouro", data.logradouro || "") + handleInputChange("bairro", data.bairro || "") + handleInputChange("cidade", data.localidade || "") + handleInputChange("estado", data.uf || "") + + // Clear CEP error if successful + if (errors.cep) { + setErrors((prev) => { + const newErrors = { ...prev } + delete newErrors.cep + return newErrors + }) + } + } + } catch (error) { + console.error("Erro ao buscar CEP:", error) + setErrors((prev) => ({ ...prev, cep: "Erro ao buscar CEP. Tente novamente." })) + } finally { + setIsLoadingCep(false) + } + } + + const validateForm = () => { + const newErrors: Record = {} + + // Required fields + if (!formData.nome.trim()) { + newErrors.nome = "Nome é obrigatório" + } + + // CPF validation + if (formData.cpf && !validateCPF(formData.cpf)) { + newErrors.cpf = "CPF inválido" + } + + // Email validation + if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = "E-mail inválido" + } + + // Responsible CPF validation + if (formData.cpfResponsavel && !validateCPF(formData.cpfResponsavel)) { + newErrors.cpfResponsavel = "CPF do responsável inválido" + } + + // Date validation + if (formData.dataNascimento) { + const birthDate = new Date(formData.dataNascimento) + const today = new Date() + if (birthDate > today) { + newErrors.dataNascimento = "Data de nascimento não pode ser futura" + } + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + if (!validateForm()) { + // Expand sections with errors + const errorFields = Object.keys(errors) + if (errorFields.some((field) => ["nome", "cpf", "rg", "sexo", "dataNascimento"].includes(field))) { + setExpandedSections((prev) => ({ ...prev, dadosPessoais: true })) + } + if (errorFields.some((field) => ["email", "celular"].includes(field))) { + setExpandedSections((prev) => ({ ...prev, contato: true })) + } + if (errorFields.some((field) => ["cep", "logradouro"].includes(field))) { + setExpandedSections((prev) => ({ ...prev, endereco: true })) + } + return + } + + setIsSubmitting(true) + + try { + console.log("[v0] Saving patient data:", formData) + console.log("[v0] Mode:", mode) + console.log("[v0] Patient ID:", patientId) + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // TODO: Implement actual API call + // const response = await fetch('/api/patients', { + // method: mode === 'create' ? 'POST' : 'PUT', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify(formData) + // }) + + // Reset form if creating new patient + if (mode === "create") { + setFormData(initialFormData) + setPhotoPreview(null) + } + + if (inline && onClose) { + onClose() + } else if (onOpenChange) { + onOpenChange(false) + } + + // Show success message (you might want to use a toast notification) + alert(mode === "create" ? "Paciente cadastrado com sucesso!" : "Paciente atualizado com sucesso!") + } catch (error) { + console.error("Erro ao salvar paciente:", error) + setErrors({ submit: "Erro ao salvar paciente. Tente novamente." }) + } finally { + setIsSubmitting(false) + } + } + + const handleCancel = () => { + if (inline && onClose) { + onClose() + } else if (onOpenChange) { + onOpenChange(false) + } + } + + if (inline) { + return ( +
+ {errors.submit && ( + + + {errors.submit} + + )} + +
+ {/* Dados Pessoais */} + toggleSection("dadosPessoais")}> + + + + + + + Dados Pessoais + + {expandedSections.dadosPessoais ? ( + + ) : ( + + )} + + + + + + {/* Foto */} +
+
+ {photoPreview ? ( + Preview + ) : ( + + )} +
+
+ + + {errors.photo &&

{errors.photo}

} +

Máximo 5MB

+
+
+ + {/* Nome e Nome Social */} +
+
+ + handleInputChange("nome", e.target.value)} + className={errors.nome ? "border-destructive" : ""} + /> + {errors.nome &&

{errors.nome}

} +
+
+ + handleInputChange("nomeSocial", e.target.value)} + /> +
+
+ + {/* CPF e RG */} +
+
+ + handleCPFChange(e.target.value)} + placeholder="000.000.000-00" + maxLength={14} + className={errors.cpf ? "border-destructive" : ""} + /> + {errors.cpf &&

{errors.cpf}

} +
+
+ + handleInputChange("rg", e.target.value)} /> +
+
+ + {/* Outros Documentos */} +
+
+ + +
+
+ + handleInputChange("numeroDocumento", e.target.value)} + disabled={!formData.outroDocumento} + /> +
+
+ + {/* Sexo e Data de Nascimento */} +
+
+ + handleInputChange("sexo", value)}> +
+ + +
+
+ + +
+
+ + +
+
+
+
+ + handleInputChange("dataNascimento", e.target.value)} + className={errors.dataNascimento ? "border-destructive" : ""} + /> + {errors.dataNascimento &&

{errors.dataNascimento}

} +
+
+ + {/* Etnia e Raça */} +
+
+ + +
+
+ + handleInputChange("raca", e.target.value)} + placeholder="Opcional" + /> +
+
+ + {/* Naturalidade e Nacionalidade */} +
+
+ + handleInputChange("naturalidade", e.target.value)} + placeholder="Cidade de nascimento" + /> +
+
+ + handleInputChange("nacionalidade", e.target.value)} + /> +
+
+ + {/* Profissão e Estado Civil */} +
+
+ + handleInputChange("profissao", e.target.value)} + /> +
+
+ + +
+
+ + {/* Dados dos Pais */} +
+
+ + handleInputChange("nomeMae", e.target.value)} + /> +
+
+ + handleInputChange("profissaoMae", e.target.value)} + /> +
+
+ +
+
+ + handleInputChange("nomePai", e.target.value)} + /> +
+
+ + handleInputChange("profissaoPai", e.target.value)} + /> +
+
+ + {/* Responsável */} +
+
+ + handleInputChange("nomeResponsavel", e.target.value)} + placeholder="Para menores ou dependentes" + /> +
+
+ + handleCPFChange(e.target.value)} + placeholder="000.000.000-00" + maxLength={14} + className={errors.cpfResponsavel ? "border-destructive" : ""} + /> + {errors.cpfResponsavel &&

{errors.cpfResponsavel}

} +
+
+ + {/* Esposo e Configurações */} +
+
+ + handleInputChange("nomeEsposo", e.target.value)} + /> +
+
+ + handleInputChange("codigoLegado", e.target.value)} + placeholder="ID de outro sistema" + /> +
+
+ + {/* RN na Guia do Convênio */} +
+ handleInputChange("rnGuiaConvenio", checked as boolean)} + /> + +
+
+
+
+
+ + {/* Observações e Anexos */} + toggleSection("observacoes")}> + + + + + + + Observações e Anexos + + {expandedSections.observacoes ? ( + + ) : ( + + )} + + + + + +
+ +