diff --git a/README.md b/README.md index 4beccf8..a737796 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ --- -## 📋 Índice +## Índice 1. [Visão Geral](#-visão-geral) 2. [Problema e Solução](#-problema-e-solução) @@ -32,61 +32,61 @@ --- -## 🎯 Visão Geral +## Visão Geral **MEDIConnect** é uma plataforma web moderna e intuitiva desenvolvida para revolucionar a gestão de saúde em clínicas e hospitais. Com foco na redução do absenteísmo (faltas em consultas), a plataforma oferece uma experiência completa para pacientes, profissionais de saúde e administradores. -### 🌟 Diferenciais +### Diferenciais -- 🤖 **Zoe IA Assistant**: Assistente virtual inteligente para suporte aos usuários -- 📱 **Interface Responsiva**: Design moderno e adaptável a qualquer dispositivo -- 🔐 **Autenticação Segura**: Sistema robusto com perfis diferenciados -- ⚡ **Performance**: Construído com Next.js 15 para máxima velocidade -- 🎨 **UX/UI Premium**: Interface limpa e profissional voltada para área da saúde +- **Zoe IA Assistant**: Assistente virtual inteligente para suporte aos usuários +- **Interface Responsiva**: Design moderno e adaptável a qualquer dispositivo +- **Autenticação Segura**: Sistema robusto com perfis diferenciados +- **Performance**: Construído com Next.js 15 para máxima velocidade +- **UX/UI Premium**: Interface limpa e profissional voltada para área da saúde --- -## 🩺 Problema e Solução +## Problema e Solução ### O Problema O **absenteísmo** (não comparecimento a consultas agendadas) é um problema crítico em clínicas e hospitais, causando: -- ⏰ Desperdício de tempo dos profissionais -- 💰 Perda de receita para estabelecimentos -- 📉 Redução da eficiência operacional -- 😔 Impacto negativo no atendimento de outros pacientes +- Desperdício de tempo dos profissionais +- Perda de receita para estabelecimentos +- Redução da eficiência operacional +- Impacto negativo no atendimento de outros pacientes ### Nossa Solução MEDIConnect oferece um sistema inteligente de gestão que: -- ✅ Facilita o agendamento e reagendamento de consultas -- ✅ Permite visualização clara da agenda para profissionais -- ✅ Oferece assistência via IA para dúvidas e suporte +- Facilita o agendamento e reagendamento de consultas +- Permite visualização clara da agenda para profissionais +- Oferece assistência via IA para dúvidas e suporte --- -## ✨ Funcionalidades +## Funcionalidades -### 👤 Para Pacientes -- 🏠 **Dashboard Personalizado**: Visão geral de consultas e exames -- 📅 **Agendamento**: Sistema fácil de marcar consultas -- 📋 **Resultados de Exames**: Acesso seguro a laudos e resultados -- 👨‍⚕️ **Busca de Profissionais**: Encontre médicos por especialidade -- 💬 **Zoe IA Assistant**: Tire dúvidas 24/7 com nossa assistente virtual +### Para Pacientes +- **Dashboard Personalizado**: Visão geral de consultas e exames +- **Agendamento**: Sistema fácil de marcar consultas +- **Resultados de Exames**: Acesso seguro a laudos e resultados +- **Busca de Profissionais**: Encontre médicos por especialidade +- **Zoe IA Assistant**: Tire dúvidas 24/7 com nossa assistente virtual -### 👨‍⚕️ Para Profissionais -- 📊 **Dashboard Profissional**: Visão completa de atendimentos -- ✍️ **Editor de Laudos**: Crie e edite laudos médicos de forma rápida -- 👥 **Gestão de Pacientes**: Acesse informações dos pacientes -- 📈 **Agenda**: Visualização clara de consultas +### Para Profissionais +- **Dashboard Profissional**: Visão completa de atendimentos +- **Editor de Laudos**: Crie e edite laudos médicos de forma rápida +- **Gestão de Pacientes**: Acesse informações dos pacientes +- **Agenda**: Visualização clara de consultas -### 🔧 Para Administradores -- 📊 **Dashboard Administrativo**: Métricas e estatísticas em tempo real -- 📈 **Relatórios Detalhados**: Análise de comparecimento e absenteísmo -- 👥 **Gestão Completa**: Gerencie pacientes, profissionais e agendamentos -- 🎯 **Painel de Controle**: Visão 360° da operação da clínica +### Para Administradores +- **Dashboard Administrativo**: Métricas e estatísticas em tempo real +- **Relatórios Detalhados**: Análise de comparecimento e absenteísmo +- **Gestão Completa**: Gerencie pacientes, profissionais e agendamentos +- **Painel de Controle**: Visão 360° da operação da clínica --- -## 🛠️ Tecnologias +## Tecnologias ### Frontend (Atual) - **[Next.js 15](https://nextjs.org/)** - Framework React com Server Components @@ -111,7 +111,7 @@ MEDIConnect oferece um sistema inteligente de gestão que: --- -## 🚀 Instalação +## Instalação ### Pré-requisitos @@ -167,21 +167,21 @@ Abra [http://localhost:3000](http://localhost:3000) no seu navegador. --- -## 💻 Como Usar +## Como Usar ### Navegação Principal -#### 🏠 Página Inicial +#### Página Inicial Acesse `/home` para conhecer a plataforma e suas funcionalidades. -#### 🔐 Autenticação +#### Autenticação O sistema possui três níveis de acesso: - **Pacientes**: `/login-paciente` - **Profissionais**: `/login-profissional` - **Administradores**: `/login-admin` -#### 📱 Funcionalidades por Perfil +#### Funcionalidades por Perfil **Como Paciente:** 1. Faça login em `/login-paciente` @@ -205,7 +205,7 @@ O sistema possui três níveis de acesso: --- -## 🎭 Fluxos de Usuário +## Fluxos de Usuário ### Fluxo de Agendamento (Paciente) @@ -243,9 +243,9 @@ E --> F[Tomar Decisões] --- -## 🧩 Componentes Principais +## Componentes Principais -### 🤖 Zoe IA Assistant +### Zoe IA Assistant Assistente virtual inteligente que oferece: - Suporte 24/7 aos usuários @@ -258,7 +258,7 @@ Assistente virtual inteligente que oferece: - `components/ZoeIA/voice-powered-orb.tsx` - `components/ZoeIA/demo.tsx` -### 📅 Sistema de Agendamento +### Sistema de Agendamento Gerenciamento completo de consultas e exames: - Calendário interativo @@ -271,7 +271,7 @@ Gerenciamento completo de consultas e exames: - `components/features/Calendario/` - `app/(main-routes)/consultas/` -### 📋 Editor de Laudos +### Editor de Laudos Ferramenta profissional para criação de laudos médicos: - Interface intuitiva @@ -283,7 +283,7 @@ Ferramenta profissional para criação de laudos médicos: - `lib/laudo-exemplos.ts` - `lib/laudo-notification.ts` -### 📊 Dashboard Analytics +### Dashboard Analytics Painéis administrativos com: - Métricas em tempo real @@ -298,7 +298,7 @@ Painéis administrativos com: --- -## 🤝 Contribuindo +## Contribuindo Contribuições são bem-vindas! Siga estes passos: @@ -351,26 +351,26 @@ Descreva suas mudanças detalhadamente. --- -## 📝 Licença +## Licença Este projeto está sob a licença **MIT**. Veja o arquivo [LICENSE](LICENSE) para mais detalhes. -## 📞 Contato +## Contato **MEDIConnect Team** -- 🌐 Website: [mediconnect.com](https://mediconecta-app-liart.vercel.app/) -- 📧 Email dos Desenvolvedores: - - 📧 [Jonas Francisco](mailto:jonastom478@gmail.com) - - 📧 [João Gustavo](mailto:jgcmendonca@gmail.com) - - 📧 [Maria Gabrielly](mailto:maria.gabrielly221106@gmail.com) - - 📧 [Pedro Gomes](mailto:pedrogomes5913@gmail.com) +- Website: [mediconnect.com](https://mediconecta-app-liart.vercel.app/) +- Email dos Desenvolvedores: + - [Jonas Francisco](mailto:jonastom478@gmail.com) + - [João Gustavo](mailto:jgcmendonca@gmail.com) + - [Maria Gabrielly](mailto:maria.gabrielly221106@gmail.com) + - [Pedro Gomes](mailto:pedrogomes5913@gmail.com) ---
-**Desenvolvido com ❤️ pelo squad 20** +**Desenvolvido pelo squad 20** *Transformando a gestão de saúde através da tecnologia* diff --git a/susconecta/app/(main-routes)/calendar/page.tsx b/susconecta/app/(main-routes)/calendar/page.tsx index cf86cd4..24ae39c 100644 --- a/susconecta/app/(main-routes)/calendar/page.tsx +++ b/susconecta/app/(main-routes)/calendar/page.tsx @@ -157,7 +157,7 @@ export default function AgendamentoPage() { // Mapa de classes para cores conhecidas const colorClassMap: Record = { blue: "bg-blue-500 ring-blue-500/20", - green: "bg-green-500 ring-green-500/20", + green: "bg-[#10B981] ring-[#10B981]/20", orange: "bg-orange-500 ring-orange-500/20", red: "bg-red-500 ring-red-500/20", purple: "bg-purple-500 ring-purple-500/20", @@ -242,7 +242,7 @@ export default function AgendamentoPage() { Solicitado
- + Confirmado
@@ -309,7 +309,7 @@ export default function AgendamentoPage() {
{/* legenda dinâmica: mostra as cores presentes nos agendamentos do dia atual */} -
+
diff --git a/susconecta/app/(main-routes)/consultas/page.tsx b/susconecta/app/(main-routes)/consultas/page.tsx index fe97473..6d0ccaa 100644 --- a/susconecta/app/(main-routes)/consultas/page.tsx +++ b/susconecta/app/(main-routes)/consultas/page.tsx @@ -111,8 +111,11 @@ export default function ConsultasPage() { const baseDate = scheduledBase ? new Date(scheduledBase) : new Date(); const duration = appointment.duration_minutes ?? appointment.duration ?? 30; - // compute start and end times (HH:MM) - const appointmentDateStr = baseDate.toISOString().split("T")[0]; + // compute start and end times (HH:MM) and date using local time to avoid timezone issues + const year = baseDate.getFullYear(); + const month = String(baseDate.getMonth() + 1).padStart(2, '0'); + const day = String(baseDate.getDate()).padStart(2, '0'); + const appointmentDateStr = `${year}-${month}-${day}`; const startTime = `${String(baseDate.getHours()).padStart(2, '0')}:${String(baseDate.getMinutes()).padStart(2, '0')}`; const endDate = new Date(baseDate.getTime() + duration * 60000); const endTime = `${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}`; @@ -567,13 +570,19 @@ export default function ConsultasPage() { {translateStatus(appointment.status)} @@ -658,13 +667,21 @@ export default function ConsultasPage() {
Status
{translateStatus(appointment.status)} @@ -777,13 +794,19 @@ export default function ConsultasPage() { {translateStatus(viewingAppointment?.status || "")} @@ -791,7 +814,7 @@ export default function ConsultasPage() {
- {capitalize(viewingAppointment?.type || "")} + {capitalize(viewingAppointment?.appointment_type || viewingAppointment?.type || "")}
diff --git a/susconecta/app/(main-routes)/dashboard/page.tsx b/susconecta/app/(main-routes)/dashboard/page.tsx index 67056ab..176da19 100644 --- a/susconecta/app/(main-routes)/dashboard/page.tsx +++ b/susconecta/app/(main-routes)/dashboard/page.tsx @@ -9,7 +9,6 @@ import { getUpcomingAppointments, getAppointmentsByDateRange, getNewUsersLastDays, - getPendingReports, getDisabledUsers, getDoctorsAvailabilityToday, getPatientById, @@ -18,7 +17,7 @@ import { import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { AlertCircle, Calendar, Users, Stethoscope, Clock, FileText, AlertTriangle, Plus, ArrowLeft } from 'lucide-react'; +import { AlertCircle, Calendar, Users, Stethoscope, Clock, AlertTriangle, Plus, ArrowLeft } from 'lucide-react'; import Link from 'next/link'; import { PatientRegistrationForm } from '@/components/features/forms/patient-registration-form'; import { DoctorRegistrationForm } from '@/components/features/forms/doctor-registration-form'; @@ -49,7 +48,6 @@ export default function DashboardPage() { const [appointments, setAppointments] = useState([]); const [appointmentData, setAppointmentData] = useState([]); const [newUsers, setNewUsers] = useState([]); - const [pendingReports, setPendingReports] = useState([]); const [disabledUsers, setDisabledUsers] = useState([]); const [doctors, setDoctors] = useState>(new Map()); const [patients, setPatients] = useState>(new Map()); @@ -83,18 +81,16 @@ export default function DashboardPage() { }); // 2. Carrega dados dos widgets em paralelo - const [upcomingAppts, appointmentDataRange, newUsersList, pendingReportsList, disabledUsersList] = await Promise.all([ + const [upcomingAppts, appointmentDataRange, newUsersList, disabledUsersList] = await Promise.all([ getUpcomingAppointments(5), getAppointmentsByDateRange(7), getNewUsersLastDays(7), - getPendingReports(5), getDisabledUsers(5), ]); setAppointments(upcomingAppts); setAppointmentData(appointmentDataRange); setNewUsers(newUsersList); - setPendingReports(pendingReportsList); setDisabledUsers(disabledUsersList); // 3. Busca detalhes de pacientes e médicos para as próximas consultas @@ -264,15 +260,7 @@ export default function DashboardPage() {
-
-
-
-

Relatórios Pendentes

-

{pendingReports.length}

-
- -
-
+ {/* 6. AÇÕES RÁPIDAS - Responsivo: stack em mobile, wrap em desktop */} @@ -294,17 +282,12 @@ export default function DashboardPage() { Novo Médico Médico - {/* 2. PRÓXIMAS CONSULTAS */} -
-
+
+

Próximas Consultas (7 dias)

{appointments.length > 0 ? (
@@ -330,28 +313,7 @@ export default function DashboardPage() { )}
- {/* 5. RELATÓRIOS PENDENTES */} -
-

- - Pendentes -

- {pendingReports.length > 0 ? ( -
- {pendingReports.map(report => ( -
-

{report.order_number}

-

{report.exam || 'Sem descrição'}

-
- ))} - -
- ) : ( -

Sem relatórios pendentes

- )} -
+
{/* 4. NOVOS USUÁRIOS */} diff --git a/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx index af905b2..ff51c1a 100644 --- a/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx +++ b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx @@ -2,10 +2,11 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { Button } from "@/components/ui/button"; import { FileDown, BarChart2, Users, CalendarCheck } from "lucide-react"; import jsPDF from "jspdf"; +import html2canvas from "html2canvas"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; import { countAppointmentsToday, @@ -30,10 +31,51 @@ const FALLBACK_MEDICOS = [ // Helper Functions // ============================================================================ -function exportPDF(title: string, content: string) { +async function exportPDF(title: string, content: string, chartElementId?: string) { const doc = new jsPDF(); - doc.text(title, 10, 10); - doc.text(content, 10, 20); + let yPosition = 15; + + // Add title + doc.setFontSize(16); + doc.setFont(undefined, "bold"); + doc.text(title, 15, yPosition); + yPosition += 10; + + // Add description/content + doc.setFontSize(11); + doc.setFont(undefined, "normal"); + const contentLines = doc.splitTextToSize(content, 180); + doc.text(contentLines, 15, yPosition); + yPosition += contentLines.length * 5 + 15; + + // Capture chart if chartElementId is provided + if (chartElementId) { + try { + const chartElement = document.getElementById(chartElementId); + if (chartElement) { + // Create a canvas from the chart element + const canvas = await html2canvas(chartElement, { + backgroundColor: "#ffffff", + scale: 2, + logging: false, + }); + + // Convert canvas to image + const imgData = canvas.toDataURL("image/png"); + const imgWidth = 180; + const imgHeight = (canvas.height * imgWidth) / canvas.width; + + // Add image to PDF + doc.addImage(imgData, "PNG", 15, yPosition, imgWidth, imgHeight); + yPosition += imgHeight + 10; + } + } catch (error) { + console.error("Error capturing chart:", error); + doc.text("(Erro ao capturar gráfico)", 15, yPosition); + yPosition += 10; + } + } + doc.save(`${title.toLowerCase().replace(/ /g, "-")}.pdf`); } @@ -203,7 +245,7 @@ export default function RelatoriosPage() { size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors w-full md:w-auto" - onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.")} + onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.", "chart-consultas")} > Exportar PDF @@ -211,15 +253,17 @@ export default function RelatoriosPage() { {loading ? (
Carregando dados...
) : ( - - - - - - - - - +
+ + + + + + + + + +
)}
@@ -229,9 +273,10 @@ export default function RelatoriosPage() {

Pacientes Mais Atendidos

- +
- +
+
@@ -257,15 +302,17 @@ export default function RelatoriosPage() { )}
Paciente
+
{/* Médicos mais produtivos */}

Médicos Mais Produtivos

- +
- +
+
@@ -291,6 +338,7 @@ export default function RelatoriosPage() { )}
Médico
+
diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx index 2a52829..e2bc619 100644 --- a/susconecta/app/(main-routes)/doutores/page.tsx +++ b/susconecta/app/(main-routes)/doutores/page.tsx @@ -131,6 +131,7 @@ export default function DoutoresPage() { const [availabilityOpenFor, setAvailabilityOpenFor] = useState(null); const [availabilityViewingFor, setAvailabilityViewingFor] = useState(null); const [availabilities, setAvailabilities] = useState([]); + const [availabilitiesForCreate, setAvailabilitiesForCreate] = useState([]); const [availLoading, setAvailLoading] = useState(false); const [editingAvailability, setEditingAvailability] = useState(null); const [exceptions, setExceptions] = useState([]); @@ -633,7 +634,17 @@ export default function DoutoresPage() { Ver pacientes atribuídos - setAvailabilityOpenFor(doctor)}> + { + try { + const list = await listarDisponibilidades({ doctorId: doctor.id, active: true }); + setAvailabilitiesForCreate(list || []); + setAvailabilityOpenFor(doctor); + } catch (e) { + console.warn('Erro ao carregar disponibilidades:', e); + setAvailabilitiesForCreate([]); + setAvailabilityOpenFor(doctor); + } + }}> Criar disponibilidade @@ -833,27 +844,27 @@ export default function DoutoresPage() {
-
- - {viewingDoctor?.full_name} +
+ + {viewingDoctor?.full_name}
-
- - +
+ + {viewingDoctor?.especialidade}
-
- - {viewingDoctor?.crm} +
+ + {viewingDoctor?.crm}
-
- - {viewingDoctor?.email} +
+ + {viewingDoctor?.email}
-
- - {viewingDoctor?.telefone} +
+ + {viewingDoctor?.telefone}
@@ -869,6 +880,7 @@ export default function DoutoresPage() { open={!!availabilityOpenFor} onOpenChange={(open) => { if (!open) setAvailabilityOpenFor(null); }} doctorId={availabilityOpenFor?.id} + existingAvailabilities={availabilitiesForCreate} onSaved={(saved) => { console.log('Disponibilidade salva', saved); setAvailabilityOpenFor(null); /* optionally reload list */ reloadAvailabilities(availabilityOpenFor?.id); }} /> )} @@ -890,6 +902,7 @@ export default function DoutoresPage() { doctorId={editingAvailability?.doctor_id ?? availabilityViewingFor?.id} availability={editingAvailability} mode="edit" + existingAvailabilities={availabilities} onSaved={(saved) => { console.log('Disponibilidade atualizada', saved); setEditingAvailability(null); reloadAvailabilities(editingAvailability?.doctor_id ?? availabilityViewingFor?.id); }} /> )} @@ -910,14 +923,35 @@ export default function DoutoresPage() {
Carregando disponibilidades…
) : availabilities && availabilities.length ? (
- {availabilities.map((a) => ( + {availabilities + .sort((a, b) => { + // Define a ordem dos dias da semana (Segunda a Domingo) + const weekdayOrder: Record = { + 'segunda': 1, 'segunda-feira': 1, 'mon': 1, 'monday': 1, '1': 1, + 'terca': 2, 'terça': 2, 'terça-feira': 2, 'tue': 2, 'tuesday': 2, '2': 2, + 'quarta': 3, 'quarta-feira': 3, 'wed': 3, 'wednesday': 3, '3': 3, + 'quinta': 4, 'quinta-feira': 4, 'thu': 4, 'thursday': 4, '4': 4, + 'sexta': 5, 'sexta-feira': 5, 'fri': 5, 'friday': 5, '5': 5, + 'sabado': 6, 'sábado': 6, 'sat': 6, 'saturday': 6, '6': 6, + 'domingo': 7, 'dom': 7, 'sun': 7, 'sunday': 7, '0': 7, '7': 7 + }; + + const getWeekdayOrder = (weekday: any) => { + if (typeof weekday === 'number') return weekday === 0 ? 7 : weekday; + const normalized = String(weekday).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); + return weekdayOrder[normalized] || 999; + }; + + return getWeekdayOrder(a.weekday) - getWeekdayOrder(b.weekday); + }) + .map((a) => (
{translateWeekday(a.weekday)} • {a.start_time} — {a.end_time}
Duração: {a.slot_minutes} min • Tipo: {a.appointment_type || '—'} • {a.active ? 'Ativa' : 'Inativa'}
- +
@@ -536,7 +594,7 @@ export default function LaudosEditorPage() { {/* Tabs */}
-
)} - {/* Imagens Tab */} - {activeTab === 'imagens' && ( -
-
- - -
- -
- {imagens.map((img) => ( -
- {img.type.startsWith('image/') ? ( - {img.name} - ) : ( -
- -
- )} -

{img.name}

- -
- ))} -
-
- )} - {/* Campos Tab */} {activeTab === 'campos' && (
@@ -958,14 +963,14 @@ export default function LaudosEditorPage() { setShowDraftConfirm(false); discardDraft(); }} - className="text-xs sm:text-sm h-9 sm:h-10 hover:bg-blue-50 dark:hover:bg-blue-950" + className="text-xs sm:text-sm h-9 sm:h-10 hover:bg-gray-100 dark:hover:bg-gray-800" > Descartar diff --git a/susconecta/app/laudos/[id]/editar/page.tsx b/susconecta/app/laudos/[id]/editar/page.tsx index e1e7d2f..bd6efc9 100644 --- a/susconecta/app/laudos/[id]/editar/page.tsx +++ b/susconecta/app/laudos/[id]/editar/page.tsx @@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { FileText, Settings, Eye, ArrowLeft, BookOpen } from 'lucide-react'; export default function EditarLaudoPage() { @@ -29,6 +30,7 @@ export default function EditarLaudoPage() { const [activeTab, setActiveTab] = useState('editor'); const [showPreview, setShowPreview] = useState(false); const [loading, setLoading] = useState(true); + const [showExitDialog, setShowExitDialog] = useState(false); // Campos do laudo const [campos, setCampos] = useState({ @@ -69,34 +71,45 @@ export default function EditarLaudoPage() { // Estado para rastrear alinhamento ativo const [activeAlignment, setActiveAlignment] = useState('left'); - // Salvar conteúdo no localStorage sempre que muda + // Salvar conteúdo no localStorage sempre que muda (com debounce) useEffect(() => { - if (content && laudoId) { - localStorage.setItem(`laudo-draft-${laudoId}`, content); - } - }, [content, laudoId]); - - // Sincronizar conteúdo com o editor - useEffect(() => { - if (editorRef.current && content) { - if (editorRef.current.innerHTML !== content) { - editorRef.current.innerHTML = content; + const timeoutId = setTimeout(() => { + if (laudoId) { + // Capturar conteúdo atual do editor antes de salvar + const currentContent = editorRef.current?.innerHTML || content; + + const draft = { + content: currentContent, + campos, + lastSaved: new Date().toISOString(), + }; + + localStorage.setItem(`laudo-draft-${laudoId}`, JSON.stringify(draft)); } - } - }, [content]); + }, 1000); // Aguarda 1 segundo após última mudança - // Restaurar conteúdo quando volta para a aba editor - useEffect(() => { - if (activeTab === 'editor' && editorRef.current && content) { - editorRef.current.focus(); - const range = document.createRange(); - const sel = window.getSelection(); - range.setStart(editorRef.current, editorRef.current.childNodes.length); - range.collapse(true); - sel?.removeAllRanges(); - sel?.addRange(range); + return () => clearTimeout(timeoutId); + }, [content, campos, laudoId]); + + // Função para trocar de aba salvando conteúdo antes + const handleTabChange = (newTab: string) => { + // Salvar conteúdo do editor antes de trocar + if (editorRef.current) { + const editorContent = editorRef.current.innerHTML; + setContent(editorContent); } - }, [activeTab]); + + // Se estiver voltando para o editor, restaurar conteúdo + if (newTab === 'editor') { + setTimeout(() => { + if (editorRef.current && content) { + editorRef.current.innerHTML = content; + } + }, 0); + } + + setActiveTab(newTab); + }; // Atualizar formatações ativas ao mudar seleção useEffect(() => { @@ -162,25 +175,49 @@ export default function EditarLaudoPage() { mostrarAssinatura: !r.hide_signature, }); - // Preencher conteúdo - const contentHtml = r.content_html || r.conteudo_html || ''; + // Preencher conteúdo - verificar todos os possíveis nomes de campo + const contentHtml = r.content_html || r.conteudo_html || r.contentHtml || r.conteudo || r.content || ''; + console.log('[EditarLaudoPage] Loading content - report:', r); + console.log('[EditarLaudoPage] Content fields check:', { + content_html: r.content_html, + conteudo_html: r.conteudo_html, + contentHtml: r.contentHtml, + conteudo: r.conteudo, + content: r.content, + finalContent: contentHtml + }); // Verificar se existe rascunho salvo no localStorage - const draftContent = typeof window !== 'undefined' ? localStorage.getItem(`laudo-draft-${laudoId}`) : null; - const finalContent = draftContent || contentHtml; + let finalContent = contentHtml; + let finalCampos = { + cid: r.cid_code || r.cid || '', + diagnostico: r.diagnosis || r.diagnostico || '', + conclusao: r.conclusion || r.conclusao || '', + exame: r.exam || r.exame || '', + especialidade: r.especialidade || '', + mostrarData: !r.hide_date, + mostrarAssinatura: !r.hide_signature, + }; - setContent(finalContent); - if (editorRef.current) { - editorRef.current.innerHTML = finalContent; - // Colocar cursor no final do texto - editorRef.current.focus(); - const range = document.createRange(); - const sel = window.getSelection(); - range.setStart(editorRef.current, editorRef.current.childNodes.length); - range.collapse(true); - sel?.removeAllRanges(); - sel?.addRange(range); + if (typeof window !== 'undefined') { + const draftData = localStorage.getItem(`laudo-draft-${laudoId}`); + if (draftData) { + try { + const draft = JSON.parse(draftData); + if (draft.content) finalContent = draft.content; + if (draft.campos) finalCampos = { ...finalCampos, ...draft.campos }; + } catch (err) { + // Se falhar parse, tentar como string simples (formato antigo) + finalContent = draftData; + } + } } + + setCampos(finalCampos); + setContent(finalContent); + console.log('[EditarLaudoPage] Setting content state with length:', finalContent.length); + + // O innerHTML será setado no useEffect separado abaixo } catch (err) { console.warn('Erro ao carregar laudo:', err); toast({ @@ -195,6 +232,14 @@ export default function EditarLaudoPage() { fetchLaudo(); }, [laudoId, token, toast]); + // UseEffect separado para injetar o conteúdo no editor quando estiver pronto + useEffect(() => { + if (content && editorRef.current && !loading) { + console.log('[EditarLaudoPage] Injecting content into editor, length:', content.length); + editorRef.current.innerHTML = content; + } + }, [content, loading]); + // Formatação com contenteditable const applyFormat = (command: string, value?: string) => { document.execCommand(command, false, value || undefined); @@ -332,7 +377,7 @@ export default function EditarLaudoPage() {
-
+ + {/* Dialog de confirmação de saída */} + + + + Salvar Rascunho? + + Você tem informações não salvas. Deseja salvar como rascunho para continuar depois? + + + + + + + + +
); diff --git a/susconecta/app/laudos/[id]/page.tsx b/susconecta/app/laudos/[id]/page.tsx index 5ed25d2..76162cc 100644 --- a/susconecta/app/laudos/[id]/page.tsx +++ b/susconecta/app/laudos/[id]/page.tsx @@ -5,7 +5,7 @@ import { useRouter, useParams } from 'next/navigation' import { useTheme } from 'next-themes' import Image from 'next/image' import { Button } from '@/components/ui/button' -import { ArrowLeft, Printer, Download, MoreVertical } from 'lucide-react' +import { ArrowLeft, Printer, Download } from 'lucide-react' import { buscarRelatorioPorId, getDoctorById, buscarMedicosPorIds, buscarPacientePorId } from '@/lib/api' import { ENV_CONFIG } from '@/lib/env-config' import ProtectedRoute from '@/components/shared/ProtectedRoute' @@ -355,18 +355,6 @@ export default function LaudoPage() { > -
diff --git a/susconecta/app/paciente/page.tsx b/susconecta/app/paciente/page.tsx index 22ff30e..f344784 100644 --- a/susconecta/app/paciente/page.tsx +++ b/susconecta/app/paciente/page.tsx @@ -1762,7 +1762,7 @@ export default function PacientePage() {
{/* Grid de 3 colunas (2 + 1) */} -
+
{/* Coluna Esquerda - Informações Pessoais */}
{/* Informações Pessoais */} @@ -1888,31 +1888,20 @@ export default function PacientePage() {

Foto do Perfil

- {isEditingProfile ? ( -
- handleProfileChange('foto_url', newUrl)} - userName={profileData.nome} - /> -
- ) : ( -
- - - - {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'} - - +
+ + + + {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'} + + -
-

- {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'} -

-
+
+

+ {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'} +

- )} +
@@ -1925,23 +1914,35 @@ export default function PacientePage() {
{/* Header com informações do paciente */} -
-
- +
+
+ {/* Logo MEDIConnect */} +
+
+ +
+ + MEDIConnect + +
+ +
+ + - {profileData.nome?.charAt(0) || 'P'} + {profileData.nome?.charAt(0) || 'P'} -
- Conta do paciente - {profileData.nome || 'Paciente'} - {profileData.email || 'Email não disponível'} +
+ Conta do paciente + {profileData.nome || 'Paciente'} + {profileData.email || 'Email não disponível'}
-
+
-
diff --git a/susconecta/app/paciente/resultados/ResultadosClient.tsx b/susconecta/app/paciente/resultados/ResultadosClient.tsx index d327823..68c4007 100644 --- a/susconecta/app/paciente/resultados/ResultadosClient.tsx +++ b/susconecta/app/paciente/resultados/ResultadosClient.tsx @@ -876,17 +876,6 @@ export default function ResultadosClient() {
- {/* Mais filtros / Voltar */} -
- -
- {/* Voltar */}
-
) @@ -1051,11 +1030,11 @@ export default function ResultadosClient() {
- - + + Página {currentPage} de {totalPages} - - + +
)} @@ -1186,8 +1165,17 @@ export default function ResultadosClient() {
- setMoreTimesDate(e.target.value)} /> - + { + setMoreTimesDate(e.target.value) + if (moreTimesForDoctor) { + void fetchSlotsForDate(moreTimesForDoctor, e.target.value) + } + }} + />
@@ -1196,12 +1184,14 @@ export default function ResultadosClient() { ) : moreTimesException ? (
{moreTimesException}
) : (moreTimesSlots.length ? ( -
- {moreTimesSlots.map(s => ( - - ))} +
+
+ {moreTimesSlots.map(s => ( + + ))} +
) : (
Sem horários para a data selecionada.
diff --git a/susconecta/app/profissional/page.tsx b/susconecta/app/profissional/page.tsx index 85e6a64..431d463 100644 --- a/susconecta/app/profissional/page.tsx +++ b/susconecta/app/profissional/page.tsx @@ -9,7 +9,7 @@ import { useAuth } from "@/hooks/useAuth"; import { useToast } from "@/hooks/use-toast"; import { useAvatarUrl } from "@/hooks/useAvatarUrl"; import { UploadAvatar } from '@/components/ui/upload-avatar'; -import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api"; +import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico, listarDisponibilidades, DoctorAvailability, deletarDisponibilidade, listarExcecoes, DoctorException, deletarExcecao } from "@/lib/api"; import { ENV_CONFIG } from '@/lib/env-config'; import { useReports } from "@/hooks/useReports"; import { CreateReportData } from "@/types/report-types"; @@ -19,6 +19,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"; +import AvailabilityForm from '@/components/features/forms/availability-form'; +import ExceptionForm from '@/components/features/forms/exception-form'; import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle"; import { Table, @@ -65,6 +67,29 @@ const colorsByType = { Oftalmologia: "#2ecc71" }; + // Função para traduzir dias da semana + function translateWeekday(w?: string) { + if (!w) return ''; + const key = w.toString().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, ''); + const map: Record = { + 'segunda': 'Segunda', + 'terca': 'Terça', + 'quarta': 'Quarta', + 'quinta': 'Quinta', + 'sexta': 'Sexta', + 'sabado': 'Sábado', + 'domingo': 'Domingo', + 'monday': 'Segunda', + 'tuesday': 'Terça', + 'wednesday': 'Quarta', + 'thursday': 'Quinta', + 'friday': 'Sexta', + 'saturday': 'Sábado', + 'sunday': 'Domingo', + }; + return map[key] ?? w; + } + // Helpers para normalizar dados de paciente (suporta schema antigo e novo) const getPatientName = (p: any) => p?.full_name ?? p?.nome ?? ''; const getPatientCpf = (p: any) => p?.cpf ?? ''; @@ -132,6 +157,17 @@ const ProfissionalPage = () => { const [isEditingProfile, setIsEditingProfile] = useState(false); const [doctorId, setDoctorId] = useState(null); + // Estados para disponibilidades e exceções do médico logado + const [availabilities, setAvailabilities] = useState([]); + const [exceptions, setExceptions] = useState([]); + const [availabilitiesForCreate, setAvailabilitiesForCreate] = useState([]); + const [availLoading, setAvailLoading] = useState(false); + const [exceptLoading, setExceptLoading] = useState(false); + const [editingAvailability, setEditingAvailability] = useState(null); + const [editingException, setEditingException] = useState(null); + const [showAvailabilityForm, setShowAvailabilityForm] = useState(false); + const [showExceptionForm, setShowExceptionForm] = useState(false); + // Hook para carregar automaticamente o avatar do médico const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(doctorId); // Removemos o placeholder extenso — inicializamos com valores minimalistas e vazios. @@ -286,6 +322,48 @@ const ProfissionalPage = () => { } }, [retrievedAvatarUrl]); + // Carregar disponibilidades e exceções do médico logado + const reloadAvailabilities = async (medId?: string) => { + const id = medId || doctorId; + if (!id) return; + try { + setAvailLoading(true); + const avails = await listarDisponibilidades({ doctorId: id, active: true }); + setAvailabilities(Array.isArray(avails) ? avails : []); + } catch (e) { + console.warn('[ProfissionalPage] Erro ao carregar disponibilidades:', e); + setAvailabilities([]); + } finally { + setAvailLoading(false); + } + }; + + const reloadExceptions = async (medId?: string) => { + const id = medId || doctorId; + if (!id) return; + try { + setExceptLoading(true); + console.log('[ProfissionalPage] Recarregando exceções para médico:', id); + const excepts = await listarExcecoes({ doctorId: id }); + console.log('[ProfissionalPage] Exceções carregadas:', excepts); + setExceptions(Array.isArray(excepts) ? excepts : []); + } catch (e) { + console.warn('[ProfissionalPage] Erro ao carregar exceções:', e); + setExceptions([]); + } finally { + setExceptLoading(false); + } + }; + + // Carrega disponibilidades quando doctorId muda + useEffect(() => { + if (doctorId) { + reloadAvailabilities(doctorId); + reloadExceptions(doctorId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [doctorId]); + // Estados para campos principais da consulta const [consultaAtual, setConsultaAtual] = useState({ @@ -1216,14 +1294,56 @@ const ProfissionalPage = () => { // helper to load laudos for the patients assigned to the logged-in user const loadAssignedLaudos = async () => { try { + // Primeiro, tenta carregar laudos criados pelo próprio médico + console.log('[LaudoManager] Tentando carregar laudos criados pelo médico:', user?.id); + try { + const reportsMod = await import('@/lib/reports'); + const allMyReports = await loadReports(); + + if (Array.isArray(allMyReports) && allMyReports.length > 0) { + // Filtrar apenas os criados por mim + const createdByMe = allMyReports.filter((r: any) => { + const creator = ((r.created_by ?? r.executante ?? r.createdBy) || '').toString(); + return user?.id && creator && creator === user.id; + }); + + if (createdByMe.length > 0) { + console.log('[LaudoManager] Encontrados', createdByMe.length, 'laudos criados pelo médico'); + const enriched = await (async (reportsArr: any[]) => { + if (!reportsArr || !reportsArr.length) return reportsArr; + const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean); + if (!pids.length) return reportsArr; + try { + const patients = await buscarPacientesPorIds(pids); + const map = new Map((patients || []).map((p: any) => [String(p.id), p])); + return reportsArr.map((r: any) => { + const pid = String(getReportPatientId(r)); + return { ...r, paciente: r.paciente ?? map.get(pid) ?? r.paciente } as any; + }); + } catch (e) { + console.warn('[LaudoManager] Erro ao enriquecer pacientes:', e); + return reportsArr; + } + })(createdByMe); + setLaudos(enriched || []); + return; + } + } + } catch (e) { + console.warn('[LaudoManager] erro ao carregar laudos criados pelo médico:', e); + } + + // Fallback: carregar laudos de pacientes atribuídos const assignments = await import('@/lib/assignment').then(m => m.listAssignmentsForUser(user?.id || '')); const patientIds = Array.isArray(assignments) ? assignments.map(a => String(a.patient_id)).filter(Boolean) : []; if (patientIds.length === 0) { + console.log('[LaudoManager] Nenhum paciente atribuído, laudos vazios'); setLaudos([]); return; } + console.log('[LaudoManager] Carregando laudos de', patientIds.length, 'pacientes atribuídos'); try { const reportsMod = await import('@/lib/reports'); if (typeof reportsMod.listarRelatoriosPorPacientes === 'function') { @@ -1315,7 +1435,7 @@ const ProfissionalPage = () => { return; } } catch (e) { - console.warn('[LaudoManager] erro ao carregar laudos para pacientes atribuídos:', e); + console.warn('[LaudoManager] erro ao carregar laudos:', e); setLaudos(reports || []); } }; @@ -1396,13 +1516,13 @@ const ProfissionalPage = () => { {/* Filtros */}
-
+
{/* Search input integrado com busca por ID */}
-
+
{ setStartDate(e.target.value); setSelectedRange('custom'); }} className="p-1 text-sm h-10" /> @@ -1411,7 +1531,7 @@ const ProfissionalPage = () => {
-
+
{/* date range buttons: Semana / Mês */}
@@ -1788,7 +1908,15 @@ const ProfissionalPage = () => { function LaudoEditor({ pacientes, laudo, onClose, isNewLaudo, preSelectedPatient, createNewReport, updateExistingReport, reloadReports, onSaved }: { pacientes?: any[]; laudo?: any; onClose: () => void; isNewLaudo?: boolean; preSelectedPatient?: any; createNewReport?: (data: any) => Promise; updateExistingReport?: (id: string, data: any) => Promise; reloadReports?: () => Promise; onSaved?: (r:any) => void }) { const { toast } = useToast(); const [activeTab, setActiveTab] = useState("editor"); - const [content, setContent] = useState(laudo?.conteudo || ""); + // Initialize content checking all possible field names + const initialContent = laudo?.conteudo ?? laudo?.content_html ?? laudo?.contentHtml ?? laudo?.content ?? ""; + console.log('[LaudoEditor] Initializing content - laudo:', laudo, 'initialContent length:', initialContent?.length, 'fields:', { + conteudo: laudo?.conteudo, + content_html: laudo?.content_html, + contentHtml: laudo?.contentHtml, + content: laudo?.content + }); + const [content, setContent] = useState(initialContent); const [showPreview, setShowPreview] = useState(false); const [pacienteSelecionado, setPacienteSelecionado] = useState(preSelectedPatient || null); const [listaPacientes, setListaPacientes] = useState([]); @@ -1875,8 +2003,10 @@ const ProfissionalPage = () => { // Carregar dados do laudo existente quando disponível (mais robusto: suporta vários nomes de campo) useEffect(() => { if (laudo && !isNewLaudo) { + console.log('[LaudoEditor useEffect] Loading existing laudo data:', laudo); // Conteúdo: aceita 'conteudo', 'content_html', 'contentHtml', 'content' const contentValue = laudo.conteudo ?? laudo.content_html ?? laudo.contentHtml ?? laudo.content ?? ""; + console.log('[LaudoEditor useEffect] Content value length:', contentValue?.length, 'Setting content...'); setContent(contentValue); // Campos: use vários fallbacks @@ -2181,32 +2311,6 @@ const ProfissionalPage = () => { Editor -
)} - {activeTab === "imagens" && ( -
-
- - -
- -
- {imagens.map((img) => ( -
- {img.type.startsWith('image/') ? ( - // eslint-disable-next-line @next/next/no-img-element - {img.name} - ) : ( -
- -
- )} -

{img.name}

- -
- ))} -
-
- )} - {activeTab === "campos" && (
@@ -2746,7 +2806,178 @@ const ProfissionalPage = () => {
); - + const renderDisponibilidadesSection = () => { + // Filtrar apenas a primeira disponibilidade de cada dia da semana + const availabilityByDay = new Map(); + (availabilities || []).forEach((a) => { + const day = String(a.weekday ?? '').toLowerCase(); + if (!availabilityByDay.has(day)) { + availabilityByDay.set(day, a); + } + }); + let filteredAvailabilities = Array.from(availabilityByDay.values()); + + // Ordenar por dia da semana (Segunda a Domingo) + filteredAvailabilities = filteredAvailabilities.sort((a, b) => { + const weekdayOrder: Record = { + 'segunda': 1, 'segunda-feira': 1, 'mon': 1, 'monday': 1, '1': 1, + 'terca': 2, 'terça': 2, 'terça-feira': 2, 'tue': 2, 'tuesday': 2, '2': 2, + 'quarta': 3, 'quarta-feira': 3, 'wed': 3, 'wednesday': 3, '3': 3, + 'quinta': 4, 'quinta-feira': 4, 'thu': 4, 'thursday': 4, '4': 4, + 'sexta': 5, 'sexta-feira': 5, 'fri': 5, 'friday': 5, '5': 5, + 'sabado': 6, 'sábado': 6, 'sat': 6, 'saturday': 6, '6': 6, + 'domingo': 7, 'dom': 7, 'sun': 7, 'sunday': 7, '0': 7, '7': 7 + }; + + const getWeekdayOrder = (weekday: any) => { + if (typeof weekday === 'number') return weekday === 0 ? 7 : weekday; + const normalized = String(weekday).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); + return weekdayOrder[normalized] || 999; + }; + + return getWeekdayOrder(a.weekday) - getWeekdayOrder(b.weekday); + }); + + // Filtrar apenas a primeira exceção de cada data + const exceptionByDate = new Map(); + (exceptions || []).forEach((ex) => { + // Alguns backends/versões usam nomes diferentes para a data da exceção. + // Fazemos cast para any ao verificar campos legados para satisfazer o tipo DoctorException. + const date = String(((ex as any).exception_date) ?? ((ex as any).exceptionDate) ?? ex.date ?? ''); + if (!exceptionByDate.has(date)) { + exceptionByDate.set(date, ex); + } + }); + const filteredExceptions = Array.from(exceptionByDate.values()); + + return ( +
+
+

Minhas Disponibilidades

+
+ +
+
+ + {/* Disponibilidades */} + {availLoading ? ( +
Carregando disponibilidades…
+ ) : filteredAvailabilities && filteredAvailabilities.length > 0 ? ( +
+ {filteredAvailabilities.map((a) => ( +
+
+
{translateWeekday(a.weekday)} • {a.start_time} — {a.end_time}
+
Duração: {a.slot_minutes} min • Tipo: {a.appointment_type || '—'} • {a.active ? 'Ativa' : 'Inativa'}
+
+
+ + +
+
+ ))} +
+ ) : ( +
+ Nenhuma disponibilidade cadastrada. +
+ )} + + {/* Exceções */} +
+

Exceções (Bloqueios/Liberações)

+ {exceptLoading ? ( +
Carregando exceções…
+ ) : filteredExceptions && filteredExceptions.length > 0 ? ( +
+ {filteredExceptions.map((ex) => ( +
+
+
+ {(() => { + try { + // Normaliza possíveis nomes de campo (exception_date, exceptionDate, date) e formata com fallback + const dateRaw = (ex as any).exception_date ?? (ex as any).exceptionDate ?? ex.date ?? ''; + const parts = String(dateRaw).split('-'); + if (parts.length >= 3) { + const [y, m, d] = parts; + return `${d}/${m}/${y}`; + } + // fallback: tentar parse ISO/locale + const dt = new Date(String(dateRaw)); + if (!isNaN(dt.getTime())) { + return `${String(dt.getDate()).padStart(2, '0')}/${String(dt.getMonth() + 1).padStart(2, '0')}/${dt.getFullYear()}`; + } + return String(dateRaw); + } catch (e) { + return ((ex as any).exception_date ?? (ex as any).exceptionDate ?? ex.date) as any; + } + })()} +
+
+ Tipo: {(ex as any).kind || 'bloqueio'} • Motivo: {(ex as any).reason || '—'} +
+
+
+ {/* Sem ações para exceções */} +
+
+ ))} +
+ ) : ( +
+ Nenhuma exceção cadastrada. +
+ )} +
+
+ ); + }; + const renderPerfilSection = () => (
{/* Header com Título e Botão */} @@ -2946,42 +3177,21 @@ const ProfissionalPage = () => {

Foto do Perfil

- {isEditingProfile ? ( - { - try { - setProfileData((prev) => ({ ...prev, fotoUrl: newUrl })); - // Foto foi salva no Supabase Storage - atualizar apenas o estado local - // Para persistir no banco, o usuário deve clicar em "Salvar" após isso - try { toast({ title: 'Foto enviada', description: 'Clique em "Salvar" para confirmar as alterações.', variant: 'default' }); } catch (e) { /* ignore toast errors */ } - } catch (err) { - console.error('[ProfissionalPage] erro ao processar upload de foto:', err); - try { toast({ title: 'Erro ao processar foto', description: (err as any)?.message || 'Falha ao processar a foto do perfil.', variant: 'destructive' }); } catch (e) {} - } - }} - /> - ) : ( - <> - - {(profileData as any).fotoUrl ? ( - - ) : ( - - {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'} - - )} - + + {(profileData as any).fotoUrl ? ( + + ) : ( + + {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'} + + )} + -
-

- {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'} -

-
- - )} +
+

+ {profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'} +

+
@@ -3022,6 +3232,8 @@ const ProfissionalPage = () => { ); case 'laudos': return renderLaudosSection(); + case 'disponibilidades': + return renderDisponibilidadesSection(); case 'comunicacao': return renderComunicacaoSection(); case 'perfil': @@ -3042,6 +3254,18 @@ const ProfissionalPage = () => {
{/* Logo/Avatar Section */}
+ {/* Logo MEDIConnect */} +
+
+ +
+ + MEDIConnect + +
+ +
+ @@ -3146,6 +3370,17 @@ const ProfissionalPage = () => { Laudos +
- {} + {/* AvailabilityForm para criar/editar disponibilidades */} + {showAvailabilityForm && ( + { + if (!open) { + setShowAvailabilityForm(false); + setEditingAvailability(null); + setAvailabilitiesForCreate([]); + } + }} + doctorId={editingAvailability?.doctor_id ?? doctorId} + availability={editingAvailability} + existingAvailabilities={availabilitiesForCreate} + mode={editingAvailability ? "edit" : "create"} + onSaved={(saved) => { + console.log('Disponibilidade salva', saved); + setEditingAvailability(null); + setShowAvailabilityForm(false); + setAvailabilitiesForCreate([]); + reloadAvailabilities(); + }} + /> + )} + + {/* Popup antigo (manter para compatibilidade) */} {showPopup && (
diff --git a/susconecta/components/features/dashboard/header.tsx b/susconecta/components/features/dashboard/header.tsx index 8112742..b568055 100644 --- a/susconecta/components/features/dashboard/header.tsx +++ b/susconecta/components/features/dashboard/header.tsx @@ -79,7 +79,7 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub {dropdownOpen && ( -
+

diff --git a/susconecta/components/features/forms/availability-form.tsx b/susconecta/components/features/forms/availability-form.tsx index 5b140f2..e449b86 100644 --- a/susconecta/components/features/forms/availability-form.tsx +++ b/susconecta/components/features/forms/availability-form.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogFooter, AlertDialogAction, AlertDialogCancel } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' @@ -18,9 +18,11 @@ export interface AvailabilityFormProps { // when editing, pass the existing availability and set mode to 'edit' availability?: DoctorAvailability | null mode?: 'create' | 'edit' + // existing availabilities to prevent duplicate weekday selection + existingAvailabilities?: DoctorAvailability[] } -export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, availability = null, mode = 'create' }: AvailabilityFormProps) { +export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, availability = null, mode = 'create', existingAvailabilities = [] }: AvailabilityFormProps) { const [weekday, setWeekday] = useState('segunda') const [startTime, setStartTime] = useState('09:00') const [endTime, setEndTime] = useState('17:00') @@ -31,6 +33,28 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, const { toast } = useToast() const [blockedException, setBlockedException] = useState(null) + // Normalize weekday to standard format for comparison + const normalizeWeekdayForComparison = (w?: string) => { + if (!w) return w; + const k = String(w).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, ''); + const map: Record = { + 'segunda':'segunda','terca':'terca','quarta':'quarta','quinta':'quinta','sexta':'sexta','sabado':'sabado','domingo':'domingo', + 'monday':'segunda','tuesday':'terca','wednesday':'quarta','thursday':'quinta','friday':'sexta','saturday':'sabado','sunday':'domingo', + '1':'segunda','2':'terca','3':'quarta','4':'quinta','5':'sexta','6':'sabado','0':'domingo','7':'domingo' + }; + return map[k] ?? k; + }; + + // Get list of already used weekdays (excluding current one in edit mode) + const usedWeekdays = useMemo(() => { + return new Set( + (existingAvailabilities || []) + .filter(a => mode === 'edit' ? a.id !== availability?.id : true) + .map(a => normalizeWeekdayForComparison(a.weekday)) + .filter(Boolean) + ); + }, [existingAvailabilities, mode, availability?.id]); + // When editing, populate state from availability prop useEffect(() => { if (mode === 'edit' && availability) { @@ -47,6 +71,17 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, } }, [mode, availability]) + // When creating and modal opens, set the first available weekday + useEffect(() => { + if (mode === 'create' && open) { + const allWeekdays = ['segunda', 'terca', 'quarta', 'quinta', 'sexta', 'sabado', 'domingo']; + const firstAvailable = allWeekdays.find(day => !usedWeekdays.has(day)); + if (firstAvailable) { + setWeekday(firstAvailable); + } + } + }, [mode, open, usedWeekdays]) + async function handleSubmit(e?: React.FormEvent) { e?.preventDefault() if (!doctorId) { @@ -181,25 +216,25 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved,

- Criar disponibilidade + {mode === 'edit' ? 'Editar disponibilidade' : 'Criar disponibilidade'}
- setWeekday(v)} disabled={mode === 'edit'}> - Segunda - Terça - Quarta - Quinta - Sexta - Sábado - Domingo + Segunda + Terça + Quarta + Quinta + Sexta + Sábado + Domingo
@@ -242,7 +277,7 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, - + diff --git a/susconecta/components/features/forms/calendar-registration-form.tsx b/susconecta/components/features/forms/calendar-registration-form.tsx index afc2aea..ba3089c 100644 --- a/susconecta/components/features/forms/calendar-registration-form.tsx +++ b/susconecta/components/features/forms/calendar-registration-form.tsx @@ -414,35 +414,31 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = } catch (e) {} const generatedSet = new Set(); + + // Helper to create ISO-like string without timezone conversion + const toLocalISOString = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; + }; + windows.forEach((w: any) => { try { const perWindowStep = Number(w.slotMinutes) || stepMinutes; const startMs = w.winStart.getTime(); const endMs = w.winEnd.getTime(); const lastStartMs = endMs - perWindowStep * 60000; - const backendSlotsInWindow = (av.slots || []).filter((s: any) => { - try { - const sd = new Date(s.datetime); - const sm = sd.getHours() * 60 + sd.getMinutes(); - const wmStart = w.winStart.getHours() * 60 + w.winStart.getMinutes(); - const wmEnd = w.winEnd.getHours() * 60 + w.winEnd.getMinutes(); - return sm >= wmStart && sm <= wmEnd; - } catch (e) { return false; } - }).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b); - - if (!backendSlotsInWindow.length) { - let cursorMs = startMs; - while (cursorMs <= lastStartMs) { - generatedSet.add(new Date(cursorMs).toISOString()); - cursorMs += perWindowStep * 60000; - } - } else { - const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1]; - let cursorMs = lastBackendMs + perWindowStep * 60000; - while (cursorMs <= lastStartMs) { - generatedSet.add(new Date(cursorMs).toISOString()); - cursorMs += perWindowStep * 60000; - } + + // Always generate slots from the start of the window to the end + // This ensures slots start at the configured availability start time + let cursorMs = startMs; + while (cursorMs <= lastStartMs) { + generatedSet.add(toLocalISOString(new Date(cursorMs))); + cursorMs += perWindowStep * 60000; } } catch (e) {} }); @@ -463,15 +459,10 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = } catch (e) { return null; } }; - (existingInWindow || []).forEach((s: any) => { - const sm = findWindowSlotMinutes(s.datetime); - mergedMap.set(s.datetime, sm ? { ...s, slot_minutes: sm } : { ...s }); - }); + // Use only generated slots based on availability windows Array.from(generatedSet).forEach((dt) => { - if (!mergedMap.has(dt)) { - const sm = findWindowSlotMinutes(dt) || stepMinutes; - mergedMap.set(dt, { datetime: dt, available: true, slot_minutes: sm }); - } + const sm = findWindowSlotMinutes(dt) || stepMinutes; + mergedMap.set(dt, { datetime: dt, available: true, slot_minutes: sm }); }); const merged = Array.from(mergedMap.values()).sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime()); @@ -869,22 +860,39 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
{loadingPatient ? ( -
Carregando dados do paciente...
+
Carregando dados do paciente...
) : patientDetails ? ( patientDetails.error ? (
Erro ao carregar paciente: {String(patientDetails.error)}
) : ( -
-
CPF: {patientDetails.cpf || '-'}
-
Telefone: {patientDetails.phone_mobile || patientDetails.telefone || '-'}
-
E-mail: {patientDetails.email || '-'}
-
Data de nascimento: {patientDetails.birth_date || '-'}
+
+
+ CPF: + {patientDetails.cpf || '-'} +
+
+ Telefone: + {patientDetails.phone_mobile || patientDetails.telefone || '-'} +
+
+ E-mail: + {patientDetails.email || '-'} +
+
+ Data de nascimento: + + {patientDetails.birth_date + ? new Date(patientDetails.birth_date + 'T00:00:00').toLocaleDateString('pt-BR') + : '-' + } + +
) ) : (
Paciente não vinculado
)} -
Para editar os dados do paciente, acesse a ficha do paciente.
+
Para editar os dados do paciente, acesse a ficha do paciente.
@@ -1033,7 +1041,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = const d = new Date(s.datetime); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); - const dateOnly = d.toISOString().split('T')[0]; + // Use local date components instead of toISOString to avoid timezone conversion + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const dateOnly = `${year}-${month}-${day}`; return dateOnly === date && `${hh}:${mm}` === time; } catch (e) { return false; @@ -1054,7 +1066,8 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = } const hh = String(dt.getHours()).padStart(2, '0'); const mm = String(dt.getMinutes()).padStart(2, '0'); - const dateOnly = dt.toISOString().split('T')[0]; + // Keep the existing appointmentDate, don't override it + const currentDate = (formData as any).appointmentDate; // set duration from slot if available const sel = (availableSlots || []).find((s) => s.datetime === value) as any; const slotMinutes = sel && sel.slot_minutes ? Number(sel.slot_minutes) : null; @@ -1065,11 +1078,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = const endM = String(endDt.getMinutes()).padStart(2, '0'); const endStr = `${endH}:${endM}`; if (slotMinutes) { - onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: slotMinutes, endTime: endStr }); + onFormChange({ ...formData, appointmentDate: currentDate, startTime: `${hh}:${mm}`, duration_minutes: slotMinutes, endTime: endStr }); try { setLockedDurationFromSlot(true); } catch (e) {} try { (lastAutoEndRef as any).current = endStr; } catch (e) {} } else { - onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, endTime: endStr }); + onFormChange({ ...formData, appointmentDate: currentDate, startTime: `${hh}:${mm}`, endTime: endStr }); try { (lastAutoEndRef as any).current = endStr; } catch (e) {} } } catch (e) { @@ -1171,9 +1184,8 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = type="button" className={`h-10 rounded-md border ${formData.startTime === `${hh}:${mm}` ? 'bg-blue-600 text-white' : 'bg-background'}`} onClick={() => { - // when selecting a slot, set appointmentDate (if missing) and startTime and duration - const isoDate = dt.toISOString(); - const dateOnly = isoDate.split('T')[0]; + // when selecting a slot, keep the existing appointmentDate and only update time + const currentDate = (formData as any).appointmentDate; const slotMinutes = s.slot_minutes || null; // compute endTime based on duration const durationForCalc = slotMinutes || (formData as any).duration_minutes || 0; @@ -1182,11 +1194,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = const endM = String(endDt.getMinutes()).padStart(2, '0'); const endStr = `${endH}:${endM}`; if (slotMinutes) { - onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: Number(slotMinutes), endTime: endStr }); + onFormChange({ ...formData, appointmentDate: currentDate, startTime: `${hh}:${mm}`, duration_minutes: Number(slotMinutes), endTime: endStr }); try { setLockedDurationFromSlot(true); } catch (e) {} try { (lastAutoEndRef as any).current = endStr; } catch (e) {} } else { - onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, endTime: endStr }); + onFormChange({ ...formData, appointmentDate: currentDate, startTime: `${hh}:${mm}`, endTime: endStr }); try { (lastAutoEndRef as any).current = endStr; } catch (e) {} } }} diff --git a/susconecta/components/features/forms/doctor-registration-form.tsx b/susconecta/components/features/forms/doctor-registration-form.tsx index 2183868..098b8df 100644 --- a/susconecta/components/features/forms/doctor-registration-form.tsx +++ b/susconecta/components/features/forms/doctor-registration-form.tsx @@ -821,7 +821,7 @@ async function handleSubmit(ev: React.FormEvent) { +
+
+ { + try { + const [y, m, d] = String(date).split('-'); + return `${d}/${m}/${y}`; + } catch (e) { + return ''; + } + })() : ''} + readOnly + /> + {showDatePicker && ( +
+ { + try { + // Parse como local date para compatibilidade com Calendar + const [y, m, d] = String(date).split('-').map(Number); + return new Date(y, m - 1, d); + } catch (e) { + return undefined; + } + })() : undefined} + onSelect={(selectedDate) => { + if (selectedDate) { + // Extrair data como local para evitar problemas de timezone + const y = selectedDate.getFullYear(); + const m = String(selectedDate.getMonth() + 1).padStart(2, '0'); + const d = String(selectedDate.getDate()).padStart(2, '0'); + const dateStr = `${y}-${m}-${d}`; + console.log('[ExceptionForm] Data selecionada:', dateStr, 'de', selectedDate); + setDate(dateStr); + setShowDatePicker(false); + } + }} + disabled={(checkDate) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return checkDate < today; + }} + /> +
+ )} +
@@ -102,7 +176,7 @@ export default function ExceptionForm({ open, onOpenChange, doctorId = null, onS
- + diff --git a/susconecta/components/features/forms/patient-registration-form.tsx b/susconecta/components/features/forms/patient-registration-form.tsx index b3b2f21..1638c38 100644 --- a/susconecta/components/features/forms/patient-registration-form.tsx +++ b/susconecta/components/features/forms/patient-registration-form.tsx @@ -453,7 +453,7 @@ export function PatientRegistrationForm({ -
@@ -384,37 +384,37 @@ export function EventManager({ {/* Desktop: Button group */}
{currentAvatarUrl && ( @@ -101,10 +103,10 @@ export function UploadAvatar({ userId, currentAvatarUrl, onAvatarChange, userNam variant="outline" size="sm" onClick={handleDownload} - className="transition duration-200 hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground" + className="transition duration-200 hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 dark:hover:text-white text-xs sm:text-sm" > - - Download + + Download )}
@@ -118,8 +120,8 @@ export function UploadAvatar({ userId, currentAvatarUrl, onAvatarChange, userNam disabled={isUploading} /> -

- Formatos aceitos: JPG, PNG, WebP (máx. 2MB) +

+ Formatos: JPG, PNG, WebP (máx. 2MB)

{error && ( diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index 7846058..349c5f6 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -488,11 +488,10 @@ export async function deletarDisponibilidade(id: string): Promise { headers: withPrefer({ ...baseHeaders() }, 'return=minimal'), }); - if (res.status === 204) return; - // Some deployments may return 200 with a representation — accept that too - if (res.status === 200) return; - // Otherwise surface a friendly error using parse() - await parse(res as Response); + if (res.status === 204 || res.status === 200) return; + + // Se chegou aqui e não foi sucesso, lance erro + throw new Error(`Erro ao deletar disponibilidade: ${res.status}`); } // ===== EXCEÇÕES (Doctor Exceptions) ===== @@ -580,14 +579,21 @@ export async function listarExcecoes(params?: { doctorId?: string; date?: string export async function deletarExcecao(id: string): Promise { if (!id) throw new Error('ID da exceção é obrigatório'); const url = `${REST}/doctor_exceptions?id=eq.${encodeURIComponent(String(id))}`; + console.log('[deletarExcecao] Deletando exceção:', id, 'URL:', url); const res = await fetch(url, { method: 'DELETE', headers: withPrefer({ ...baseHeaders() }, 'return=minimal'), }); - if (res.status === 204) return; - if (res.status === 200) return; - await parse(res as Response); + console.log('[deletarExcecao] Status da resposta:', res.status); + + if (res.status === 204 || res.status === 200) { + console.log('[deletarExcecao] Exceção deletada com sucesso'); + return; + } + + // Se chegou aqui e não foi sucesso, lance erro + throw new Error(`Erro ao deletar exceção: ${res.status}`); }