Compare commits

...

2 Commits

Author SHA1 Message Date
bcd5ce9bac fix(resultados/profissional): type callbacks and
mute warnings from dynamic images
2025-11-05 21:31:00 -03:00
d4cb5f98e0 refactor(structure): Organizes the structure of the app and components folders
- Reorganizes the components folder into ui, layout, features, shared, and providers for better modularity.
- Groups routes in the app folder using a route group (auth).
- Updates all imports to reflect the new file structure.
2025-11-05 18:06:13 -03:00
32 changed files with 182 additions and 139 deletions

View File

@ -6,7 +6,7 @@ import dynamic from "next/dynamic";
import Link from "next/link";
// --- Imports do EventManager (NOVO) - MANTIDOS ---
import { EventManager, type Event } from "@/components/event-manager";
import { EventManager, type Event } from "@/components/features/general/event-manager";
import { v4 as uuidv4 } from 'uuid'; // Usado para IDs de fallback
// Imports mantidos
@ -193,7 +193,7 @@ export default function AgendamentoPage() {
<div className="flex flex-row">
<Button
variant={"outline"}
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-l-[100px] rounded-r-[0px]"
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-l-[100px] rounded-r-none"
onClick={() => setActiveTab("calendar")}
>
Calendário
@ -209,7 +209,7 @@ export default function AgendamentoPage() {
<Button
variant={"outline"}
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-r-[100px] rounded-l-[0px]"
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-r-[100px] rounded-l-none"
onClick={() => setActiveTab("espera")}
>
Lista de espera

View File

@ -388,7 +388,7 @@ export default function DashboardPage() {
)}
{/* 11. LINK PARA RELATÓRIOS */}
<div className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 p-6 rounded-lg border border-blue-500/20">
<div className="bg-linear-to-r from-blue-500/10 to-purple-500/10 p-6 rounded-lg border border-blue-500/20">
<h2 className="text-lg font-semibold text-foreground mb-2">Seção de Relatórios</h2>
<p className="text-muted-foreground text-sm mb-4">
Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos.

View File

@ -303,8 +303,8 @@ export default function RelatoriosPage() {
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
{ label: "Absenteísmo", value: '—', icon: <UserCheck className="w-6 h-6 text-red-500" /> },
{ label: "Satisfação", value: 'Dados não foram disponibilizados', icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
{ label: "Faturamento (Mês)", value: `R$ ${faturamentoArr[faturamentoArr.length - 1]?.valor ?? 0}`, icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
{ label: "No-show", value: `${taxaArr[taxaArr.length - 1]?.noShow ?? 0}%`, icon: <User className="w-6 h-6 text-yellow-500" /> },
{ label: "Faturamento (Mês)", value: `R$ ${faturamentoArr.at(-1)?.valor ?? 0}`, icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
{ label: "No-show", value: `${taxaArr.at(-1)?.noShow ?? 0}%`, icon: <User className="w-6 h-6 text-yellow-500" /> },
] as any);
} catch (err: any) {

View File

@ -20,7 +20,7 @@ import { listAssignmentsForUser } from '@/lib/assignment';
function normalizeMedico(m: any): Medico {
const normalizeSex = (v: any) => {
if (v === null || typeof v === 'undefined') return null;
if (v === undefined) return null;
const s = String(v || '').trim().toLowerCase();
if (!s) return null;
const male = new Set(['m','masc','male','masculino','homem','h','1','mas']);

View File

@ -1,5 +1,5 @@
import type React from "react";
import ProtectedRoute from "@/components/ProtectedRoute";
import ProtectedRoute from "@/components/shared/ProtectedRoute";
import { Sidebar } from "@/components/layout/sidebar";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { PagesHeader } from "@/components/features/dashboard/header";

View File

@ -1,7 +1,7 @@
import type React from "react"
import type { Metadata } from "next"
import { AuthProvider } from "@/hooks/useAuth"
import { ThemeProvider } from "@/components/theme-provider"
import { ThemeProvider } from "@/components/providers/theme-provider"
import "./globals.css"
export const metadata: Metadata = {

View File

@ -12,10 +12,10 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { User, LogOut, Calendar, FileText, MessageCircle, UserCog, Home, Clock, FolderOpen, ChevronLeft, ChevronRight, MapPin, Stethoscope } from 'lucide-react'
import { SimpleThemeToggle } from '@/components/simple-theme-toggle'
import { SimpleThemeToggle } from '@/components/ui/simple-theme-toggle'
import { UploadAvatar } from '@/components/ui/upload-avatar'
import Link from 'next/link'
import ProtectedRoute from '@/components/ProtectedRoute'
import ProtectedRoute from '@/components/shared/ProtectedRoute'
import { useAuth } from '@/hooks/useAuth'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { buscarPacientes, buscarPacientePorUserId, getUserInfo, listarAgendamentos, buscarMedicosPorIds, buscarMedicos, atualizarPaciente, buscarPacientePorId, getDoctorById } from '@/lib/api'
@ -172,7 +172,6 @@ export default function PacientePage() {
loadProfile()
return () => { mounted = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.email])
// Load authoritative patient row for the logged-in user (prefer user_id lookup)
@ -414,7 +413,7 @@ export default function PacientePage() {
}
load()
return () => { mounted = false }
}, [patientId])
}, [])
return (
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
@ -622,7 +621,7 @@ export default function PacientePage() {
loadAppointments()
return () => { mounted = false }
}, [patientId])
}, [])
// Monta a URL de resultados com os filtros atuais
const buildResultadosHref = () => {
@ -632,7 +631,7 @@ export default function PacientePage() {
if (localizacao) qs.set('local', localizacao)
// indicate navigation origin so destination can alter UX (e.g., show modal instead of redirect)
qs.set('origin', 'paciente')
return `/resultados?${qs.toString()}`
return `/paciente/resultados?${qs.toString()}`
}
// derived lists for the page (computed after appointments state is declared)
@ -642,14 +641,14 @@ export default function PacientePage() {
return (
<div className="space-y-6">
{/* Hero Section */}
<section className="bg-gradient-to-br from-card to-card/95 shadow-lg rounded-2xl border border-primary/10 p-8">
<section className="bg-linear-to-br from-card to-card/95 shadow-lg rounded-2xl border border-primary/10 p-8">
<div className="max-w-3xl mx-auto space-y-8">
<header className="text-center space-y-4">
<h2 className="text-4xl font-bold text-foreground">Agende sua próxima consulta</h2>
<p className="text-lg text-muted-foreground leading-relaxed">Escolha o formato ideal, selecione a especialidade e encontre o profissional perfeito para você.</p>
</header>
<div className="space-y-6 rounded-2xl border border-primary/15 bg-gradient-to-r from-primary/5 to-primary/10 p-8 shadow-sm">
<div className="space-y-6 rounded-2xl border border-primary/15 bg-linear-to-r from-primary/5 to-primary/10 p-8 shadow-sm">
<div className="flex justify-center">
<Button asChild className="w-full md:w-auto px-10 py-3 bg-primary text-white hover:bg-primary/90! hover:text-white! transition-all duration-200 font-semibold text-base rounded-lg shadow-md hover:shadow-lg active:scale-95">
<Link href={buildResultadosHref()} prefetch={false}>
@ -670,7 +669,7 @@ export default function PacientePage() {
</header>
{/* Date Navigation */}
<div className="flex flex-col gap-4 rounded-2xl border border-primary/20 bg-gradient-to-r from-primary/5 to-primary/10 p-6 sm:flex-row sm:items-center sm:justify-between shadow-sm">
<div className="flex flex-col gap-4 rounded-2xl border border-primary/20 bg-linear-to-r from-primary/5 to-primary/10 p-6 sm:flex-row sm:items-center sm:justify-between shadow-sm">
<div className="flex items-center gap-2 sm:gap-3">
<Button
type="button"
@ -740,16 +739,16 @@ export default function PacientePage() {
{/* Doctor Info */}
<div className="flex items-start gap-4 min-w-0">
<span
className="mt-2 h-4 w-4 flex-shrink-0 rounded-full shadow-sm"
className="mt-2 h-4 w-4 shrink-0 rounded-full shadow-sm"
style={{ backgroundColor: consulta.status === 'Confirmada' ? '#10b981' : consulta.status === 'Pendente' ? '#f59e0b' : '#ef4444' }}
aria-hidden
/>
<div className="space-y-3 min-w-0">
<div className="font-bold flex items-center gap-2.5 text-foreground text-lg leading-tight">
<Stethoscope className="h-5 w-5 text-primary flex-shrink-0" />
<Stethoscope className="h-5 w-5 text-primary shrink-0" />
<span className="truncate">{consulta.medico}</span>
</div>
<p className="text-sm text-muted-foreground break-words leading-relaxed">
<p className="text-sm text-muted-foreground wrap-break-word leading-relaxed">
<span className="font-medium text-foreground/70">{consulta.especialidade}</span>
<span className="mx-1.5"></span>
<span>{consulta.local}</span>
@ -759,7 +758,7 @@ export default function PacientePage() {
{/* Time */}
<div className="flex items-center justify-start gap-2.5 text-foreground">
<Clock className="h-5 w-5 text-primary flex-shrink-0" />
<Clock className="h-5 w-5 text-primary shrink-0" />
<span className="font-bold text-lg">{consulta.hora}</span>
</div>
@ -767,10 +766,10 @@ export default function PacientePage() {
<div className="flex items-center justify-start">
<span className={`px-4 py-2.5 rounded-full text-xs font-bold text-white shadow-md transition-all ${
consulta.status === 'Confirmada'
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/20'
? 'bg-linear-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/20'
: consulta.status === 'Pendente'
? 'bg-gradient-to-r from-amber-500 to-amber-600 shadow-amber-500/20'
: 'bg-gradient-to-r from-red-500 to-red-600 shadow-red-500/20'
? 'bg-linear-to-r from-amber-500 to-amber-600 shadow-amber-500/20'
: 'bg-linear-to-r from-red-500 to-red-600 shadow-red-500/20'
}`}>
{consulta.status}
</span>
@ -884,6 +883,7 @@ export default function PacientePage() {
return false
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reports, searchTerm, doctorsMap, remoteMatch])
// When the search term looks like an id, attempt a direct fetch using the reports API
@ -1198,7 +1198,7 @@ export default function PacientePage() {
})()
return () => { mounted = false }
}, [patientId])
}, [])
// When a report is selected, try to fetch doctor name if we have an id
useEffect(() => {
@ -1255,7 +1255,7 @@ export default function PacientePage() {
}
})()
return () => { mounted = false }
}, [selectedReport])
}, [])
// reset pagination when reports change
useEffect(() => {

View File

@ -55,16 +55,17 @@ export default function ResultadosClient() {
const params = useSearchParams()
const router = useRouter()
// Filtros/controles da UI
const [tipoConsulta, setTipoConsulta] = useState<TipoConsulta>(
params?.get('tipo') === 'presencial' ? 'local' : 'teleconsulta'
)
const [especialidadeHero, setEspecialidadeHero] = useState<string>(params?.get('especialidade') || 'Psicólogo')
// Filtros/controles da UI - initialize with defaults to avoid hydration mismatch
const [tipoConsulta, setTipoConsulta] = useState<TipoConsulta>('teleconsulta')
const [especialidadeHero, setEspecialidadeHero] = useState<string>('Psicólogo')
const [convenio, setConvenio] = useState<string>('Todos')
const [bairro, setBairro] = useState<string>('Todos')
// Busca por nome do médico
const [searchQuery, setSearchQuery] = useState<string>('')
// Track if URL params have been synced to avoid race condition
const [paramsSync, setParamsSync] = useState(false)
// Estado dinâmico
const [patientId, setPatientId] = useState<string | null>(null)
const [medicos, setMedicos] = useState<Medico[]>([])
@ -107,7 +108,20 @@ export default function ResultadosClient() {
const [bookingSuccessOpen, setBookingSuccessOpen] = useState(false)
const [bookedWhenLabel, setBookedWhenLabel] = useState<string | null>(null)
// 1) Obter patientId a partir do usuário autenticado (email -> patients)
// 1) Sincronize URL params with state after client mount (prevent hydration mismatch)
useEffect(() => {
if (!params) return
const tipoParam = params.get('tipo')
if (tipoParam === 'presencial') setTipoConsulta('local')
const especialidadeParam = params.get('especialidade')
if (especialidadeParam) setEspecialidadeHero(especialidadeParam)
// Mark params as synced
setParamsSync(true)
}, [params])
// 2) Fetch patient ID from auth
useEffect(() => {
let mounted = true
;(async () => {
@ -127,10 +141,31 @@ export default function ResultadosClient() {
return () => { mounted = false }
}, [])
// 2) Buscar médicos conforme especialidade selecionada
// 3) Initial doctors fetch on mount (one-time initialization)
useEffect(() => {
// If the user is actively searching by name, this effect should not run
if (searchQuery && String(searchQuery).trim().length > 1) return
let mounted = true
;(async () => {
try {
setLoadingMedicos(true)
console.log('[ResultadosClient] Initial doctors fetch starting')
const list = await buscarMedicos('medico').catch((err) => {
console.error('[ResultadosClient] Initial fetch error:', err)
return []
})
if (!mounted) return
console.log('[ResultadosClient] Initial fetch completed, got:', list?.length || 0, 'doctors')
setMedicos(Array.isArray(list) ? list : [])
} finally {
if (mounted) setLoadingMedicos(false)
}
})()
return () => { mounted = false }
}, [])
// 4) Re-fetch doctors when especialidade changes (after initial sync)
useEffect(() => {
// Skip if this is the initial render or if user is searching by name
if (!paramsSync || (searchQuery && String(searchQuery).trim().length > 1)) return
let mounted = true
;(async () => {
@ -139,10 +174,15 @@ export default function ResultadosClient() {
setMedicos([])
setAgendaByDoctor({})
setAgendasExpandida({})
// termo de busca: usar a especialidade escolhida (fallback para string genérica)
const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : (params?.get('q') || 'medico')
const list = await buscarMedicos(termo).catch(() => [])
// termo de busca: usar a especialidade escolhida
const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : 'medico'
console.log('[ResultadosClient] Fetching doctors with term:', termo)
const list = await buscarMedicos(termo).catch((err) => {
console.error('[ResultadosClient] buscarMedicos error:', err)
return []
})
if (!mounted) return
console.log('[ResultadosClient] Doctors fetched:', list?.length || 0)
setMedicos(Array.isArray(list) ? list : [])
} catch (e: any) {
showToast('error', e?.message || 'Falha ao buscar profissionais')
@ -151,9 +191,9 @@ export default function ResultadosClient() {
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [especialidadeHero])
}, [especialidadeHero, paramsSync])
// Debounced search by doctor name. When searchQuery is non-empty (>=2 chars), call buscarMedicos
// 5) Debounced search by doctor name
useEffect(() => {
let mounted = true
const term = String(searchQuery || '').trim()
@ -203,7 +243,7 @@ export default function ResultadosClient() {
days.push({ label, data: fmtDay(d), dateKey, horarios: [] })
}
const onlyAvail = (res?.slots || []).filter(s => s.available)
const onlyAvail = (res?.slots || []).filter((s: any) => s.available)
for (const s of onlyAvail) {
const dt = new Date(s.datetime)
const key = dt.toISOString().split('T')[0]
@ -387,7 +427,7 @@ export default function ResultadosClient() {
let start: Date
let end: Date
try {
const parts = String(dateOnly).split('-').map((p) => Number(p))
const parts = String(dateOnly).split('-').map(Number)
if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
const [y, m, d] = parts
start = new Date(y, m - 1, d, 0, 0, 0, 0)
@ -425,12 +465,12 @@ export default function ResultadosClient() {
5: ['5','fri','friday','sexta','sexta-feira'],
6: ['6','sat','saturday','sabado','sábado']
}
const allowed = (weekdayNames[weekdayNumber] || []).map(s => String(s).toLowerCase())
const allowed = new Set((weekdayNames[weekdayNumber] || []).map(s => String(s).toLowerCase()))
const matched = (disponibilidades || []).filter((d: any) => {
try {
const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase()
if (!raw) return false
if (allowed.includes(raw)) return true
if (allowed.has(raw)) return true
if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true
if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true
return false
@ -441,7 +481,7 @@ export default function ResultadosClient() {
const windows = matched.map((d: any) => {
const parseTime = (t?: string) => {
if (!t) return { hh: 0, mm: 0, ss: 0 }
const parts = String(t).split(':').map((p) => Number(p))
const parts = String(t).split(':').map(Number)
return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 }
}
const s = parseTime(d.start_time)
@ -488,8 +528,8 @@ export default function ResultadosClient() {
cursorMs += perWindowStep * 60000
}
} else {
const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1]
let cursorMs = lastBackendMs + perWindowStep * 60000
const lastBackendMs = backendSlotsInWindow.at(-1)
let cursorMs = (lastBackendMs ?? 0) + perWindowStep * 60000
while (cursorMs <= lastStartMs) {
generatedSet.add(new Date(cursorMs).toISOString())
cursorMs += perWindowStep * 60000
@ -599,7 +639,7 @@ export default function ResultadosClient() {
)}
{/* Confirmation dialog shown when a user selects a slot */}
<Dialog open={confirmOpen} onOpenChange={(open) => { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}>
<Dialog open={confirmOpen} onOpenChange={(open: boolean) => { if (!open) { setConfirmOpen(false); setPendingAppointment(null); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmar agendamento</DialogTitle>
@ -632,7 +672,7 @@ export default function ResultadosClient() {
</Dialog>
{/* Booking success modal shown when origin=paciente */}
<Dialog open={bookingSuccessOpen} onOpenChange={(open) => setBookingSuccessOpen(open)}>
<Dialog open={bookingSuccessOpen} onOpenChange={(open: boolean) => setBookingSuccessOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Consulta agendada</DialogTitle>
@ -682,7 +722,7 @@ export default function ResultadosClient() {
<Toggle
pressed={tipoConsulta === 'teleconsulta'}
onPressedChange={() => setTipoConsulta('teleconsulta')}
className={cn('rounded-full px-4 py-[10px] text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
className={cn('rounded-full px-4 py-2.5 text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
tipoConsulta === 'teleconsulta' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
>
<Globe className="mr-2 h-4 w-4" />
@ -691,7 +731,7 @@ export default function ResultadosClient() {
<Toggle
pressed={tipoConsulta === 'local'}
onPressedChange={() => setTipoConsulta('local')}
className={cn('rounded-full px-4 py-[10px] text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
className={cn('rounded-full px-4 py-2.5 text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
tipoConsulta === 'local' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
>
<Building2 className="mr-2 h-4 w-4" />
@ -713,7 +753,7 @@ export default function ResultadosClient() {
</Select>
<Select value={bairro} onValueChange={setBairro}>
<SelectTrigger className="h-10 min-w-[160px] rounded-full border border-primary/40 bg-primary/10 text-primary transition duration-200 hover:border-primary! focus:ring-2 focus:ring-primary cursor-pointer">
<SelectTrigger className="h-10 min-w-40 rounded-full border border-primary/40 bg-primary/10 text-primary transition duration-200 hover:border-primary! focus:ring-2 focus:ring-primary cursor-pointer">
<SelectValue placeholder="Bairro" />
</SelectTrigger>
<SelectContent>
@ -729,7 +769,7 @@ export default function ResultadosClient() {
<Input
placeholder="Buscar médico por nome"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
className="min-w-[220px] rounded-full"
/>
{searchQuery ? (
@ -958,7 +998,7 @@ export default function ResultadosClient() {
</section>
{/* Dialog de perfil completo (mantido e adaptado) */}
<Dialog open={!!medicoSelecionado} onOpenChange={open => !open && setMedicoSelecionado(null)}>
<Dialog open={!!medicoSelecionado} onOpenChange={(open: boolean) => !open && setMedicoSelecionado(null)}>
<DialogContent className="max-h[90vh] max-h-[90vh] w-full max-w-5xl overflow-y-auto border border-border bg-card p-0">
{medicoSelecionado && (
<>
@ -1074,7 +1114,7 @@ export default function ResultadosClient() {
</DialogContent>
</Dialog>
{/* Dialog: Mostrar mais horários (escolher data arbitrária) */}
<Dialog open={!!moreTimesForDoctor} onOpenChange={(open) => { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}>
<Dialog open={!!moreTimesForDoctor} onOpenChange={(open: boolean) => { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}>
<DialogContent className="w-full max-w-2xl border border-border bg-card p-6">
<DialogHeader className="mb-4">
<DialogTitle>Mais horários</DialogTitle>

View File

@ -3,7 +3,7 @@ import ResultadosClient from './ResultadosClient'
export default function Page() {
return (
<Suspense fallback={<div className="min-h-screen">Carregando...</div>}>
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><span>Carregando...</span></div>}>
<ResultadosClient />
</Suspense>
)

View File

@ -1,5 +1,5 @@
import { Header } from "@/components/layout/header"
import { HeroSection } from "@/components/hero-section"
import { HeroSection } from "@/components/features/general/hero-section"
import { Footer } from "@/components/layout/footer"
export default function HomePage() {

View File

@ -1,9 +1,11 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import Image from "next/image";
import SignatureCanvas from "react-signature-canvas";
import Link from "next/link";
import ProtectedRoute from "@/components/ProtectedRoute";
import ProtectedRoute from "@/components/shared/ProtectedRoute";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api";
import { useReports } from "@/hooks/useReports";
import { CreateReportData } from "@/types/report-types";
@ -12,7 +14,7 @@ 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 { SimpleThemeToggle } from "@/components/simple-theme-toggle";
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
import {
Table,
TableBody,
@ -174,7 +176,8 @@ const ProfissionalPage = () => {
}
})();
return () => { mounted = false; };
}, [user?.id, doctorId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Carregar perfil do médico correspondente ao usuário logado
useEffect(() => {
@ -226,7 +229,8 @@ const ProfissionalPage = () => {
}
})();
return () => { mounted = false; };
}, [user?.id, user?.email]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -338,7 +342,7 @@ const ProfissionalPage = () => {
// Helper: parse 'YYYY-MM-DD' into a local Date to avoid UTC parsing which can shift day
const parseYMDToLocal = (ymd?: string) => {
if (!ymd || typeof ymd !== 'string') return new Date();
const parts = ymd.split('-').map((p) => Number(p));
const parts = ymd.split('-').map(Number);
if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return new Date(ymd);
const [y, m, d] = parts;
return new Date(y, (m || 1) - 1, d || 1);
@ -369,7 +373,8 @@ const ProfissionalPage = () => {
}
})();
return () => { mounted = false; };
}, [doctorId, user?.id, user?.email]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId]);
const [editingEvent, setEditingEvent] = useState<any>(null);
const [showPopup, setShowPopup] = useState(false);
const [showActionModal, setShowActionModal] = useState(false);
@ -1200,12 +1205,14 @@ const ProfissionalPage = () => {
await loadAssignedLaudos();
})();
return () => { mounted = false; };
}, [user?.id]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// sincroniza quando reports mudarem no hook (fallback)
useEffect(() => {
if (!laudos || laudos.length === 0) setLaudos(reports || []);
}, [reports]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sort reports newest-first (more recent dates at the top)
const sortedLaudos = React.useMemo(() => {
@ -1668,8 +1675,7 @@ const ProfissionalPage = () => {
// Editor de Laudo Avançado (para novos laudos)
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<any>; updateExistingReport?: (id: string, data: any) => Promise<any>; reloadReports?: () => Promise<void>; onSaved?: (r:any) => void }) {
// Import useToast at the top level of the component
const { toast } = require('@/hooks/use-toast').useToast();
const { toast } = useToast();
const [activeTab, setActiveTab] = useState("editor");
const [content, setContent] = useState(laudo?.conteudo || "");
const [showPreview, setShowPreview] = useState(false);
@ -1818,7 +1824,7 @@ const ProfissionalPage = () => {
const sig = laudo.assinaturaImg ?? laudo.signature_image ?? laudo.signature ?? laudo.sign_image ?? null;
if (sig) setAssinaturaImg(sig);
}
}, [laudo, isNewLaudo, pacienteSelecionado, listaPacientes, user]);
}, [laudo, isNewLaudo, pacienteSelecionado, listaPacientes]);
// Histórico para desfazer/refazer
const [history, setHistory] = useState<string[]>([]);
@ -2250,6 +2256,7 @@ const ProfissionalPage = () => {
{imagens.map((img) => (
<div key={img.id} className="border border-border rounded-lg p-2">
{img.type.startsWith('image/') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={img.url}
alt={img.name}
@ -2417,6 +2424,7 @@ const ProfissionalPage = () => {
<h3 className="font-semibold mb-2">Imagens:</h3>
<div className="grid grid-cols-2 gap-2">
{imagens.map((img) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={img.id}
src={img.url}
@ -2432,6 +2440,7 @@ const ProfissionalPage = () => {
{campos.mostrarAssinatura && (
<div className="mt-8 text-center">
{assinaturaImg && assinaturaImg.length > 30 ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={assinaturaImg} alt="Assinatura Digital" className="mx-auto h-16 object-contain mb-2" />
) : (
<div className="h-16 mb-2 text-xs text-muted-foreground">Assine no campo ao lado para visualizar aqui.</div>
@ -2528,7 +2537,11 @@ const ProfissionalPage = () => {
} else if (typeof val === 'boolean') {
if (origVal !== val) diff[k] = val;
} else if (val !== undefined && val !== null) {
if (JSON.stringify(origVal) !== JSON.stringify(val)) diff[k] = val;
if (JSON.stringify(origVal) !== JSON.stringify(val)) {
diff[k] = val;
} else {
// no change
}
}
}

View File

@ -1,5 +1,5 @@
import { Header } from "@/components/layout/header"
import { AboutSection } from "@/components/about-section"
import { AboutSection } from "@/components/features/general/about-section"
import { Footer } from "@/components/layout/footer"
export default function AboutPage() {

View File

@ -3,7 +3,7 @@ import { EventCard } from "./EventCard";
import { Card } from "@/components/ui/card";
// Types
import { Event } from "@/components/event-manager";
import { Event } from "@/components/features/general/event-manager";
// Week View Component
export function WeekView({

View File

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Event } from "@/components/event-manager";
import { Event } from "@/components/features/general/event-manager";
import { cn } from "@/lib/utils";
/*

View File

@ -8,7 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { SidebarTrigger } from "../../ui/sidebar"
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
const { logout, user } = useAuth();

View File

@ -32,7 +32,7 @@ import { getAvatarPublicUrl } from '@/lib/api';
;
import { buscarCepAPI } from "@/lib/api";
import { CredentialsDialog } from "@/components/credentials-dialog";
import { CredentialsDialog } from "@/components/features/general/credentials-dialog";
type FormacaoAcademica = {
instituicao: string;

View File

@ -30,7 +30,7 @@ import { getAvatarPublicUrl } from '@/lib/api';
import { validarCPFLocal } from "@/lib/utils";
import { verificarCpfDuplicado } from "@/lib/api";
import { CredentialsDialog } from "@/components/credentials-dialog";
import { CredentialsDialog } from "@/components/features/general/credentials-dialog";
type Mode = "create" | "edit";

View File

@ -5,7 +5,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Menu, X } from "lucide-react";
import { usePathname } from "next/navigation";
import { SimpleThemeToggle } from "@/components/simple-theme-toggle";
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);

View File

@ -742,7 +742,8 @@ async function parse<T>(res: Response): Promise<T> {
}
// For other errors, log a concise error and try to produce a friendly message
console.error('[API ERROR] Status:', res.status, json ? 'JSON response' : 'no-json', rawText ? 'raw body present' : 'no raw body');
const endpoint = res.url ? new URL(res.url).pathname : 'unknown';
console.error('[API ERROR] Status:', res.status, 'Endpoint:', endpoint, json ? 'JSON response' : 'no-json', rawText ? 'raw body present' : 'no raw body', 'Message:', msg || 'N/A');
// Mensagens amigáveis para erros comuns
let friendlyMessage = msg;
@ -847,7 +848,7 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
// Busca por ID se parece com UUID
if (searchTerm.includes('-') && searchTerm.length > 10) {
queries.push(`id=eq.${searchTerm}`);
queries.push(`id=eq.${encodeURIComponent(searchTerm)}`);
}
// Busca por CPF (com e sem formatação)
@ -858,14 +859,14 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
}
// Busca por nome (usando ilike para busca case-insensitive)
// NOTA: apenas full_name existe, social_name foi removido
if (searchTerm.length >= 2) {
queries.push(`full_name=ilike.*${searchTerm}*`);
queries.push(`social_name=ilike.*${searchTerm}*`);
queries.push(`full_name=ilike.*${q}*`);
}
// Busca por email se contém @
if (searchTerm.includes('@')) {
queries.push(`email=ilike.*${searchTerm}*`);
queries.push(`email=ilike.*${q}*`);
}
const results: Paciente[] = [];
@ -874,13 +875,8 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
// Executa as buscas e combina resultados únicos
for (const query of queries) {
try {
const [key, val] = String(query).split('=');
const params = new URLSearchParams();
if (key && val !== undefined) params.set(key, val);
params.set('limit', '10');
const url = `${REST}/patients?${params.toString()}`;
const url = `${REST}/patients?${query}&limit=10`;
const headers = baseHeaders();
// Logs removidos por segurança
const res = await fetch(url, { method: "GET", headers });
const arr = await parse<Paciente[]>(res);
@ -893,7 +889,7 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
}
}
} catch (error) {
console.warn(`Erro na busca com query: ${query}`, error);
console.warn(`[API] Erro na busca de pacientes com query: ${query}`, error);
}
}
@ -1729,8 +1725,7 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
const searchTerm = termo.toLowerCase().trim();
const digitsOnly = searchTerm.replace(/\D/g, '');
// Do not pre-encode the searchTerm here; we'll let URLSearchParams handle encoding
const q = searchTerm;
const q = encodeURIComponent(searchTerm);
// Monta queries para buscar em múltiplos campos
const queries = [];
@ -1742,21 +1737,19 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
// Busca por CRM (com e sem formatação)
if (digitsOnly.length >= 3) {
queries.push(`crm=ilike.*${digitsOnly}*`);
queries.push(`crm=ilike.*${encodeURIComponent(digitsOnly)}*`);
}
// Busca por nome (usando ilike para busca case-insensitive)
// NOTA: apenas full_name existe na tabela, nome_social foi removido
if (searchTerm.length >= 2) {
queries.push(`full_name=ilike.*${q}*`);
queries.push(`nome_social=ilike.*${q}*`);
}
// Busca por email se contém @
if (searchTerm.includes('@')) {
// Quando o usuário pesquisa por email (contendo '@'), limitar as queries apenas ao campo email.
// Em alguns esquemas de banco / views, buscar por outros campos com um email pode provocar
// erros de requisição (400) dependendo das colunas e políticas. Reduzimos o escopo para evitar 400s.
queries.length = 0; // limpar queries anteriores
queries.length = 0;
queries.push(`email=ilike.*${q}*`);
}
@ -1764,8 +1757,6 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
if (searchTerm.length >= 2) {
queries.push(`specialty=ilike.*${q}*`);
}
// Debug removido por segurança
const results: Medico[] = [];
const seenIds = new Set<string>();
@ -1773,15 +1764,8 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
// Executa as buscas e combina resultados únicos
for (const query of queries) {
try {
// Build the URL safely using URLSearchParams so special characters (like @) are encoded correctly
// query is like 'nome_social=ilike.*something*' -> split into key/value
const [key, val] = String(query).split('=');
const params = new URLSearchParams();
if (key && val !== undefined) params.set(key, val);
params.set('limit', '10');
const url = `${REST}/doctors?${params.toString()}`;
const url = `${REST}/doctors?${query}&limit=10`;
const headers = baseHeaders();
// Logs removidos por segurança
const res = await fetch(url, { method: 'GET', headers });
const arr = await parse<Medico[]>(res);
@ -1794,7 +1778,7 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
}
}
} catch (error) {
console.warn(`Erro na busca com query: ${query}`, error);
console.warn(`[API] Erro na busca de médicos com query: ${query}`, error);
}
}

View File

@ -162,18 +162,23 @@ export async function listarRelatorios(filtros?: { patient_id?: string; status?:
*/
export async function buscarRelatorioPorId(id: string): Promise<Report> {
try {
// Log removido por segurança
const resposta = await fetch(`${BASE_API_RELATORIOS}?id=eq.${id}`, {
// Validar ID antes de fazer requisição
if (!id || typeof id !== 'string' || id.trim() === '') {
console.warn('[REPORTS] ID vazio ou inválido ao buscar relatório');
throw new Error('ID de relatório inválido');
}
const encodedId = encodeURIComponent(id.trim());
const resposta = await fetch(`${BASE_API_RELATORIOS}?id=eq.${encodedId}`, {
method: 'GET',
headers: obterCabecalhos(),
});
const resultado = await tratarRespostaApi<Report[]>(resposta);
const relatorio = Array.isArray(resultado) && resultado.length > 0 ? resultado[0] : null;
// Log removido por segurança
if (!relatorio) throw new Error('Relatório não encontrado');
return relatorio;
} catch (erro) {
console.error('❌ [API RELATÓRIOS] Erro ao buscar relatório:', erro);
console.error('[REPORTS] Erro ao buscar relatório:', erro);
throw erro;
}
}
@ -259,39 +264,38 @@ export async function deletarRelatorio(id: string): Promise<void> {
*/
export async function listarRelatoriosPorPaciente(idPaciente: string): Promise<Report[]> {
try {
// Logs removidos por segurança
// Validar ID antes de fazer requisição
if (!idPaciente || typeof idPaciente !== 'string' || idPaciente.trim() === '') {
console.warn('[REPORTS] ID paciente vazio ou inválido ao listar relatórios');
return [];
}
// Try a strict eq lookup first (encode the id)
const encodedId = encodeURIComponent(String(idPaciente));
const encodedId = encodeURIComponent(String(idPaciente).trim());
let url = `${BASE_API_RELATORIOS}?patient_id=eq.${encodedId}`;
const headers = obterCabecalhos();
const masked = (headers as any)['Authorization'] ? `${String((headers as any)['Authorization']).slice(0,6)}...${String((headers as any)['Authorization']).slice(-6)}` : null;
// Logs removidos por segurança
const resposta = await fetch(url, {
method: 'GET',
headers,
});
const resultado = await tratarRespostaApi<Report[]>(resposta);
// Log removido por segurança
// If eq returned results, return them. Otherwise retry using `in.(id)` which some setups prefer.
if (Array.isArray(resultado) && resultado.length) return resultado;
// Retry with in.(id) clause as a fallback
try {
const inClause = encodeURIComponent(`(${String(idPaciente)})`);
const inClause = encodeURIComponent(`(${String(idPaciente).trim()})`);
const urlIn = `${BASE_API_RELATORIOS}?patient_id=in.${inClause}`;
// Log removido por segurança
const resp2 = await fetch(urlIn, { method: 'GET', headers });
const res2 = await tratarRespostaApi<Report[]>(resp2);
// Log removido por segurança
return Array.isArray(res2) ? res2 : [];
} catch (e) {
// Log removido por segurança
// Fallback falhou, retornar vazio
return [];
}
return [];
} catch (erro) {
console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios do paciente:', erro);
throw erro;
console.error('[REPORTS] Erro ao buscar relatórios do paciente:', erro);
return [];
}
}
@ -300,20 +304,24 @@ export async function listarRelatoriosPorPaciente(idPaciente: string): Promise<R
*/
export async function listarRelatoriosPorMedico(idMedico: string): Promise<Report[]> {
try {
console.log('👨‍⚕️ [API RELATÓRIOS] Buscando relatórios do médico:', idMedico);
const url = `${BASE_API_RELATORIOS}?requested_by=eq.${idMedico}`;
// Validar ID antes de fazer requisição
if (!idMedico || typeof idMedico !== 'string' || idMedico.trim() === '') {
console.warn('[REPORTS] ID médico vazio ou inválido ao listar relatórios');
return [];
}
const encodedId = encodeURIComponent(idMedico.trim());
const url = `${BASE_API_RELATORIOS}?requested_by=eq.${encodedId}`;
const headers = obterCabecalhos();
// Logs removidos por segurança
const resposta = await fetch(url, {
method: 'GET',
headers: obterCabecalhos(),
});
const resultado = await tratarRespostaApi<Report[]>(resposta);
// Log removido por segurança
return resultado;
return Array.isArray(resultado) ? resultado : [];
} catch (erro) {
console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios do médico:', erro);
throw erro;
console.error('[REPORTS] Erro ao buscar relatórios do médico:', erro);
return [];
}
}
@ -328,19 +336,17 @@ export async function listarRelatoriosPorPacientes(ids: string[]): Promise<Repor
const cleaned = ids.map(i => String(i).trim()).filter(Boolean);
if (!cleaned.length) return [];
// monta cláusula in.(id1,id2,...)
const inClause = cleaned.join(',');
const url = `${BASE_API_RELATORIOS}?patient_id=in.(${inClause})`;
// monta cláusula in.(id1,id2,...) com proper encoding
const encodedIds = cleaned.map(id => encodeURIComponent(id)).join(',');
const url = `${BASE_API_RELATORIOS}?patient_id=in.(${encodedIds})`;
const headers = obterCabecalhos();
// Logs removidos por segurança
const resposta = await fetch(url, { method: 'GET', headers });
const resultado = await tratarRespostaApi<Report[]>(resposta);
// Log removido por segurança
return resultado;
return Array.isArray(resultado) ? resultado : [];
} catch (erro) {
console.error('❌ [API RELATÓRIOS] Erro ao buscar relatórios para vários pacientes:', erro);
throw erro;
console.error('[REPORTS] Erro ao buscar relatórios para vários pacientes:', erro);
return [];
}
}