Merge pull request 'fix/appoiments' (#67) from fix/appoiments into develop

Reviewed-on: #67
This commit is contained in:
M-Gabrielly 2025-11-06 23:47:46 +00:00
commit 2e7b3561b6
14 changed files with 1225 additions and 1260 deletions

View File

@ -2,30 +2,27 @@
// Imports mantidos // Imports mantidos
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
// --- Imports do EventManager (NOVO) - MANTIDOS --- // --- Imports do EventManager (NOVO) - MANTIDOS ---
import { EventManager, type Event } from "@/components/features/general/event-manager"; import { EventManager, type Event } from "@/components/features/general/event-manager";
import { v4 as uuidv4 } from 'uuid'; // Usado para IDs de fallback import { v4 as uuidv4 } from 'uuid'; // Usado para IDs de fallback
// Imports mantidos // Imports mantidos
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { mockWaitingList } from "@/lib/mocks/appointment-mocks";
import "./index.css"; import "./index.css";
import { ThreeDWallCalendar, CalendarEvent } from "@/components/ui/three-dwall-calendar"; // Calendário 3D mantido
import { PatientRegistrationForm } from "@/components/features/forms/patient-registration-form";
const ListaEspera = dynamic(
() => import("@/components/features/agendamento/ListaEspera"),
{ ssr: false }
);
export default function AgendamentoPage() { export default function AgendamentoPage() {
const { user, token } = useAuth();
const [appointments, setAppointments] = useState<any[]>([]); const [appointments, setAppointments] = useState<any[]>([]);
const [activeTab, setActiveTab] = useState<"calendar" | "3d">("calendar"); // REMOVIDO: abas e 3D → não há mais alternância de abas
const [threeDEvents, setThreeDEvents] = useState<CalendarEvent[]>([]); // const [activeTab, setActiveTab] = useState<"calendar" | "3d">("calendar");
// REMOVIDO: estados do 3D e formulário do paciente (eram usados pelo 3D)
// const [threeDEvents, setThreeDEvents] = useState<CalendarEvent[]>([]);
// const [showPatientForm, setShowPatientForm] = useState(false);
// --- NOVO ESTADO ---
// Estado para alimentar o NOVO EventManager com dados da API
const [managerEvents, setManagerEvents] = useState<Event[]>([]);
const [managerLoading, setManagerLoading] = useState<boolean>(true);
// Padroniza idioma da página para pt-BR (afeta componentes que usam o lang do documento) // Padroniza idioma da página para pt-BR (afeta componentes que usam o lang do documento)
useEffect(() => { useEffect(() => {
@ -42,21 +39,6 @@ export default function AgendamentoPage() {
} }
}, []); }, []);
// --- NOVO ESTADO ---
// Estado para alimentar o NOVO EventManager com dados da API
const [managerEvents, setManagerEvents] = useState<Event[]>([]);
const [managerLoading, setManagerLoading] = useState<boolean>(true);
// Estado para o formulário de registro de paciente
const [showPatientForm, setShowPatientForm] = useState(false);
useEffect(() => {
document.addEventListener("keydown", (event) => {
if (event.key === "c") setActiveTab("calendar");
if (event.key === "3") setActiveTab("3d");
});
}, []);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
(async () => { (async () => {
@ -67,8 +49,8 @@ export default function AgendamentoPage() {
if (!mounted) return; if (!mounted) return;
if (!arr || !arr.length) { if (!arr || !arr.length) {
setAppointments([]); setAppointments([]);
setThreeDEvents([]); // REMOVIDO: setThreeDEvents([])
setManagerEvents([]); // Limpa o novo calendário setManagerEvents([]);
setManagerLoading(false); setManagerLoading(false);
return; return;
} }
@ -86,12 +68,11 @@ export default function AgendamentoPage() {
const start = scheduled ? new Date(scheduled) : new Date(); const start = scheduled ? new Date(scheduled) : new Date();
const duration = Number(obj.duration_minutes ?? obj.duration ?? 30) || 30; const duration = Number(obj.duration_minutes ?? obj.duration ?? 30) || 30;
const end = new Date(start.getTime() + duration * 60 * 1000); const end = new Date(start.getTime() + duration * 60 * 1000);
const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente'; const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente';
const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim(); const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim();
// Mapeamento de cores padronizado: // Mapeamento de cores padronizado
// azul = solicitado; verde = confirmado; laranja = pendente; vermelho = cancelado; azul como fallback
const status = String(obj.status || "").toLowerCase(); const status = String(obj.status || "").toLowerCase();
let color: Event["color"] = "blue"; let color: Event["color"] = "blue";
if (status === "confirmed" || status === "confirmado") color = "green"; if (status === "confirmed" || status === "confirmado") color = "green";
@ -112,27 +93,12 @@ export default function AgendamentoPage() {
setManagerLoading(false); setManagerLoading(false);
// --- FIM DA LÓGICA --- // --- FIM DA LÓGICA ---
// Convert to 3D calendar events (MANTIDO 100%) // REMOVIDO: conversão para 3D e setThreeDEvents
const threeDEvents: CalendarEvent[] = (arr || []).map((obj: any) => {
const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null;
const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente';
const appointmentType = obj.appointment_type ?? obj.type ?? 'Consulta';
const title = `${patient}: ${appointmentType}`.trim();
return {
id: obj.id || String(Date.now()),
title,
date: scheduled ? new Date(scheduled).toISOString() : new Date().toISOString(),
status: obj.status || 'pending',
patient,
type: appointmentType,
};
});
setThreeDEvents(threeDEvents);
} catch (err) { } catch (err) {
console.warn('[AgendamentoPage] falha ao carregar agendamentos', err); console.warn('[AgendamentoPage] falha ao carregar agendamentos', err);
setAppointments([]); setAppointments([]);
setThreeDEvents([]); // REMOVIDO: setThreeDEvents([])
setManagerEvents([]); // Limpa o novo calendário setManagerEvents([]);
setManagerLoading(false); setManagerLoading(false);
} }
})(); })();
@ -154,12 +120,38 @@ export default function AgendamentoPage() {
} }
}; };
const handleAddEvent = (event: CalendarEvent) => { // Mapeia cor do calendário -> status da API
setThreeDEvents((prev) => [...prev, event]); const statusFromColor = (color?: string) => {
switch ((color || "").toLowerCase()) {
case "green": return "confirmed";
case "orange": return "pending";
case "red": return "canceled";
default: return "requested";
}
}; };
const handleRemoveEvent = (id: string) => { // Envia atualização para a API e atualiza UI
setThreeDEvents((prev) => prev.filter((e) => e.id !== id)); const handleEventUpdate = async (id: string, partial: Partial<Event>) => {
try {
const payload: any = {};
if (partial.startTime) payload.scheduled_at = partial.startTime.toISOString();
if (partial.startTime && partial.endTime) {
const minutes = Math.max(1, Math.round((partial.endTime.getTime() - partial.startTime.getTime()) / 60000));
payload.duration_minutes = minutes;
}
if (partial.color) payload.status = statusFromColor(partial.color);
if (typeof partial.description === "string") payload.notes = partial.description;
if (Object.keys(payload).length) {
const api = await import('@/lib/api');
await api.atualizarAgendamento(id, payload);
}
// Otimista: reflete mudanças locais
setManagerEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...partial } : e)));
} catch (e) {
console.warn("[Calendário] Falha ao atualizar agendamento na API:", e);
}
}; };
return ( return (
@ -167,39 +159,17 @@ export default function AgendamentoPage() {
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<div className="flex w-full flex-col gap-10 p-6"> <div className="flex w-full flex-col gap-10 p-6">
<div className="flex flex-row justify-between items-center"> <div className="flex flex-row justify-between items-center">
{/* Todo o cabeçalho foi mantido */} {/* Cabeçalho simplificado (sem 3D) */}
<div> <div>
<h1 className="text-2xl font-bold text-foreground"> <h1 className="text-2xl font-bold text-foreground">Calendário</h1>
{activeTab === "calendar" ? "Calendário" : activeTab === "3d" ? "Calendário 3D" : "Lista de Espera"}
</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Navegue através dos atalhos: Calendário (C), Fila de espera (F) ou 3D (3). Navegue através do atalho: Calendário (C).
</p> </p>
</div> </div>
<div className="flex space-x-2 items-center"> {/* REMOVIDO: botões de abas Calendário/3D */}
<div className="flex flex-row">
<Button
type="button"
variant={"outline"}
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-l-[100px] rounded-r-none"
onClick={() => setActiveTab("calendar")}
>
Calendário
</Button>
<Button
type="button"
variant={"outline"}
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-r-[100px] rounded-l-none"
onClick={() => setActiveTab("3d")}
>
3D
</Button>
</div>
</div>
</div> </div>
{/* Legenda de status (estilo Google Calendar) */} {/* Legenda de status (aplica-se ao EventManager) */}
<div className="rounded-md border bg-card/60 p-2 sm:p-3 -mt-4"> <div className="rounded-md border bg-card/60 p-2 sm:p-3 -mt-4">
<div className="flex flex-wrap items-center gap-6 text-sm"> <div className="flex flex-wrap items-center gap-6 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -210,49 +180,35 @@ export default function AgendamentoPage() {
<span aria-hidden className="h-3 w-3 rounded-full bg-green-500 ring-2 ring-green-500/30" /> <span aria-hidden className="h-3 w-3 rounded-full bg-green-500 ring-2 ring-green-500/30" />
<span className="text-foreground">Confirmado</span> <span className="text-foreground">Confirmado</span>
</div> </div>
{/* Novo: Cancelado (vermelho) */}
<div className="flex items-center gap-2">
<span aria-hidden className="h-3 w-3 rounded-full bg-red-500 ring-2 ring-red-500/30" />
<span className="text-foreground">Cancelado</span>
</div>
</div> </div>
</div> </div>
{/* --- AQUI ESTÁ A SUBSTITUIÇÃO --- */} {/* Apenas o EventManager */}
{activeTab === "calendar" ? ( <div className="flex w-full">
<div className="flex w-full"> <div className="w-full">
{/* mostra loading até managerEvents ser preenchido (API integrada desde a entrada) */} {managerLoading ? (
<div className="w-full"> <div className="flex items-center justify-center w-full min-h-[70vh]">
{managerLoading ? ( <div className="text-sm text-muted-foreground">Conectando ao calendário carregando agendamentos...</div>
<div className="flex items-center justify-center w-full min-h-[70vh]"> </div>
<div className="text-sm text-muted-foreground">Conectando ao calendário carregando agendamentos...</div> ) : (
</div> <div className="w-full min-h-[70vh]">
) : ( <EventManager
// EventManager ocupa a área principal e já recebe events da API events={managerEvents}
<div className="w-full min-h-[70vh]"> className="compact-event-manager"
<EventManager events={managerEvents} className="compact-event-manager" /> onEventUpdate={handleEventUpdate}
</div> />
)} </div>
</div> )}
</div> </div>
) : activeTab === "3d" ? ( </div>
// O calendário 3D (ThreeDWallCalendar) foi MANTIDO 100%
<div className="flex w-full justify-center">
<ThreeDWallCalendar
events={threeDEvents}
onAddEvent={handleAddEvent}
onRemoveEvent={handleRemoveEvent}
onOpenAddPatientForm={() => setShowPatientForm(true)}
/>
</div>
) : null}
</div> </div>
{/* Formulário de Registro de Paciente */} {/* REMOVIDO: PatientRegistrationForm (era acionado pelo 3D) */}
<PatientRegistrationForm
open={showPatientForm}
onOpenChange={setShowPatientForm}
mode="create"
onSaved={(newPaciente) => {
console.log('[Calendar] Novo paciente registrado:', newPaciente);
setShowPatientForm(false);
}}
/>
</div> </div>
</div> </div>
); );

View File

@ -520,28 +520,30 @@ export default function RelatoriosPage() {
{/* Performance por médico */} {/* Performance por médico */}
<div className="bg-card border border-border rounded-lg shadow p-6"> <div className="bg-card border border-border rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-2"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-0 mb-4">
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><TrendingUp className="w-5 h-5" /> Performance por Médico</h2> <h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><TrendingUp className="w-5 h-5" /> Performance por Médico</h2>
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Performance por Médico", "Resumo da performance por médico.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button> <Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors w-full md:w-auto" onClick={() => exportPDF("Performance por Médico", "Resumo da performance por médico.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
</div> </div>
<table className="w-full text-sm mt-4"> <div className="overflow-x-auto">
<thead> <table className="w-full text-sm">
<tr className="text-muted-foreground"> <thead>
<th className="text-left font-medium">Médico</th> <tr className="text-muted-foreground border-b border-border">
<th className="text-left font-medium">Consultas</th> <th className="text-left font-medium py-3 px-2 md:px-0">Médico</th>
<th className="text-left font-medium">Absenteísmo (%)</th> <th className="text-center font-medium py-3 px-2 md:px-0">Consultas</th>
</tr> <th className="text-center font-medium py-3 px-2 md:px-0">Absenteísmo (%)</th>
</thead>
<tbody>
{(loading ? performancePorMedico : medicosPerformance).map((m) => (
<tr key={m.nome}>
<td className="py-1">{m.nome}</td>
<td className="py-1">{m.consultas}</td>
<td className="py-1">{m.absenteismo}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {(loading ? performancePorMedico : medicosPerformance).map((m) => (
<tr key={m.nome} className="border-b border-border/50 hover:bg-muted/30 transition-colors">
<td className="py-3 px-2 md:px-0">{m.nome}</td>
<td className="py-3 px-2 md:px-0 text-center font-medium">{m.consultas}</td>
<td className="py-3 px-2 md:px-0 text-center text-blue-500 font-medium">{m.absenteismo}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -145,7 +145,12 @@ export default function DoutoresPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
// NOVO: Ordenação e filtros
const [sortBy, setSortBy] = useState<"name_asc" | "name_desc" | "recent" | "oldest">("name_asc");
const [stateFilter, setStateFilter] = useState<string>("");
const [cityFilter, setCityFilter] = useState<string>("");
const [specialtyFilter, setSpecialtyFilter] = useState<string>("");
async function load() { async function load() {
setLoading(true); setLoading(true);
try { try {
@ -272,47 +277,87 @@ export default function DoutoresPage() {
}; };
}, [searchTimeout]); }, [searchTimeout]);
// Lista de médicos a exibir (busca ou filtro local) // NOVO: Opções dinâmicas
const stateOptions = useMemo(
() =>
Array.from(
new Set((doctors || []).map((d) => (d.state || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
[doctors],
);
const cityOptions = useMemo(() => {
const base = (doctors || []).filter((d) => !stateFilter || String(d.state) === stateFilter);
return Array.from(
new Set(base.map((d) => (d.city || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
}, [doctors, stateFilter]);
const specialtyOptions = useMemo(
() =>
Array.from(
new Set((doctors || []).map((d) => (d.especialidade || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
[doctors],
);
// NOVO: Índice para ordenação por "tempo" (ordem de carregamento)
const indexById = useMemo(() => {
const map = new Map<string, number>();
(doctors || []).forEach((d, i) => map.set(String(d.id), i));
return map;
}, [doctors]);
// Lista de médicos a exibir com busca + filtros + ordenação
const displayedDoctors = useMemo(() => { const displayedDoctors = useMemo(() => {
console.log('🔍 Filtro - search:', search, 'searchMode:', searchMode, 'doctors:', doctors.length, 'searchResults:', searchResults.length); console.log('🔍 Filtro - search:', search, 'searchMode:', searchMode, 'doctors:', doctors.length, 'searchResults:', searchResults.length);
// Se não tem busca, mostra todos os médicos
if (!search.trim()) return doctors;
const q = search.toLowerCase().trim(); const q = search.toLowerCase().trim();
const qDigits = q.replace(/\D/g, ""); const qDigits = q.replace(/\D/g, "");
// Se estamos em modo de busca (servidor), filtra os resultados da busca
const sourceList = searchMode ? searchResults : doctors; const sourceList = searchMode ? searchResults : doctors;
console.log('🔍 Usando sourceList:', searchMode ? 'searchResults' : 'doctors', '- tamanho:', sourceList.length);
// 1) Busca
const filtered = sourceList.filter((d) => { const afterSearch = !q
// Busca por nome ? sourceList
const byName = (d.full_name || "").toLowerCase().includes(q); : sourceList.filter((d) => {
const byName = (d.full_name || "").toLowerCase().includes(q);
// Busca por CRM (remove formatação se necessário) const byCrm = qDigits.length >= 3 && (d.crm || "").replace(/\D/g, "").includes(qDigits);
const byCrm = qDigits.length >= 3 && (d.crm || "").replace(/\D/g, "").includes(qDigits); const byId = (d.id || "").toLowerCase().includes(q);
const byEmail = (d.email || "").toLowerCase().includes(q);
// Busca por ID (UUID completo ou parcial) const byEspecialidade = (d.especialidade || "").toLowerCase().includes(q);
const byId = (d.id || "").toLowerCase().includes(q); const match = byName || byCrm || byId || byEmail || byEspecialidade;
if (match) console.log('✅ Match encontrado:', d.full_name, d.id);
// Busca por email return match;
const byEmail = (d.email || "").toLowerCase().includes(q); });
// Busca por especialidade // 2) Filtros de localização e especialidade
const byEspecialidade = (d.especialidade || "").toLowerCase().includes(q); const afterFilters = afterSearch.filter((d) => {
if (stateFilter && String(d.state) !== stateFilter) return false;
const match = byName || byCrm || byId || byEmail || byEspecialidade; if (cityFilter && String(d.city) !== cityFilter) return false;
if (match) { if (specialtyFilter && String(d.especialidade) !== specialtyFilter) return false;
console.log('✅ Match encontrado:', d.full_name, d.id, 'por:', { byName, byCrm, byId, byEmail, byEspecialidade }); return true;
}
return match;
}); });
console.log('🔍 Resultados filtrados:', filtered.length); // 3) Ordenação
return filtered; const sorted = [...afterFilters];
}, [doctors, search, searchMode, searchResults]); if (sortBy === "name_asc" || sortBy === "name_desc") {
sorted.sort((a, b) => {
const an = (a.full_name || "").trim();
const bn = (b.full_name || "").trim();
const cmp = an.localeCompare(bn, "pt-BR", { sensitivity: "base" });
return sortBy === "name_asc" ? cmp : -cmp;
});
} else if (sortBy === "recent" || sortBy === "oldest") {
sorted.sort((a, b) => {
const ia = indexById.get(String(a.id)) ?? 0;
const ib = indexById.get(String(b.id)) ?? 0;
return sortBy === "recent" ? ia - ib : ib - ia;
});
}
console.log('🔍 Resultados filtrados:', sorted.length);
return sorted;
}, [doctors, search, searchMode, searchResults, stateFilter, cityFilter, specialtyFilter, sortBy, indexById]);
// Dados paginados // Dados paginados
const paginatedDoctors = useMemo(() => { const paginatedDoctors = useMemo(() => {
@ -323,10 +368,10 @@ export default function DoutoresPage() {
const totalPages = Math.ceil(displayedDoctors.length / itemsPerPage); const totalPages = Math.ceil(displayedDoctors.length / itemsPerPage);
// Reset para página 1 quando mudar a busca ou itens por página // Reset página ao mudar busca/filtros/ordenação
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [search, itemsPerPage, searchMode]); }, [search, itemsPerPage, searchMode, stateFilter, cityFilter, specialtyFilter, sortBy]);
function handleAdd() { function handleAdd() {
setEditingId(null); setEditingId(null);
@ -440,7 +485,7 @@ export default function DoutoresPage() {
<p className="text-muted-foreground">Gerencie os médicos da sua clínica</p> <p className="text-muted-foreground">Gerencie os médicos da sua clínica</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
@ -473,6 +518,59 @@ export default function DoutoresPage() {
</Button> </Button>
)} )}
</div> </div>
{/* NOVO: Ordenar por */}
<select
aria-label="Ordenar por"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="name_asc">Nome (AZ)</option>
<option value="name_desc">Nome (ZA)</option>
<option value="recent">Mais recentes (carregamento)</option>
<option value="oldest">Mais antigos (carregamento)</option>
</select>
{/* NOVO: Especialidade */}
<select
aria-label="Filtrar por especialidade"
value={specialtyFilter}
onChange={(e) => setSpecialtyFilter(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Todas as especialidades</option>
{specialtyOptions.map((sp) => (
<option key={sp} value={sp}>{sp}</option>
))}
</select>
{/* NOVO: Estado (UF) */}
<select
aria-label="Filtrar por estado"
value={stateFilter}
onChange={(e) => { setStateFilter(e.target.value); setCityFilter(""); }}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Todos os estados</option>
{stateOptions.map((uf) => (
<option key={uf} value={uf}>{uf}</option>
))}
</select>
{/* NOVO: Cidade (dependente do estado) */}
<select
aria-label="Filtrar por cidade"
value={cityFilter}
onChange={(e) => setCityFilter(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Todas as cidades</option>
{cityOptions.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<Button onClick={handleAdd} disabled={loading}> <Button onClick={handleAdd} disabled={loading}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Novo Médico Novo Médico

View File

@ -1,147 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Calculator, DollarSign } from "lucide-react";
import HeaderAgenda from "@/components/features/agenda/HeaderAgenda";
import FooterAgenda from "@/components/features/agenda/FooterAgenda";
export default function FinanceiroPage() {
const router = useRouter();
const [formaTipo, setFormaTipo] = useState("");
const handleSave = () => {
// Lógica de salvar será implementada
console.log("Salvando informações financeiras...");
};
const handleCancel = () => {
router.push("/calendar");
};
return (
<div className="flex flex-col h-full bg-background">
<HeaderAgenda />
{/* CORPO */}
<main className="mx-auto w-full max-w-7xl px-8 py-6 space-y-6 flex-1 overflow-auto">
{/* INFORMAÇÕES FINANCEIRAS */}
<section className="space-y-6">
{/* Selo Financeiro */}
<div className="inline-flex items-center gap-2 border border-border px-3 py-1.5 bg-card text-[12px] rounded-md cursor-pointer hover:bg-muted">
<span className="flex h-5 w-5 items-center justify-center rounded-full border border-border bg-muted text-muted-foreground">
<DollarSign className="h-3 w-3" strokeWidth={2} />
</span>
<span className="text-foreground">Informações Financeiras</span>
</div>
{/* Traço separador */}
<div className="border-b border-border" />
{/* VALOR DO ATENDIMENTO */}
<div className="space-y-4">
<Label className="text-[13px] text-foreground/80">
Valor do Atendimento
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Valor Particular</Label>
<div className="relative">
<DollarSign className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="R$ 0,00"
className="h-10 w-full rounded-md pl-8 pr-4 focus-visible:ring-1 focus-visible:ring-sky-500 focus-visible:border-sky-500"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Valor Convênio</Label>
<div className="relative">
<DollarSign className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="R$ 0,00"
className="h-10 w-full rounded-md pl-8 pr-4 focus-visible:ring-1 focus-visible:ring-sky-500 focus-visible:border-sky-500"
/>
</div>
</div>
</div>
</div>
{/* Traço separador */}
<div className="border-b border-border" />
{/* FORMA DE PAGAMENTO */}
<div className="space-y-4">
<Label className="text-[13px] text-foreground/80">
Forma de Pagamento
</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Tipo</Label>
<select value={formaTipo} onChange={(e) => setFormaTipo(e.target.value)} className="h-10 w-full rounded-md border border-gray-300 dark:border-input bg-background text-foreground pr-8 pl-3 text-[13px] appearance-none transition-colors hover:bg-muted/30 hover:border-gray-400">
<option value="">Selecionar</option>
<option value="dinheiro">Dinheiro</option>
<option value="cartao">Cartão</option>
<option value="pix">PIX</option>
<option value="convenio">Convênio</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Parcelas</Label>
<select className="h-10 w-full rounded-md border border-gray-300 dark:border-input bg-background text-foreground pr-8 pl-3 text-[13px] appearance-none transition-colors hover:bg-muted/30 hover:border-gray-400">
<option value="1">1x</option>
<option value="2">2x</option>
<option value="3">3x</option>
<option value="4">4x</option>
<option value="5">5x</option>
<option value="6">6x</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Desconto</Label>
<div className="relative">
<Calculator className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="0%"
className="h-10 w-full rounded-md pl-8 pr-4 focus-visible:ring-1 focus-visible:ring-sky-500 focus-visible:border-sky-500"
/>
</div>
</div>
</div>
</div>
{/* Traço separador */}
<div className="border-b border-border" />
{/* RESUMO FINANCEIRO */}
<div className="space-y-4">
<Label className="text-[13px] text-foreground/80">
Resumo Financeiro
</Label>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Subtotal:</span>
<span className="text-sm font-medium text-foreground">R$ 0,00</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Desconto:</span>
<span className="text-sm font-medium text-foreground">- R$ 0,00</span>
</div>
<div className="border-t border-border pt-2">
<div className="flex justify-between items-center">
<span className="text-base font-medium text-foreground">Total:</span>
<span className="text-lg font-bold text-primary">R$ 0,00</span>
</div>
</div>
</div>
</div>
</section>
</main>
{/* RODAPÉ FIXO */}
<FooterAgenda onSave={handleSave} onCancel={handleCancel} />
</div>
);
}

View File

@ -1,4 +1,3 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@ -54,6 +53,11 @@ export default function PacientesPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
// Ordenação e filtros adicionais
const [sortBy, setSortBy] = useState<"name_asc" | "name_desc" | "recent" | "oldest">("name_asc");
const [stateFilter, setStateFilter] = useState<string>("");
const [cityFilter, setCityFilter] = useState<string>("");
async function loadAll() { async function loadAll() {
try { try {
setLoading(true); setLoading(true);
@ -77,27 +81,72 @@ export default function PacientesPage() {
loadAll(); loadAll();
}, []); }, []);
// Opções dinâmicas para Estado e Cidade
const stateOptions = useMemo(
() =>
Array.from(
new Set((patients || []).map((p) => (p.state || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
[patients],
);
const cityOptions = useMemo(() => {
const base = (patients || []).filter((p) => !stateFilter || String(p.state) === stateFilter);
return Array.from(
new Set(base.map((p) => (p.city || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
}, [patients, stateFilter]);
// Índice para ordenar por "tempo" (ordem de carregamento)
const indexById = useMemo(() => {
const map = new Map<string, number>();
(patients || []).forEach((p, i) => map.set(String(p.id), i));
return map;
}, [patients]);
// Substitui o filtered anterior: aplica busca + filtros + ordenação
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search.trim()) return patients; let base = patients;
const q = search.toLowerCase().trim();
const qDigits = q.replace(/\D/g, ""); // Busca
if (search.trim()) {
return patients.filter((p) => { const q = search.toLowerCase().trim();
// Busca por nome const qDigits = q.replace(/\D/g, "");
const byName = (p.full_name || "").toLowerCase().includes(q); base = patients.filter((p) => {
const byName = (p.full_name || "").toLowerCase().includes(q);
// Busca por CPF (remove formatação) const byCPF = qDigits.length >= 3 && (p.cpf || "").replace(/\D/g, "").includes(qDigits);
const byCPF = qDigits.length >= 3 && (p.cpf || "").replace(/\D/g, "").includes(qDigits); const byId = (p.id || "").toLowerCase().includes(q);
const byEmail = (p.email || "").toLowerCase().includes(q);
// Busca por ID (UUID completo ou parcial) return byName || byCPF || byId || byEmail;
const byId = (p.id || "").toLowerCase().includes(q); });
}
// Busca por email
const byEmail = (p.email || "").toLowerCase().includes(q); // Filtros por UF e cidade
const withLocation = base.filter((p) => {
return byName || byCPF || byId || byEmail; if (stateFilter && String(p.state) !== stateFilter) return false;
if (cityFilter && String(p.city) !== cityFilter) return false;
return true;
}); });
}, [patients, search]);
// Ordenação
const sorted = [...withLocation];
if (sortBy === "name_asc" || sortBy === "name_desc") {
sorted.sort((a, b) => {
const an = (a.full_name || "").trim();
const bn = (b.full_name || "").trim();
const cmp = an.localeCompare(bn, "pt-BR", { sensitivity: "base" });
return sortBy === "name_asc" ? cmp : -cmp;
});
} else if (sortBy === "recent" || sortBy === "oldest") {
sorted.sort((a, b) => {
const ia = indexById.get(String(a.id)) ?? 0;
const ib = indexById.get(String(b.id)) ?? 0;
return sortBy === "recent" ? ia - ib : ib - ia;
});
}
return sorted;
}, [patients, search, stateFilter, cityFilter, sortBy, indexById]);
// Dados paginados // Dados paginados
const paginatedData = useMemo(() => { const paginatedData = useMemo(() => {
@ -108,10 +157,10 @@ export default function PacientesPage() {
const totalPages = Math.ceil(filtered.length / itemsPerPage); const totalPages = Math.ceil(filtered.length / itemsPerPage);
// Reset para página 1 quando mudar a busca ou itens por página // Reset página ao mudar filtros/ordenadores
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [search, itemsPerPage]); }, [search, itemsPerPage, stateFilter, cityFilter, sortBy]);
function handleAdd() { function handleAdd() {
setEditingId(null); setEditingId(null);
@ -214,7 +263,8 @@ export default function PacientesPage() {
<p className="text-muted-foreground">Gerencie os pacientes</p> <p className="text-muted-foreground">Gerencie os pacientes</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
{/* Busca */}
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
@ -225,7 +275,52 @@ export default function PacientesPage() {
onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()} onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()}
/> />
</div> </div>
<Button variant="secondary" onClick={() => void handleBuscarServidor()} className="hover:bg-primary hover:text-white">Buscar</Button> <Button variant="secondary" onClick={() => void handleBuscarServidor()} className="hover:bg-primary hover:text-white">
Buscar
</Button>
{/* Ordenar por */}
<select
aria-label="Ordenar por"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="name_asc">Nome (AZ)</option>
<option value="name_desc">Nome (ZA)</option>
<option value="recent">Mais recentes (carregamento)</option>
<option value="oldest">Mais antigos (carregamento)</option>
</select>
{/* Estado (UF) */}
<select
aria-label="Filtrar por estado"
value={stateFilter}
onChange={(e) => {
setStateFilter(e.target.value);
setCityFilter("");
}}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Todos os estados</option>
{stateOptions.map((uf) => (
<option key={uf} value={uf}>{uf}</option>
))}
</select>
{/* Cidade (dependente do estado) */}
<select
aria-label="Filtrar por cidade"
value={cityFilter}
onChange={(e) => setCityFilter(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Todas as cidades</option>
{cityOptions.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<Button onClick={handleAdd}> <Button onClick={handleAdd}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Novo paciente Novo paciente

View File

@ -1,83 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Search, ChevronDown } from "lucide-react";
import { Plus } from "lucide-react";
import HeaderAgenda from "@/components/features/agenda/HeaderAgenda";
import FooterAgenda from "@/components/features/agenda/FooterAgenda";
export default function ProcedimentoPage() {
const router = useRouter();
const handleSave = () => {
// Lógica de salvar será implementada
console.log("Salvando procedimentos...");
};
const handleCancel = () => {
router.push("/calendar");
};
return (
<div className="flex flex-col h-full bg-background">
<HeaderAgenda />
{/* CORPO */}
<main className="mx-auto w-full max-w-7xl px-8 py-6 space-y-6 flex-1 overflow-auto">
{/* ATENDIMENTOS */}
<section className="space-y-6">
{/* Selo Atendimento com + dentro da bolinha */}
<div className="inline-flex items-center gap-2 border border-border px-3 py-1.5 bg-card text-[12px] rounded-md cursor-pointer hover:bg-muted">
<span className="flex h-5 w-5 items-center justify-center rounded-full border border-border bg-muted text-muted-foreground">
<Plus className="h-3 w-3" strokeWidth={2} />
</span>
<span className="text-foreground">Atendimento</span>
</div>
{/* Traço separador */}
<div className="border-b border-border" />
{/* PROCEDIMENTOS */}
<div className="space-y-1">
<Label className="text-[13px] text-foreground/80">
Procedimentos
</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar"
className="h-10 w-full rounded-md pl-8 pr-8 border-input focus-visible:ring-1 focus-visible:ring-sky-500 focus-visible:border-sky-500"
/>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
{/* Traço separador */}
<div className="border-b border-border" />
{/* OUTRAS DESPESAS */}
<div className="space-y-1">
<Label className="text-[13px] text-foreground/80">
Outras despesas
</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar"
className="h-10 w-full rounded-md pl-8 pr-8 border-input focus-visible:ring-1 focus-visible:ring-sky-500 focus-visible:border-sky-500"
/>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
</section>
</main>
{/* RODAPÉ FIXO */}
<FooterAgenda onSave={handleSave} onCancel={handleCancel} />
</div>
);
}

View File

@ -0,0 +1,387 @@
'use client'
import { useState, useEffect } from 'react'
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 { buscarRelatorioPorId, getDoctorById, buscarMedicosPorIds } from '@/lib/api'
import { ENV_CONFIG } from '@/lib/env-config'
import ProtectedRoute from '@/components/shared/ProtectedRoute'
import { useAuth } from '@/hooks/useAuth'
export default function LaudoPage() {
const router = useRouter()
const params = useParams()
const { user } = useAuth()
const { theme } = useTheme()
const reportId = params.id as string
const isDark = theme === 'dark'
const [report, setReport] = useState<any | null>(null)
const [doctor, setDoctor] = useState<any | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
if (!reportId) return
let mounted = true
async function loadReport() {
try {
setLoading(true)
const reportData = await buscarRelatorioPorId(reportId)
if (!mounted) return
setReport(reportData)
// Load doctor info using the same strategy as paciente/page.tsx
const rd = reportData as any
const maybeId = rd?.doctor_id ?? rd?.created_by ?? rd?.doctor ?? null
if (maybeId) {
try {
// First try: buscarMedicosPorIds
let doctors = await buscarMedicosPorIds([maybeId]).catch(() => [])
if (!doctors || doctors.length === 0) {
// Second try: getDoctorById
const doc = await getDoctorById(String(maybeId)).catch(() => null)
if (doc) doctors = [doc]
}
if (!doctors || doctors.length === 0) {
// Third try: direct REST with user_id filter
const token = (typeof window !== 'undefined')
? (localStorage.getItem('auth_token') || localStorage.getItem('token') ||
sessionStorage.getItem('auth_token') || sessionStorage.getItem('token'))
: null
const headers: Record<string,string> = {
apikey: (ENV_CONFIG as any).SUPABASE_ANON_KEY,
Accept: 'application/json'
}
if (token) headers.Authorization = `Bearer ${token}`
const url = `${(ENV_CONFIG as any).SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(maybeId))}&limit=1`
const res = await fetch(url, { method: 'GET', headers })
if (res && res.status < 400) {
const rows = await res.json().catch(() => [])
if (rows && Array.isArray(rows) && rows.length) {
doctors = rows
}
}
}
if (mounted && doctors && doctors.length > 0) {
setDoctor(doctors[0])
}
} catch (e) {
console.warn('Erro ao carregar dados do profissional:', e)
}
}
} catch (err) {
if (mounted) setError('Erro ao carregar o laudo.')
console.error(err)
} finally {
if (mounted) setLoading(false)
}
}
loadReport()
return () => { mounted = false }
}, [reportId])
const handlePrint = () => {
window.print()
}
if (loading) {
return (
<ProtectedRoute>
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-lg text-muted-foreground">Carregando laudo...</div>
</div>
</ProtectedRoute>
)
}
if (error || !report) {
return (
<ProtectedRoute>
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
<div className="text-lg text-red-500 mb-4">{error || 'Laudo não encontrado.'}</div>
<Button onClick={() => router.back()} variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
</div>
</ProtectedRoute>
)
}
// Extract fields with fallbacks
const reportDate = new Date(report.report_date || report.created_at || Date.now()).toLocaleDateString('pt-BR')
const cid = report.cid ?? report.cid_code ?? report.cidCode ?? report.cie ?? ''
const exam = report.exam ?? report.exame ?? report.especialidade ?? report.report_type ?? ''
const diagnosis = report.diagnosis ?? report.diagnostico ?? report.diagnosis_text ?? report.diagnostico_text ?? ''
const conclusion = report.conclusion ?? report.conclusao ?? report.conclusion_text ?? report.conclusao_text ?? ''
const notesHtml = report.content_html ?? report.conteudo_html ?? report.contentHtml ?? null
const notesText = report.content ?? report.body ?? report.conteudo ?? report.notes ?? report.observacoes ?? ''
// Extract doctor name with multiple fallbacks
let doctorName = ''
if (doctor) {
doctorName = doctor.full_name || doctor.name || doctor.fullName || doctor.doctor_name || ''
}
if (!doctorName) {
const rd = report as any
const tryKeys = [
'doctor_name', 'doctor_full_name', 'doctorFullName', 'doctorName',
'requested_by_name', 'requested_by', 'requester_name', 'requester',
'created_by_name', 'created_by', 'executante', 'executante_name',
]
for (const k of tryKeys) {
const v = rd[k]
if (v !== undefined && v !== null && String(v).trim() !== '') {
doctorName = String(v)
break
}
}
}
return (
<ProtectedRoute>
<div className={`min-h-screen transition-colors duration-300 ${
isDark
? 'bg-gradient-to-br from-slate-950 to-slate-900'
: 'bg-gradient-to-br from-slate-50 to-slate-100'
}`}>
{/* Header Toolbar */}
<div className={`sticky top-0 z-40 transition-colors duration-300 ${
isDark
? 'bg-slate-800 border-slate-700'
: 'bg-white border-slate-200'
} border-b shadow-md`}>
<div className="flex items-center justify-between px-6 py-4">
{/* Left Section */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
className={`${
isDark
? 'text-slate-300 hover:bg-slate-700 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className={`h-8 w-px ${isDark ? 'bg-slate-600' : 'bg-slate-300'}`} />
<div>
<p className={`text-xs font-semibold uppercase tracking-wide ${
isDark ? 'text-slate-400' : 'text-slate-500'
}`}>Laudo Médico</p>
<p className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{doctorName || 'Profissional'}
</p>
</div>
</div>
{/* Right Section */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handlePrint}
title="Imprimir"
className={`${
isDark
? 'text-slate-300 hover:bg-slate-700 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<Printer className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
title="Mais opções"
className={`${
isDark
? 'text-slate-300 hover:bg-slate-700 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<MoreVertical className="w-5 h-5" />
</Button>
</div>
</div>
</div>
{/* Main Content Area */}
<div className="flex justify-center py-12 px-4 min-h-[calc(100vh-80px)]">
{/* Document Container */}
<div className={`w-full max-w-4xl transition-colors duration-300 shadow-2xl rounded-xl overflow-hidden ${
isDark ? 'bg-slate-800' : 'bg-white'
}`}>
{/* Document Content */}
<div className="p-16 space-y-8 print:p-0 print:shadow-none">
{/* Title */}
<div className={`text-center mb-12 pb-8 border-b-2 ${
isDark ? 'border-blue-900' : 'border-blue-200'
}`}>
<h1 className={`text-4xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
RELATÓRIO MÉDICO
</h1>
<div className={`text-sm space-y-1 ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>
<p className="font-medium">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>Data:</span> {reportDate}
</p>
{doctorName && (
<p className="font-medium">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>Profissional:</span>{' '}
<strong className={isDark ? 'text-blue-400' : 'text-blue-700'}>{doctorName}</strong>
</p>
)}
</div>
</div>
{/* Patient/Header Info */}
<div className={`rounded-lg p-6 border transition-colors duration-300 ${
isDark
? 'bg-slate-900 border-slate-700'
: 'bg-slate-50 border-slate-200'
}`}>
<div className="grid grid-cols-2 gap-6 text-sm">
{cid && (
<div>
<label className={`text-xs uppercase font-semibold tracking-wide block mb-2 ${
isDark ? 'text-slate-400' : 'text-slate-600'
}`}>CID</label>
<p className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{cid}
</p>
</div>
)}
{exam && (
<div>
<label className={`text-xs uppercase font-semibold tracking-wide block mb-2 ${
isDark ? 'text-slate-400' : 'text-slate-600'
}`}>Exame / Tipo</label>
<p className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{exam}
</p>
</div>
)}
</div>
</div>
{/* Diagnosis Section */}
{diagnosis && (
<div className="space-y-3">
<h2 className={`text-xl font-bold uppercase tracking-wide ${
isDark ? 'text-blue-400' : 'text-blue-700'
}`}>Diagnóstico</h2>
<div className={`whitespace-pre-wrap text-base leading-relaxed rounded-lg p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}>
{diagnosis}
</div>
</div>
)}
{/* Conclusion Section */}
{conclusion && (
<div className="space-y-3">
<h2 className={`text-xl font-bold uppercase tracking-wide ${
isDark ? 'text-blue-400' : 'text-blue-700'
}`}>Conclusão</h2>
<div className={`whitespace-pre-wrap text-base leading-relaxed rounded-lg p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}>
{conclusion}
</div>
</div>
)}
{/* Notes/Content Section */}
{(notesHtml || notesText) && (
<div className="space-y-3">
<h2 className={`text-xl font-bold uppercase tracking-wide ${
isDark ? 'text-blue-400' : 'text-blue-700'
}`}>Notas do Profissional</h2>
{notesHtml ? (
<div
className={`prose prose-sm max-w-none rounded-lg p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'prose-invert bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}
dangerouslySetInnerHTML={{ __html: String(notesHtml) }}
/>
) : (
<div className={`whitespace-pre-wrap text-base leading-relaxed rounded-lg p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}>
{notesText}
</div>
)}
</div>
)}
{/* Signature Section */}
{report.doctor_signature && (
<div className={`pt-8 border-t-2 ${isDark ? 'border-slate-600' : 'border-slate-300'}`}>
<div className="flex flex-col items-center gap-4">
<div className={`rounded-lg p-4 border transition-colors duration-300 ${
isDark
? 'bg-slate-900 border-slate-600'
: 'bg-slate-100 border-slate-300'
}`}>
<Image
src={report.doctor_signature}
alt="Assinatura do profissional"
width={150}
height={100}
className="h-20 w-auto"
/>
</div>
{doctorName && (
<div className="text-center">
<p className={`text-sm font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{doctorName}
</p>
{doctor?.crm && (
<p className={`text-xs mt-1 ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
CRM: {doctor.crm}
</p>
)}
</div>
)}
</div>
</div>
)}
{/* Footer */}
<div className={`pt-8 border-t-2 text-center space-y-2 ${isDark ? 'border-slate-600' : 'border-slate-300'}`}>
<p className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
Documento gerado em {new Date().toLocaleString('pt-BR')}
</p>
</div>
</div>
</div>
</div>
</div>
</ProtectedRoute>
)
}

View File

@ -932,6 +932,7 @@ export default function PacientePage() {
const [selectedReport, setSelectedReport] = useState<any | null>(null) const [selectedReport, setSelectedReport] = useState<any | null>(null)
function ExamesLaudos() { function ExamesLaudos() {
const router = useRouter()
const [reports, setReports] = useState<any[] | null>(null) const [reports, setReports] = useState<any[] | null>(null)
const [loadingReports, setLoadingReports] = useState(false) const [loadingReports, setLoadingReports] = useState(false)
const [reportsError, setReportsError] = useState<string | null>(null) const [reportsError, setReportsError] = useState<string | null>(null)
@ -1426,7 +1427,7 @@ export default function PacientePage() {
<div className="text-base md:text-base text-muted-foreground mt-1">Data: {new Date(r.report_date || r.created_at || Date.now()).toLocaleDateString('pt-BR')}</div> <div className="text-base md:text-base text-muted-foreground mt-1">Data: {new Date(r.report_date || r.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
</div> </div>
<div className="flex gap-2 mt-2 md:mt-0"> <div className="flex gap-2 mt-2 md:mt-0">
<Button variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={async () => { setSelectedReport(r); }}>{strings.visualizarLaudo}</Button> <Button variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={async () => { router.push(`/laudos/${r.id}`); }}>{strings.visualizarLaudo}</Button>
<Button variant="secondary" className="hover:bg-primary! hover:text-white! transition-colors" onClick={async () => { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado.' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>{strings.compartilhar}</Button> <Button variant="secondary" className="hover:bg-primary! hover:text-white! transition-colors" onClick={async () => { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado.' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>{strings.compartilhar}</Button>
</div> </div>
</div> </div>
@ -1449,95 +1450,7 @@ export default function PacientePage() {
</section> </section>
{/* Modal removed - now using dedicated page /app/laudos/[id] */}
<Dialog open={!!selectedReport} onOpenChange={open => !open && setSelectedReport(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{selectedReport && (
(() => {
const looksLikeIdStr = (s: any) => {
try {
const hexOnly = String(s || '').replace(/[^0-9a-fA-F]/g, '');
const len = (typeof hexOnly === 'string') ? hexOnly.length : (Number(hexOnly) || 0);
return len >= 8;
} catch { return false; }
};
const maybeId = selectedReport?.doctor_id || selectedReport?.created_by || selectedReport?.doctor || null;
const derived = reportDoctorName ? reportTitle(selectedReport, reportDoctorName) : reportTitle(selectedReport);
if (looksLikeIdStr(derived)) {
return <span className="font-semibold text-xl md:text-2xl text-muted-foreground">{strings.carregando}</span>;
}
if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) {
return <span className="font-semibold text-xl md:text-2xl text-muted-foreground">{strings.carregando}</span>;
}
return <span className="font-semibold text-xl md:text-2xl">{derived}</span>;
})()
)}
</DialogTitle>
<DialogDescription className="sr-only">Detalhes do laudo</DialogDescription>
<div className="mt-4 space-y-3 max-h-96 overflow-y-auto">
{selectedReport && (
<>
<div className="text-sm text-muted-foreground">Data: {new Date(selectedReport.report_date || selectedReport.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
{reportDoctorName && <div className="text-sm text-muted-foreground">Profissional: <strong className="text-foreground">{reportDoctorName}</strong></div>}
{/* Standardized laudo sections */}
{(() => {
const cid = selectedReport.cid ?? selectedReport.cid_code ?? selectedReport.cidCode ?? selectedReport.cie ?? '-';
const exam = selectedReport.exam ?? selectedReport.exame ?? selectedReport.especialidade ?? selectedReport.report_type ?? '-';
const diagnosis = selectedReport.diagnosis ?? selectedReport.diagnostico ?? selectedReport.diagnosis_text ?? selectedReport.diagnostico_text ?? '';
const conclusion = selectedReport.conclusion ?? selectedReport.conclusao ?? selectedReport.conclusion_text ?? selectedReport.conclusao_text ?? '';
const notesHtml = selectedReport.content_html ?? selectedReport.conteudo_html ?? selectedReport.contentHtml ?? null;
const notesText = selectedReport.content ?? selectedReport.body ?? selectedReport.conteudo ?? selectedReport.notes ?? selectedReport.observacoes ?? '';
return (
<div className="space-y-3">
<div>
<div className="text-xs text-muted-foreground">CID</div>
<div className="text-foreground">{cid || '-'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Exame</div>
<div className="text-foreground">{exam || '-'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Diagnóstico</div>
<div className="whitespace-pre-line text-foreground">{diagnosis || '-'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Conclusão</div>
<div className="whitespace-pre-line text-foreground">{conclusion || '-'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Notas do Profissional</div>
{notesHtml ? (
<div className="prose max-w-none p-2 bg-muted rounded" dangerouslySetInnerHTML={{ __html: String(notesHtml) }} />
) : (
<div className="whitespace-pre-line text-foreground p-2 bg-muted rounded">{notesText || '-'}</div>
)}
</div>
</div>
);
})()}
{selectedReport.doctor_signature && (
<div className="mt-4 text-sm text-muted-foreground">Assinatura: <Image src={selectedReport.doctor_signature} alt="assinatura" width={40} height={40} className="inline-block h-10 w-auto" /></div>
)}
</>
)}
</div>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSelectedReport(null)}
className="transition duration-200 hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
>
Fechar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
) )
} }

View File

@ -244,8 +244,12 @@ export default function ResultadosClient() {
} }
const onlyAvail = (res?.slots || []).filter((s: any) => s.available) const onlyAvail = (res?.slots || []).filter((s: any) => s.available)
const nowMs = Date.now()
for (const s of onlyAvail) { for (const s of onlyAvail) {
const dt = new Date(s.datetime) const dt = new Date(s.datetime)
const dtMs = dt.getTime()
// Filtrar: só mostrar horários que são posteriores ao horário atual
if (dtMs < nowMs) continue
const key = dt.toISOString().split('T')[0] const key = dt.toISOString().split('T')[0]
const bucket = days.find(d => d.dateKey === key) const bucket = days.find(d => d.dateKey === key)
if (!bucket) continue if (!bucket) continue
@ -260,7 +264,6 @@ export default function ResultadosClient() {
// compute nearest slot (earliest available in the returned window, but after now) // compute nearest slot (earliest available in the returned window, but after now)
let nearest: { iso: string; label: string } | null = null let nearest: { iso: string; label: string } | null = null
const nowMs = Date.now()
const allSlots = days.flatMap(d => d.horarios || []) const allSlots = days.flatMap(d => d.horarios || [])
const futureSorted = allSlots const futureSorted = allSlots
.map(s => ({ ...s, ms: new Date(s.iso).getTime() })) .map(s => ({ ...s, ms: new Date(s.iso).getTime() }))
@ -582,17 +585,24 @@ export default function ResultadosClient() {
}) })
const merged = Array.from(mergedMap.values()).sort((a:any,b:any) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime()) const merged = Array.from(mergedMap.values()).sort((a:any,b:any) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
const formatted = (merged || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) })) const nowMs = Date.now()
// Filtrar: só mostrar horários que são posteriores ao horário atual
const futureOnly = merged.filter((s: any) => new Date(s.datetime).getTime() >= nowMs)
const formatted = (futureOnly || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }))
setMoreTimesSlots(formatted) setMoreTimesSlots(formatted)
return formatted return formatted
} else { } else {
const slots = (av.slots || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) })) const nowMs = Date.now()
// Filtrar: só mostrar horários que são posteriores ao horário atual
const slots = (av.slots || []).filter((s:any) => new Date(s.datetime).getTime() >= nowMs).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }))
setMoreTimesSlots(slots) setMoreTimesSlots(slots)
return slots return slots
} }
} catch (e) { } catch (e) {
console.warn('[ResultadosClient] erro ao filtrar por disponibilidades', e) console.warn('[ResultadosClient] erro ao filtrar por disponibilidades', e)
const slots = (av.slots || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) })) const nowMs = Date.now()
// Filtrar: só mostrar horários que são posteriores ao horário atual
const slots = (av.slots || []).filter((s:any) => new Date(s.datetime).getTime() >= nowMs).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }))
setMoreTimesSlots(slots) setMoreTimesSlots(slots)
return slots return slots
} }

View File

@ -7,6 +7,7 @@ import ProtectedRoute from "@/components/shared/ProtectedRoute";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
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 } from "@/lib/api";
import { ENV_CONFIG } from '@/lib/env-config';
import { useReports } from "@/hooks/useReports"; import { useReports } from "@/hooks/useReports";
import { CreateReportData } from "@/types/report-types"; import { CreateReportData } from "@/types/report-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -36,7 +37,6 @@ import {
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { ENV_CONFIG } from '@/lib/env-config';
import dayGridPlugin from "@fullcalendar/daygrid"; import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid"; import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction"; import interactionPlugin from "@fullcalendar/interaction";
@ -182,7 +182,7 @@ const ProfissionalPage = () => {
const q = `doctor_id=eq.${encodeURIComponent(String(resolvedDoctorId))}&select=patient_id&limit=200`; const q = `doctor_id=eq.${encodeURIComponent(String(resolvedDoctorId))}&select=patient_id&limit=200`;
const appts = await listarAgendamentos(q).catch(() => []); const appts = await listarAgendamentos(q).catch(() => []);
for (const a of (appts || [])) { for (const a of (appts || [])) {
const pid = a.patient_id ?? a.patient ?? a.patient_id_raw ?? null; const pid = (a as any).patient_id ?? null;
if (pid) patientIdSet.add(String(pid)); if (pid) patientIdSet.add(String(pid));
} }
} catch (e) { } catch (e) {
@ -211,6 +211,7 @@ const ProfissionalPage = () => {
})(); })();
return () => { mounted = false; }; return () => { mounted = false; };
// Re-run when user id becomes available so patients assigned to the logged-in doctor are loaded // Re-run when user id becomes available so patients assigned to the logged-in doctor are loaded
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]); }, [user?.id]);
// Carregar perfil do médico correspondente ao usuário logado // Carregar perfil do médico correspondente ao usuário logado
@ -354,9 +355,9 @@ const ProfissionalPage = () => {
} }
} }
const pid = a.patient_id || a.patient || a.patient_id_raw || a.patientId || null; const pid = a.patient_id || (a as any).patient || a.patient_id_raw || a.patientId || null;
const patientObj = pid ? patientMap.get(String(pid)) : null; const patientObj = pid ? patientMap.get(String(pid)) : null;
const patientName = patientObj?.full_name || a.patient || a.patient_name || String(pid) || 'Paciente'; const patientName = patientObj?.full_name || (a as any).patient || a.patient_name || String(pid) || 'Paciente';
const patientIdVal = pid || null; const patientIdVal = pid || null;
return { return {
@ -429,6 +430,9 @@ const ProfissionalPage = () => {
const [commPhoneNumber, setCommPhoneNumber] = useState(''); const [commPhoneNumber, setCommPhoneNumber] = useState('');
const [commMessage, setCommMessage] = useState(''); const [commMessage, setCommMessage] = useState('');
const [commPatientId, setCommPatientId] = useState<string | null>(null); const [commPatientId, setCommPatientId] = useState<string | null>(null);
const [commResponses, setCommResponses] = useState<any[]>([]);
const [commResponsesLoading, setCommResponsesLoading] = useState(false);
const [commResponsesError, setCommResponsesError] = useState<string | null>(null);
const [smsSending, setSmsSending] = useState(false); const [smsSending, setSmsSending] = useState(false);
const handleSave = async (event: React.MouseEvent<HTMLButtonElement>) => { const handleSave = async (event: React.MouseEvent<HTMLButtonElement>) => {
@ -520,6 +524,68 @@ const ProfissionalPage = () => {
} }
}; };
const loadCommResponses = async (patientId?: string) => {
const pid = patientId ?? commPatientId;
if (!pid) {
setCommResponses([]);
setCommResponsesError('Selecione um paciente para ver respostas');
return;
}
setCommResponsesLoading(true);
setCommResponsesError(null);
try {
// 1) tentar buscar por patient_id (o comportamento ideal)
const qs = new URLSearchParams();
qs.set('patient_id', `eq.${String(pid)}`);
qs.set('order', 'created_at.desc');
const url = `${(ENV_CONFIG as any).REST}/messages?${qs.toString()}`;
const headers: Record<string,string> = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
if ((ENV_CONFIG as any)?.SUPABASE_ANON_KEY) headers['apikey'] = (ENV_CONFIG as any).SUPABASE_ANON_KEY;
const r = await fetch(url, { method: 'GET', headers });
let data = await r.json().catch(() => []);
data = Array.isArray(data) ? data : [];
// 2) Se não houver mensagens por patient_id, tentar buscar por número (from/to)
if ((!data || data.length === 0) && commPhoneNumber) {
try {
const norm = normalizePhoneNumber(commPhoneNumber);
if (norm) {
// Primeiro tenta buscar mensagens onde `from` é o número
const qsFrom = new URLSearchParams();
qsFrom.set('from', `eq.${String(norm)}`);
qsFrom.set('order', 'created_at.desc');
const urlFrom = `${(ENV_CONFIG as any).REST}/messages?${qsFrom.toString()}`;
const rf = await fetch(urlFrom, { method: 'GET', headers });
const dataFrom = await rf.json().catch(() => []);
if (Array.isArray(dataFrom) && dataFrom.length) {
data = dataFrom;
} else {
// se nada, tenta `to` (caso o provedor grave a direção inversa)
const qsTo = new URLSearchParams();
qsTo.set('to', `eq.${String(norm)}`);
qsTo.set('order', 'created_at.desc');
const urlTo = `${(ENV_CONFIG as any).REST}/messages?${qsTo.toString()}`;
const rt = await fetch(urlTo, { method: 'GET', headers });
const dataTo = await rt.json().catch(() => []);
if (Array.isArray(dataTo) && dataTo.length) data = dataTo;
}
}
} catch (phoneErr) {
// não bloqueara o fluxo principal; apenas log
console.warn('[ProfissionalPage] fallback por telefone falhou', phoneErr);
}
}
setCommResponses(Array.isArray(data) ? data : []);
} catch (e: any) {
setCommResponsesError(String(e?.message || e || 'Falha ao buscar respostas'));
setCommResponses([]);
} finally {
setCommResponsesLoading(false);
}
};
const handleEditarLaudo = (paciente: any) => { const handleEditarLaudo = (paciente: any) => {
@ -720,14 +786,14 @@ const ProfissionalPage = () => {
const todayEvents = getTodayEvents(); const todayEvents = getTodayEvents();
return ( return (
<section className="bg-card shadow-md rounded-lg border border-border p-6"> <section className="bg-card shadow-md rounded-lg border border-border p-6 overflow-x-hidden">{/* adicionada overflow-x-hidden */}
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">Agenda do Dia</h2> <h2 className="text-xl sm:text-2xl font-bold">Agenda do Dia</h2>
</div> </div>
{/* Navegação de Data */} {/* Navegação de Data - Responsiva */}
<div className="flex items-center justify-between mb-6 p-4 bg-blue-50 rounded-lg dark:bg-muted"> <div className="flex items-center justify-between mb-6 p-3 sm:p-4 bg-blue-50 rounded-lg dark:bg-muted flex-wrap gap-2 sm:gap-4">
<div className="flex items-center space-x-4"> <div className="flex items-center gap-2 sm:gap-4">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -736,7 +802,7 @@ const ProfissionalPage = () => {
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<h3 className="text-lg font-medium text-foreground"> <h3 className="text-base sm:text-lg font-medium text-foreground whitespace-nowrap line-clamp-2">
{formatDate(currentCalendarDate)} {formatDate(currentCalendarDate)}
</h3> </h3>
<Button <Button
@ -747,20 +813,19 @@ const ProfissionalPage = () => {
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="text-sm text-gray-600 dark:text-muted-foreground"> <div className="text-xs sm:text-sm text-gray-600 dark:text-muted-foreground whitespace-nowrap">
{todayEvents.length} consulta{todayEvents.length !== 1 ? 's' : ''} agendada{todayEvents.length !== 1 ? 's' : ''} {todayEvents.length} consulta{todayEvents.length !== 1 ? 's' : ''}
</div> </div>
</div> </div>
{/* Lista de Pacientes do Dia */} {/* Lista de Pacientes do Dia */}
<div className="space-y-4 max-h-[calc(100vh-450px)] overflow-y-auto pr-2"> <div className="space-y-4 max-h-[calc(100vh-450px)] overflow-y-auto overflow-x-hidden pr-2">{/* adicionada overflow-x-hidden */}
{todayEvents.length === 0 ? ( {todayEvents.length === 0 ? (
<div className="text-center py-8 text-gray-600 dark:text-muted-foreground"> <div className="text-center py-6 sm:py-8 text-gray-600 dark:text-muted-foreground">
<CalendarIcon className="h-12 w-12 mx-auto mb-4 text-gray-400 dark:text-muted-foreground/50" /> <CalendarIcon className="h-10 sm:h-12 w-10 sm:w-12 mx-auto mb-3 sm:mb-4 text-gray-400 dark:text-muted-foreground/50" />
<p className="text-lg mb-2">Nenhuma consulta agendada para este dia</p> <p className="text-base sm:text-lg mb-2">Nenhuma consulta agendada para este dia</p>
<p className="text-sm">Agenda livre para este dia</p> <p className="text-xs sm:text-sm">Agenda livre para este dia</p>
</div> </div>
) : ( ) : (
todayEvents.map((appointment) => { todayEvents.map((appointment) => {
@ -768,47 +833,46 @@ const ProfissionalPage = () => {
return ( return (
<div <div
key={appointment.id} key={appointment.id}
className="border-l-4 border-t border-r border-b p-4 rounded-lg shadow-sm bg-card border-border" className="border-l-4 border-t border-r border-b p-3 sm:p-4 rounded-lg shadow-sm bg-card border-border"
style={{ borderLeftColor: getStatusColor(appointment.type) }} style={{ borderLeftColor: getStatusColor(appointment.type) }}
> >
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-center"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2 sm:gap-4 items-center">
<div className="flex items-center"> <div className="flex items-center gap-2">
<div <div
className="w-3 h-3 rounded-full mr-3" className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: getStatusColor(appointment.type) }} style={{ backgroundColor: getStatusColor(appointment.type) }}
></div> ></div>
<div> <div className="min-w-0">
<div className="font-medium flex items-center"> <div className="font-medium text-sm sm:text-base flex items-center gap-2">
<User className="h-4 w-4 mr-2 text-gray-500 dark:text-muted-foreground" /> <User className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500 dark:text-muted-foreground flex-shrink-0" />
{appointment.title} <span className="truncate">{appointment.title}</span>
</div> </div>
{paciente && ( {paciente && (
<div className="text-sm text-gray-600 dark:text-muted-foreground"> <div className="text-xs text-gray-600 dark:text-muted-foreground truncate">
CPF: {getPatientCpf(paciente)} {getPatientAge(paciente)} anos CPF: {getPatientCpf(paciente)} {getPatientAge(paciente)} anos
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center"> <div className="flex items-center gap-2">
<Clock className="h-4 w-4 mr-2 text-gray-500 dark:text-muted-foreground" /> <Clock className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500 dark:text-muted-foreground flex-shrink-0" />
<span className="font-medium">{appointment.time}</span> <span className="font-medium text-sm sm:text-base">{appointment.time}</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<div <div
className="px-3 py-1 rounded-full text-sm font-medium text-white" className="px-2 sm:px-3 py-1 rounded-full text-xs sm:text-sm font-medium text-white whitespace-nowrap"
style={{ backgroundColor: getStatusColor(appointment.type) }} style={{ backgroundColor: getStatusColor(appointment.type) }}
> >
{appointment.type} {appointment.type}
</div> </div>
</div> </div>
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end">
<div className="relative group"> <div className="relative group">
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-md opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-50"> <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-md opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-50">
Ver informações do paciente Ver informações do paciente
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div> <div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@ -1594,7 +1658,7 @@ const ProfissionalPage = () => {
function LaudoViewer({ laudo, onClose }: { laudo: any; onClose: () => void }) { function LaudoViewer({ laudo, onClose }: { laudo: any; onClose: () => void }) {
return ( return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div className="bg-background rounded-lg shadow-xl w-full h-full md:h-auto md:rounded-lg md:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"> <div className="bg-background rounded-lg shadow-xl w-full h-full md:rounded-lg md:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border"> <div className="flex items-center justify-between p-4 border-b border-border">
<div> <div>
@ -2625,19 +2689,19 @@ const ProfissionalPage = () => {
const renderComunicacaoSection = () => ( const renderComunicacaoSection = () => (
<div className="bg-card shadow-md rounded-lg p-6"> <div className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6 w-full">
<h2 className="text-2xl font-bold mb-4 text-foreground">Comunicação com o Paciente</h2> <h2 className="text-xl sm:text-2xl font-bold mb-4">Comunicação com o Paciente</h2>
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="patientSelect">Paciente *</Label> <Label htmlFor="patientSelect" className="text-xs sm:text-sm">Paciente *</Label>
<Select <Select
value={commPatientId ?? ''} value={commPatientId ?? ''}
onValueChange={(val: string) => { onValueChange={(val: string) => {
// Radix Select does not allow an Item with empty string as value.
// Use a sentinel value "__none" for the "-- nenhum --" choice and map it to null here.
const v = val === "__none" ? null : (val || null); const v = val === "__none" ? null : (val || null);
setCommPatientId(v); setCommPatientId(v);
setCommResponses([]);
setCommResponsesError(null);
if (!v) { if (!v) {
setCommPhoneNumber(''); setCommPhoneNumber('');
return; return;
@ -2655,9 +2719,10 @@ const ProfissionalPage = () => {
console.warn('[ProfissionalPage] erro ao preencher telefone do paciente selecionado', e); console.warn('[ProfissionalPage] erro ao preencher telefone do paciente selecionado', e);
setCommPhoneNumber(''); setCommPhoneNumber('');
} }
void loadCommResponses(String(v));
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full text-xs sm:text-sm">
<SelectValue placeholder="-- nenhum --" /> <SelectValue placeholder="-- nenhum --" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -2672,20 +2737,49 @@ const ProfissionalPage = () => {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="phoneNumber">Número (phone_number)</Label> <Label htmlFor="phoneNumber" className="text-xs sm:text-sm">Número (phone_number)</Label>
<Input id="phoneNumber" placeholder="+5511999999999" value={commPhoneNumber} readOnly disabled className="bg-muted/50" /> <Input id="phoneNumber" placeholder="+5511999999999" value={commPhoneNumber} readOnly disabled className="bg-muted/50 text-xs sm:text-sm" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="message">Mensagem (message)</Label> <Label htmlFor="message" className="text-xs sm:text-sm">Mensagem (message)</Label>
<textarea id="message" className="w-full p-2 border rounded" rows={5} value={commMessage} onChange={(e) => setCommMessage(e.target.value)} /> <textarea id="message" className="w-full p-2 sm:p-3 border rounded text-xs sm:text-sm" rows={5} value={commMessage} onChange={(e) => setCommMessage(e.target.value)} />
</div> </div>
<div className="flex justify-end mt-6"> <div className="flex justify-end mt-6">
<Button onClick={handleSave} disabled={smsSending}> <Button onClick={handleSave} disabled={smsSending} size="sm" className="text-xs sm:text-sm">
{smsSending ? 'Enviando...' : 'Enviar SMS'} {smsSending ? 'Enviando...' : 'Enviar SMS'}
</Button> </Button>
</div> </div>
{/* Respostas do paciente */}
<div className="mt-6 border-t border-border pt-4">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 className="text-base sm:text-lg font-semibold">Últimas respostas do paciente</h3>
<div>
<Button size="sm" variant="outline" onClick={() => void loadCommResponses()} disabled={!commPatientId || commResponsesLoading} className="text-xs sm:text-sm">
{commResponsesLoading ? 'Atualizando...' : 'Atualizar respostas'}
</Button>
</div>
</div>
{commResponsesLoading ? (
<div className="text-xs sm:text-sm text-muted-foreground">Carregando respostas...</div>
) : commResponsesError ? (
<div className="text-xs sm:text-sm text-red-500">{commResponsesError}</div>
) : (commResponses && commResponses.length) ? (
<div className="space-y-2">
{commResponses.map((m:any) => (
<div key={m.id} className="p-3 rounded border border-border bg-muted/10">
<div className="text-xs text-muted-foreground">{m.created_at ? new Date(m.created_at).toLocaleString() : ''}</div>
<div className="mt-1 whitespace-pre-wrap text-xs sm:text-sm">{m.body ?? m.content ?? m.message ?? '-'}</div>
</div>
))}
</div>
) : (
<div className="text-xs sm:text-sm text-muted-foreground">Nenhuma resposta encontrada para o paciente selecionado.</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -2693,24 +2787,24 @@ const ProfissionalPage = () => {
const renderPerfilSection = () => ( const renderPerfilSection = () => (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-10 md:px-8"> <div className="mx-auto flex w-full max-w-6xl flex-col gap-4 sm:gap-6 px-0 py-4 sm:py-8 md:px-4">
{/* Header com Título e Botão */} {/* Header com Título e Botão */}
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div> <div>
<h2 className="text-3xl font-bold">Meu Perfil</h2> <h2 className="text-2xl sm:text-3xl font-bold">Meu Perfil</h2>
<p className="text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p> <p className="text-xs sm:text-sm text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
</div> </div>
{!isEditingProfile ? ( {!isEditingProfile ? (
<Button <Button
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700 text-xs sm:text-sm w-full sm:w-auto"
onClick={() => setIsEditingProfile(true)} onClick={() => setIsEditingProfile(true)}
> >
Editar Perfil Editar Perfil
</Button> </Button>
) : ( ) : (
<div className="flex gap-2"> <div className="flex gap-2 w-full sm:w-auto">
<Button <Button
className="bg-green-600 hover:bg-green-700" className="bg-green-600 hover:bg-green-700 flex-1 sm:flex-initial text-xs sm:text-sm"
onClick={handleSaveProfile} onClick={handleSaveProfile}
> >
Salvar Salvar
@ -2718,6 +2812,7 @@ const ProfissionalPage = () => {
<Button <Button
variant="outline" variant="outline"
onClick={handleCancelEdit} onClick={handleCancelEdit}
className="flex-1 sm:flex-initial text-xs sm:text-sm"
> >
Cancelar Cancelar
</Button> </Button>
@ -2725,21 +2820,21 @@ const ProfissionalPage = () => {
)} )}
</div> </div>
{/* Grid de 3 colunas (2 + 1) */} {/* Grid de 3 colunas (2 + 1) - Responsivo */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
{/* Coluna Esquerda - Informações Pessoais */} {/* Coluna Esquerda - Informações Pessoais */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-4 sm:space-y-6">
{/* Informações Pessoais */} {/* Informações Pessoais */}
<div className="border border-border rounded-lg p-6"> <div className="border border-border rounded-lg p-4 sm:p-6">
<h3 className="text-lg font-semibold mb-4">Informações Pessoais</h3> <h3 className="text-base sm:text-lg font-semibold mb-4">Informações Pessoais</h3>
<div className="space-y-4"> <div className="space-y-4">
{/* Nome Completo */} {/* Nome Completo */}
<div> <div>
<Label className="text-sm font-medium text-muted-foreground"> <Label className="text-xs sm:text-sm font-medium text-muted-foreground">
Nome Completo Nome Completo
</Label> </Label>
<div className="mt-2 p-3 bg-muted rounded text-foreground font-medium"> <div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground font-medium">
{profileData.nome || "Não preenchido"} {profileData.nome || "Não preenchido"}
</div> </div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
@ -2749,18 +2844,18 @@ const ProfissionalPage = () => {
{/* Email */} {/* Email */}
<div> <div>
<Label className="text-sm font-medium text-muted-foreground"> <Label className="text-xs sm:text-sm font-medium text-muted-foreground">
Email Email
</Label> </Label>
{isEditingProfile ? ( {isEditingProfile ? (
<Input <Input
value={profileData.email || ""} value={profileData.email || ""}
onChange={(e) => handleProfileChange('email', e.target.value)} onChange={(e) => handleProfileChange('email', e.target.value)}
className="mt-2" className="mt-2 text-xs sm:text-sm"
type="email" type="email"
/> />
) : ( ) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground"> <div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
{profileData.email || "Não preenchido"} {profileData.email || "Não preenchido"}
</div> </div>
)} )}
@ -2768,18 +2863,18 @@ const ProfissionalPage = () => {
{/* Telefone */} {/* Telefone */}
<div> <div>
<Label className="text-sm font-medium text-muted-foreground"> <Label className="text-xs sm:text-sm font-medium text-muted-foreground">
Telefone Telefone
</Label> </Label>
{isEditingProfile ? ( {isEditingProfile ? (
<Input <Input
value={profileData.telefone || ""} value={profileData.telefone || ""}
onChange={(e) => handleProfileChange('telefone', e.target.value)} onChange={(e) => handleProfileChange('telefone', e.target.value)}
className="mt-2" className="mt-2 text-xs sm:text-sm"
placeholder="(00) 00000-0000" placeholder="(00) 00000-0000"
/> />
) : ( ) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground"> <div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
{profileData.telefone || "Não preenchido"} {profileData.telefone || "Não preenchido"}
</div> </div>
)} )}
@ -2787,10 +2882,10 @@ const ProfissionalPage = () => {
{/* CRM */} {/* CRM */}
<div> <div>
<Label className="text-sm font-medium text-muted-foreground"> <Label className="text-xs sm:text-sm font-medium text-muted-foreground">
CRM CRM
</Label> </Label>
<div className="mt-2 p-3 bg-muted rounded text-foreground font-medium"> <div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground font-medium">
{profileData.crm || "Não preenchido"} {profileData.crm || "Não preenchido"}
</div> </div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
@ -2800,18 +2895,18 @@ const ProfissionalPage = () => {
{/* Especialidade */} {/* Especialidade */}
<div> <div>
<Label className="text-sm font-medium text-muted-foreground"> <Label className="text-xs sm:text-sm font-medium text-muted-foreground">
Especialidade Especialidade
</Label> </Label>
{isEditingProfile ? ( {isEditingProfile ? (
<Input <Input
value={profileData.especialidade || ""} value={profileData.especialidade || ""}
onChange={(e) => handleProfileChange('especialidade', e.target.value)} onChange={(e) => handleProfileChange('especialidade', e.target.value)}
className="mt-2" className="mt-2 text-xs sm:text-sm"
placeholder="Ex: Cardiologia" placeholder="Ex: Cardiologia"
/> />
) : ( ) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground"> <div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
{profileData.especialidade || "Não preenchido"} {profileData.especialidade || "Não preenchido"}
</div> </div>
)} )}
@ -2820,24 +2915,24 @@ const ProfissionalPage = () => {
</div> </div>
{/* Endereço e Contato */} {/* Endereço e Contato */}
<div className="border border-border rounded-lg p-6"> <div className="border border-border rounded-lg p-4 sm:p-6">
<h3 className="text-lg font-semibold mb-4">Endereço e Contato</h3> <h3 className="text-base sm:text-lg font-semibold mb-4">Endereço e Contato</h3>
<div className="space-y-4"> <div className="space-y-4">
{/* Logradouro */} {/* Logradouro */}
<div> <div>
<Label className="text-sm font-medium text-muted-foreground"> <Label className="text-xs sm:text-sm font-medium text-muted-foreground">
Logradouro Logradouro
</Label> </Label>
{isEditingProfile ? ( {isEditingProfile ? (
<Input <Input
value={profileData.endereco || ""} value={profileData.endereco || ""}
onChange={(e) => handleProfileChange('endereco', e.target.value)} onChange={(e) => handleProfileChange('endereco', e.target.value)}
className="mt-2" className="mt-2 text-xs sm:text-sm"
placeholder="Rua, avenida, etc." placeholder="Rua, avenida, etc."
/> />
) : ( ) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground"> <div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
{profileData.endereco || "Não preenchido"} {profileData.endereco || "Não preenchido"}
</div> </div>
)} )}
@ -2845,18 +2940,18 @@ const ProfissionalPage = () => {
{/* Cidade */} {/* Cidade */}
<div> <div>
<Label className="text-sm font-medium text-muted-foreground"> <Label className="text-xs sm:text-sm font-medium text-muted-foreground">
Cidade Cidade
</Label> </Label>
{isEditingProfile ? ( {isEditingProfile ? (
<Input <Input
value={profileData.cidade || ""} value={profileData.cidade || ""}
onChange={(e) => handleProfileChange('cidade', e.target.value)} onChange={(e) => handleProfileChange('cidade', e.target.value)}
className="mt-2" className="mt-2 text-xs sm:text-sm"
placeholder="São Paulo" placeholder="São Paulo"
/> />
) : ( ) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground"> <div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
{profileData.cidade || "Não preenchido"} {profileData.cidade || "Não preenchido"}
</div> </div>
)} )}
@ -2864,18 +2959,18 @@ const ProfissionalPage = () => {
{/* CEP */} {/* CEP */}
<div> <div>
<Label className="text-sm font-medium text-muted-foreground"> <Label className="text-xs sm:text-sm font-medium text-muted-foreground">
CEP CEP
</Label> </Label>
{isEditingProfile ? ( {isEditingProfile ? (
<Input <Input
value={profileData.cep || ""} value={profileData.cep || ""}
onChange={(e) => handleProfileChange('cep', e.target.value)} onChange={(e) => handleProfileChange('cep', e.target.value)}
className="mt-2" className="mt-2 text-xs sm:text-sm"
placeholder="00000-000" placeholder="00000-000"
/> />
) : ( ) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground"> <div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
{profileData.cep || "Não preenchido"} {profileData.cep || "Não preenchido"}
</div> </div>
)} )}
@ -2886,18 +2981,18 @@ const ProfissionalPage = () => {
{/* Coluna Direita - Foto do Perfil */} {/* Coluna Direita - Foto do Perfil */}
<div> <div>
<div className="border border-border rounded-lg p-6"> <div className="border border-border rounded-lg p-4 sm:p-6">
<h3 className="text-lg font-semibold mb-4">Foto do Perfil</h3> <h3 className="text-base sm:text-lg font-semibold mb-4">Foto do Perfil</h3>
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Avatar className="h-24 w-24"> <Avatar className="h-20 w-20 sm:h-24 sm:w-24">
<AvatarFallback className="bg-primary text-primary-foreground text-2xl font-bold"> <AvatarFallback className="bg-primary text-primary-foreground text-lg sm:text-2xl font-bold">
{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'}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<p className="text-sm text-muted-foreground"> <p className="text-xs sm:text-sm text-muted-foreground">
{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'}
</p> </p>
</div> </div>
@ -2915,23 +3010,23 @@ const ProfissionalPage = () => {
return renderCalendarioSection(); return renderCalendarioSection();
case 'pacientes': case 'pacientes':
return ( return (
<section className="bg-card shadow-md rounded-lg border border-border p-6"> <section className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6 w-full">
<h2 className="text-2xl font-bold mb-4">Pacientes</h2> <h2 className="text-xl sm:text-2xl font-bold mb-4">Pacientes</h2>
<div className="overflow-x-auto"> <div className="overflow-x-auto w-full">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Nome</TableHead> <TableHead className="text-xs sm:text-sm">Nome</TableHead>
<TableHead>CPF</TableHead> <TableHead className="text-xs sm:text-sm">CPF</TableHead>
<TableHead>Idade</TableHead> <TableHead className="text-xs sm:text-sm">Idade</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{pacientes.map((paciente) => ( {pacientes.map((paciente) => (
<TableRow key={paciente.id ?? paciente.cpf}> <TableRow key={paciente.id ?? paciente.cpf}>
<TableCell>{paciente.nome}</TableCell> <TableCell className="text-xs sm:text-sm">{paciente.nome}</TableCell>
<TableCell>{paciente.cpf}</TableCell> <TableCell className="text-xs sm:text-sm">{paciente.cpf}</TableCell>
<TableCell>{getPatientAge(paciente) ? `${getPatientAge(paciente)} anos` : '-'}</TableCell> <TableCell className="text-xs sm:text-sm">{getPatientAge(paciente) ? `${getPatientAge(paciente)} anos` : '-'}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -2941,8 +3036,8 @@ const ProfissionalPage = () => {
); );
case 'laudos': case 'laudos':
return renderLaudosSection(); return renderLaudosSection();
// case 'comunicacao': case 'comunicacao':
// return renderComunicacaoSection(); return renderComunicacaoSection();
case 'perfil': case 'perfil':
return renderPerfilSection(); return renderPerfilSection();
default: default:
@ -2950,82 +3045,139 @@ const ProfissionalPage = () => {
} }
}; };
const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<ProtectedRoute requiredUserType={["profissional"]}> <ProtectedRoute requiredUserType={["profissional"]}>
<div className="container mx-auto px-4 py-8"> <div className="flex flex-col min-h-screen">
<header className="bg-card shadow-md rounded-lg border border-border p-4 mb-6 flex items-center justify-between"> {/* Header - Responsivo */}
<div className="flex items-center gap-4"> <header className="bg-card shadow-md border-b border-border sticky top-0 z-40">
<Avatar className="h-12 w-12"> <div className="px-4 py-3 md:px-6">
<AvatarImage src={(profileData as any).fotoUrl || undefined} alt={profileData.nome} /> <div className="flex items-center justify-between gap-4 flex-wrap md:flex-nowrap">
<AvatarFallback className="bg-muted"> {/* Logo/Avatar Section */}
<User className="h-5 w-5" /> <div className="flex items-center gap-3 min-w-0 flex-1 md:flex-none">
</AvatarFallback> <Avatar className="h-10 w-10 md:h-12 md:w-12 flex-shrink-0">
</Avatar> <AvatarImage src={(profileData as any).fotoUrl || undefined} alt={profileData.nome} />
<div className="min-w-0"> <AvatarFallback className="bg-muted text-xs md:text-sm">
<p className="text-sm text-muted-foreground truncate">Conta do profissional</p> <User className="h-4 w-4 md:h-5 md:w-5" />
<h2 className="text-lg font-semibold leading-none truncate">{profileData.nome}</h2> </AvatarFallback>
<p className="text-sm text-muted-foreground truncate">{(profileData.crm ? `CRM: ${profileData.crm}` : '') + (profileData.especialidade ? `${profileData.especialidade}` : '')}</p> </Avatar>
{user?.email && ( <div className="min-w-0">
<p className="text-xs text-muted-foreground truncate">Logado como: {user.email}</p> <p className="text-xs md:text-sm text-muted-foreground truncate">Profissional de Saúde</p>
)} <h2 className="text-sm md:text-base font-semibold leading-none truncate">{profileData.nome}</h2>
<p className="text-xs text-muted-foreground truncate line-clamp-1">{(profileData.crm ? `CRM: ${profileData.crm}` : '') + (profileData.especialidade ? `${profileData.especialidade}` : '')}</p>
</div>
</div>
{/* Actions - Mobile hidden on small screens */}
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
<SimpleThemeToggle />
{/* Desktop Buttons - Hidden on mobile */}
<div className="hidden sm:flex items-center gap-1 md:gap-2">
<Button asChild variant="default" size="sm" className="bg-primary hover:bg-primary/90 text-primary-foreground rounded shadow-sm shadow-blue-500/10 border border-primary text-xs md:text-sm px-2 md:px-4 h-8 md:h-9">
<Link href="/" aria-label="Início">Início</Link>
</Button>
<Button
variant="outline"
onClick={logout}
size="sm"
className="text-red-600 border-red-600 hover:bg-red-50 cursor-pointer dark:hover:bg-red-600 dark:hover:text-white text-xs md:text-sm px-2 md:px-4 h-8 md:h-9"
>
Sair
</Button>
</div>
{/* Mobile Menu Button */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="md:hidden p-2 hover:bg-muted rounded transition-colors flex-shrink-0"
aria-label="Menu"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sidebarOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
</svg>
</button>
</div>
</div> </div>
</div>
<div className="flex items-center gap-2"> {/* Mobile menu items - visible when sidebarOpen */}
<SimpleThemeToggle /> {sidebarOpen && (
<Button asChild variant="default" className="mr-2 bg-primary hover:bg-primary/90 text-primary-foreground px-3 py-1 rounded shadow-sm shadow-blue-500/10 border border-primary"> <div className="md:hidden flex flex-col gap-2 mt-3 pt-3 border-t border-border">
<Link href="/" aria-label="Início">Início</Link> <Button asChild variant="default" size="sm" className="w-full bg-primary hover:bg-primary/90 text-primary-foreground">
</Button> <Link href="/">Início</Link>
<Button </Button>
variant="outline" <Button
onClick={logout} variant="outline"
className="text-red-600 border-red-600 hover:bg-red-50 cursor-pointer dark:hover:bg-red-600 dark:hover:text-white" onClick={logout}
> size="sm"
Sair className="w-full text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-600 dark:hover:text-white"
</Button> >
Sair
</Button>
</div>
)}
</div> </div>
</header> </header>
<div className="grid grid-cols-1 md:grid-cols-[220px_1fr] gap-6"> <div className="flex-1 flex flex-col md:flex-row gap-0 md:gap-6 px-3 sm:px-4 md:px-8 py-4 md:py-8">
{} {/* Sidebar - Mobile Drawer or Desktop */}
<aside className="md:sticky md:top-8 h-fit"> <aside className={`${
<nav className="bg-card shadow-md rounded-lg border border-border p-3 space-y-1"> sidebarOpen ? 'block' : 'hidden md:block'
} md:sticky md:top-24 md:h-fit w-full md:w-[220px] mb-4 md:mb-0`}>
<nav className="bg-card shadow-md rounded-lg border border-border p-2 md:p-3 space-y-1">
<Button <Button
variant={activeSection === 'calendario' ? 'default' : 'ghost'} variant={activeSection === 'calendario' ? 'default' : 'ghost'}
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer" className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
onClick={() => setActiveSection('calendario')} onClick={() => {
setActiveSection('calendario');
setSidebarOpen(false);
}}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
Calendário <span className="hidden sm:inline">Calendário</span>
<span className="sm:hidden">Calendário</span>
</Button> </Button>
<Button <Button
variant={activeSection === 'pacientes' ? 'default' : 'ghost'} variant={activeSection === 'pacientes' ? 'default' : 'ghost'}
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer" className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
onClick={() => setActiveSection('pacientes')} onClick={() => {
setActiveSection('pacientes');
setSidebarOpen(false);
}}
> >
<Users className="mr-2 h-4 w-4" /> <Users className="mr-2 h-4 w-4" />
Pacientes Pacientes
</Button> </Button>
<Button <Button
variant={activeSection === 'laudos' ? 'default' : 'ghost'} variant={activeSection === 'laudos' ? 'default' : 'ghost'}
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer" className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
onClick={() => setActiveSection('laudos')} onClick={() => {
setActiveSection('laudos');
setSidebarOpen(false);
}}
> >
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
Laudos Laudos
</Button> </Button>
{/* Comunicação removida - campos embaixo do calendário */} <Button
{/* <Button
variant={activeSection === 'comunicacao' ? 'default' : 'ghost'} variant={activeSection === 'comunicacao' ? 'default' : 'ghost'}
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer" className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
onClick={() => setActiveSection('comunicacao')} onClick={() => {
setActiveSection('comunicacao');
setSidebarOpen(false);
}}
> >
<MessageSquare className="mr-2 h-4 w-4" /> <MessageSquare className="mr-2 h-4 w-4" />
Comunicação SMS
</Button> */} </Button>
<Button <Button
variant={activeSection === 'perfil' ? 'default' : 'ghost'} variant={activeSection === 'perfil' ? 'default' : 'ghost'}
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer" className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
onClick={() => setActiveSection('perfil')} onClick={() => {
setActiveSection('perfil');
setSidebarOpen(false);
}}
> >
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
Meu Perfil Meu Perfil
@ -3033,11 +3185,12 @@ const ProfissionalPage = () => {
</nav> </nav>
</aside> </aside>
<main> {/* Main Content Area */}
<main className="flex-1 min-w-0 w-full">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold">Área do Profissional de Saúde</h1> <h1 className="text-2xl md:text-3xl font-bold">Área do Profissional</h1>
</div> </div>
<p className="mb-8">Bem-vindo à sua área exclusiva.</p> <p className="mb-6 md:mb-8 text-sm md:text-base">Bem-vindo à sua área exclusiva.</p>
{renderActiveSection()} {renderActiveSection()}
</main> </main>
@ -3157,21 +3310,21 @@ const ProfissionalPage = () => {
</div> </div>
)} )}
{} {/* Modal de ação para editar evento */}
{showActionModal && selectedEvent && ( {showActionModal && selectedEvent && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50 p-4">
<div className="bg-card border border-border p-6 rounded-lg w-96"> <div className="bg-card border border-border p-4 sm:p-6 rounded-lg w-full max-w-md">
<h3 className="text-lg font-semibold mb-2"> <h3 className="text-base sm:text-lg font-semibold mb-2">
Consulta de {selectedEvent.title} Consulta de {selectedEvent.title}
</h3> </h3>
<p className="text-sm text-gray-600 mb-4"> <p className="text-xs sm:text-sm text-gray-600 mb-4">
{selectedEvent.extendedProps.type} às {selectedEvent.extendedProps.time} {selectedEvent.extendedProps.type} às {selectedEvent.extendedProps.time}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleStartEdit} onClick={handleStartEdit}
className="flex-1 flex items-center gap-2" className="flex-1 flex items-center justify-center gap-2 text-xs sm:text-sm"
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
Editar Editar
@ -3181,7 +3334,7 @@ const ProfissionalPage = () => {
<Button <Button
onClick={() => setShowActionModal(false)} onClick={() => setShowActionModal(false)}
variant="outline" variant="outline"
className="w-full mt-2 hover:bg-primary! hover:text-white! transition-colors" className="w-full mt-2 hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm"
> >
Cancelar Cancelar
</Button> </Button>
@ -3194,25 +3347,24 @@ const ProfissionalPage = () => {
<div className="fixed inset-0 bg-black/50 flex justify-center items-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex justify-center items-center z-50 p-4">
<div className="bg-card border border-border rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"> <div className="bg-card border border-border rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header com navegação */} {/* Header com navegação */}
<div className="flex items-center justify-between p-4 border-b border-border"> <div className="flex items-center justify-between p-3 sm:p-4 border-b border-border gap-2">
<button <button
onClick={() => { onClick={() => {
const prev = new Date(selectedDayDate); const prev = new Date(selectedDayDate);
prev.setDate(prev.getDate() - 1); prev.setDate(prev.getDate() - 1);
setSelectedDayDate(prev); setSelectedDayDate(prev);
}} }}
className="p-2 hover:bg-muted rounded transition-colors" className="p-2 hover:bg-muted rounded transition-colors flex-shrink-0"
aria-label="Dia anterior" aria-label="Dia anterior"
> >
<ChevronLeft className="h-5 w-5" /> <ChevronLeft className="h-4 w-4 sm:h-5 sm:w-5" />
</button> </button>
<h2 className="text-lg font-semibold flex-1 text-center"> <h2 className="text-base sm:text-lg font-semibold flex-1 text-center line-clamp-2">
{selectedDayDate.toLocaleDateString('pt-BR', { {selectedDayDate.toLocaleDateString('pt-BR', {
weekday: 'long', weekday: 'short',
day: 'numeric', day: 'numeric',
month: 'long', month: 'short'
year: 'numeric'
})} })}
</h2> </h2>
@ -3222,41 +3374,39 @@ const ProfissionalPage = () => {
next.setDate(next.getDate() + 1); next.setDate(next.getDate() + 1);
setSelectedDayDate(next); setSelectedDayDate(next);
}} }}
className="p-2 hover:bg-muted rounded transition-colors" className="p-2 hover:bg-muted rounded transition-colors flex-shrink-0"
aria-label="Próximo dia" aria-label="Próximo dia"
> >
<ChevronRight className="h-5 w-5" /> <ChevronRight className="h-4 w-4 sm:h-5 sm:w-5" />
</button> </button>
<div className="w-12" />
<button <button
onClick={() => setShowDayModal(false)} onClick={() => setShowDayModal(false)}
className="p-2 hover:bg-muted rounded transition-colors ml-2" className="p-2 hover:bg-muted rounded transition-colors flex-shrink-0"
aria-label="Fechar" aria-label="Fechar"
> >
<X className="h-5 w-5" /> <X className="h-4 w-4 sm:h-5 sm:w-5" />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{(() => { {(() => {
const dayStr = selectedDayDate.toISOString().split('T')[0]; const dayStr = selectedDayDate.toISOString().split('T')[0];
const dayEvents = events.filter(e => e.date === dayStr).sort((a, b) => a.time.localeCompare(b.time)); const dayEvents = events.filter(e => e.date === dayStr).sort((a, b) => a.time.localeCompare(b.time));
if (dayEvents.length === 0) { if (dayEvents.length === 0) {
return ( return (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-6 sm:py-8 text-muted-foreground">
<CalendarIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" /> <CalendarIcon className="h-10 sm:h-12 w-10 sm:w-12 mx-auto mb-3 sm:mb-4 text-muted-foreground/50" />
<p className="text-lg">Nenhuma consulta agendada para este dia</p> <p className="text-base sm:text-lg">Nenhuma consulta agendada para este dia</p>
</div> </div>
); );
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-muted-foreground mb-4"> <p className="text-xs sm:text-sm text-muted-foreground mb-4">
{dayEvents.length} consulta{dayEvents.length !== 1 ? 's' : ''} agendada{dayEvents.length !== 1 ? 's' : ''} {dayEvents.length} consulta{dayEvents.length !== 1 ? 's' : ''} agendada{dayEvents.length !== 1 ? 's' : ''}
</p> </p>
{dayEvents.map((appointment) => { {dayEvents.map((appointment) => {
@ -3264,20 +3414,20 @@ const ProfissionalPage = () => {
return ( return (
<div <div
key={appointment.id} key={appointment.id}
className="border-l-4 p-4 rounded-lg bg-muted/20" className="border-l-4 p-3 sm:p-4 rounded-lg bg-muted/20"
style={{ borderLeftColor: getStatusColor(appointment.type) }} style={{ borderLeftColor: getStatusColor(appointment.type) }}
> >
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-semibold flex items-center gap-2"> <h3 className="font-semibold text-xs sm:text-sm flex items-center gap-2">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
{appointment.title} {appointment.title}
</h3> </h3>
<span className="px-2 py-1 rounded-full text-xs font-medium text-white" style={{ backgroundColor: getStatusColor(appointment.type) }}> <span className="px-2 sm:px-3 py-1 rounded-full text-xs font-medium text-white" style={{ backgroundColor: getStatusColor(appointment.type) }}>
{appointment.type} {appointment.type}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm text-muted-foreground flex-wrap">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
{appointment.time} {appointment.time}
@ -3324,4 +3474,5 @@ const getShortId = (id?: string) => {
} }
}; };
export default ProfissionalPage; export default ProfissionalPage;

View File

@ -1,72 +1,10 @@
"use client"; "use client";
import { RotateCcw } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
export default function HeaderAgenda() { export default function HeaderAgenda() {
const pathname = usePathname();
const router = useRouter();
const isAg = pathname?.startsWith("/agenda");
const isPr = pathname?.startsWith("/procedimento");
const isFi = pathname?.startsWith("/financeiro");
return ( return (
<header className="border-b bg-background border-border"> <header className="border-b bg-background border-border">
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between"> <div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
<h1 className="text-[18px] font-semibold text-foreground">Novo Agendamento</h1> <h1 className="text-[18px] font-semibold text-foreground">Novo Agendamento</h1>
<div className="flex items-center gap-2">
<nav
role="tablist"
aria-label="Navegação de Agendamento"
className="flex items-center gap-2"
>
<Link
href="/agenda"
role="tab"
className={`px-4 py-1.5 text-[13px] font-medium border rounded-md ${
isAg
? "bg-primary text-white border-primary dark:bg-primary dark:text-white"
: "text-foreground hover:bg-muted border-input"
}`}
>
Agendamento
</Link>
<Link
href="/procedimento"
role="tab"
className={`px-4 py-1.5 text-[13px] font-medium border rounded-md ${
isPr
? "bg-primary text-white border-primary dark:bg-primary dark:text-white"
: "text-foreground hover:bg-muted border-input"
}`}
>
Procedimento
</Link>
<Link
href="/financeiro"
role="tab"
className={`px-4 py-1.5 text-[13px] font-medium border rounded-md ${
isFi
? "bg-primary text-white border-primary dark:bg-primary dark:text-white"
: "text-foreground hover:bg-muted border-input"
}`}
>
Financeiro
</Link>
</nav>
<button
type="button"
aria-label="Voltar para Calendário"
onClick={() => router.push("/calendar")}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background text-muted-foreground hover:bg-primary hover:text-white hover:border-primary transition-colors"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div> </div>
</header> </header>
); );

View File

@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Calendar, Search, ChevronDown, X } from "lucide-react"; import { Calendar, Search, ChevronDown, X } from "lucide-react";
interface FormData { interface FormData {
@ -91,6 +92,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const [lockedDurationFromSlot, setLockedDurationFromSlot] = useState(false); const [lockedDurationFromSlot, setLockedDurationFromSlot] = useState(false);
const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false); const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false);
const [exceptionDialogMessage, setExceptionDialogMessage] = useState<string | null>(null); const [exceptionDialogMessage, setExceptionDialogMessage] = useState<string | null>(null);
const [showDatePicker, setShowDatePicker] = useState(false);
// Helpers to convert between ISO (server) and input[type=datetime-local] value // Helpers to convert between ISO (server) and input[type=datetime-local] value
const isoToDatetimeLocal = (iso?: string | null) => { const isoToDatetimeLocal = (iso?: string | null) => {
@ -554,6 +556,42 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Filter available slots: if date is today, only show future times
const filteredAvailableSlots = (() => {
try {
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
const selectedDateStr = (formData as any).appointmentDate || null;
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const currentTimeInMinutes = currentHours * 60 + currentMinutes;
if (selectedDateStr === todayStr) {
// Today: filter out past times (add 30-minute buffer for admin to schedule)
return (availableSlots || []).filter((s) => {
try {
const slotDate = new Date(s.datetime);
const slotHours = slotDate.getHours();
const slotMinutes = slotDate.getMinutes();
const slotTimeInMinutes = slotHours * 60 + slotMinutes;
// Keep slots that are at least 30 minutes in the future
return slotTimeInMinutes >= currentTimeInMinutes + 30;
} catch (e) {
return true;
}
});
} else if (selectedDateStr && selectedDateStr > todayStr) {
// Future date: show all slots
return availableSlots || [];
} else {
// Past date: no slots
return [];
}
} catch (e) {
return availableSlots || [];
}
})();
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = event.target; const { name, value } = event.target;
@ -684,6 +722,9 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
} catch (e) {} } catch (e) {}
// ref to the appointment date input
const appointmentDateRef = useRef<HTMLInputElement | null>(null);
return ( return (
<form className="space-y-8"> <form className="space-y-8">
{/* Exception dialog shown when a blocking exception exists for selected date */} {/* Exception dialog shown when a blocking exception exists for selected date */}
@ -863,10 +904,50 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-[13px]">Data *</Label> <div className="flex items-center gap-2">
<Label className="text-[13px]">Data *</Label>
<button
type="button"
aria-label="Abrir seletor de data"
onClick={() => setShowDatePicker(!showDatePicker)}
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer"
>
<Calendar className="h-4 w-4" />
</button>
</div>
<div className="relative"> <div className="relative">
<Calendar className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Input
<Input name="appointmentDate" type="date" className="h-11 w-full rounded-md pl-8 pr-3 text-[13px] transition-colors hover:bg-muted/30" value={formData.appointmentDate || ''} onChange={handleChange} /> ref={appointmentDateRef as any}
name="appointmentDate"
type="text"
placeholder="DD/MM/AAAA"
className="h-11 w-full rounded-md pl-3 pr-3 text-[13px] transition-colors hover:bg-muted/30"
value={formData.appointmentDate ? (() => {
try {
const [y, m, d] = String(formData.appointmentDate).split('-');
return `${d}/${m}/${y}`;
} catch (e) {
return '';
}
})() : ''}
readOnly
/>
{showDatePicker && (
<div className="absolute top-full left-0 mt-1 z-50 bg-card border border-border rounded-md shadow-lg p-3">
<CalendarComponent
mode="single"
selected={formData.appointmentDate ? new Date(formData.appointmentDate + 'T00:00:00') : undefined}
onSelect={(date) => {
if (date) {
const dateStr = date.toISOString().split('T')[0];
onFormChange({ ...formData, appointmentDate: dateStr });
setShowDatePicker(false);
}
}}
disabled={(date) => date < new Date(new Date().toISOString().split('T')[0] + 'T00:00:00')}
/>
</div>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
@ -1011,8 +1092,8 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
<div className="mt-2 grid grid-cols-3 gap-2"> <div className="mt-2 grid grid-cols-3 gap-2">
{loadingSlots ? ( {loadingSlots ? (
<div className="col-span-3">Carregando horários...</div> <div className="col-span-3">Carregando horários...</div>
) : availableSlots && availableSlots.length ? ( ) : filteredAvailableSlots && filteredAvailableSlots.length ? (
availableSlots.map((s) => { filteredAvailableSlots.map((s) => {
const dt = new Date(s.datetime); const dt = new Date(s.datetime);
const hh = String(dt.getHours()).padStart(2, '0'); const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0'); const mm = String(dt.getMinutes()).padStart(2, '0');

View File

@ -16,7 +16,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { ChevronLeft, ChevronRight, Plus, Calendar, Clock, Grid3x3, List, Search, X } from "lucide-react" import { ChevronLeft, ChevronRight, Calendar, Clock, Grid3x3, List, Search, X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export interface Event { export interface Event {
@ -343,17 +343,6 @@ export function EventManager({
<span className="ml-1">Lista</span> <span className="ml-1">Lista</span>
</Button> </Button>
</div> </div>
<Button
onClick={() => {
setIsCreating(true)
setIsDialogOpen(true)
}}
className="w-full sm:w-auto"
>
<Plus className="mr-2 h-4 w-4" />
Novo Evento
</Button>
</div> </div>
</div> </div>
@ -515,11 +504,11 @@ export function EventManager({
{/* Event Dialog */} {/* Event Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-md max-h[90vh] overflow-y-auto"> <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{isCreating ? "Criar Evento" : "Detalhes do Evento"}</DialogTitle> <DialogTitle>{isCreating ? "Criar Evento" : "Detalhes do Agendamento"}</DialogTitle>
<DialogDescription> <DialogDescription>
{isCreating ? "Adicione um novo evento ao seu calendário" : "Visualizar e editar detalhes do evento"} {isCreating ? "Adicione um novo evento ao seu calendário" : "Visualizar e editar detalhes do agendamento"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -528,7 +517,7 @@ export function EventManager({
<Label htmlFor="title">Título</Label> <Label htmlFor="title">Título</Label>
<Input <Input
id="title" id="title"
value={isCreating ? newEvent.title : selectedEvent?.title} value={isCreating ? (newEvent.title ?? "") : (selectedEvent?.title ?? "")}
onChange={(e) => onChange={(e) =>
isCreating isCreating
? setNewEvent((prev) => ({ ...prev, title: e.target.value })) ? setNewEvent((prev) => ({ ...prev, title: e.target.value }))
@ -542,7 +531,7 @@ export function EventManager({
<Label htmlFor="description">Descrição</Label> <Label htmlFor="description">Descrição</Label>
<Textarea <Textarea
id="description" id="description"
value={isCreating ? newEvent.description : selectedEvent?.description} value={isCreating ? (newEvent.description ?? "") : (selectedEvent?.description ?? "")}
onChange={(e) => onChange={(e) =>
isCreating isCreating
? setNewEvent((prev) => ({ ? setNewEvent((prev) => ({
@ -972,7 +961,7 @@ function WeekView({
getColorClasses: (color: string) => { bg: string; text: string } getColorClasses: (color: string) => { bg: string; text: string }
}) { }) {
const startOfWeek = new Date(currentDate) const startOfWeek = new Date(currentDate)
startOfWeek.setDate(currentDate.getDay()) startOfWeek.setDate(currentDate.getDate() - currentDate.getDay())
const weekDays = Array.from({ length: 7 }, (_, i) => { const weekDays = Array.from({ length: 7 }, (_, i) => {
const day = new Date(startOfWeek) const day = new Date(startOfWeek)
@ -980,7 +969,30 @@ function WeekView({
return day return day
}) })
const hours = Array.from({ length: 24 }, (_, i) => i) // NOVO: limita intervalo de horas ao 1º e último evento da semana
const [startHour, endHour] = React.useMemo(() => {
let minH = Infinity
let maxH = -Infinity
for (const ev of events) {
const d = ev.startTime
const sameWeekDay = weekDays.some(wd =>
d.getFullYear() === wd.getFullYear() &&
d.getMonth() === wd.getMonth() &&
d.getDate() === wd.getDate()
)
if (!sameWeekDay) continue
minH = Math.min(minH, d.getHours())
maxH = Math.max(maxH, ev.endTime.getHours())
}
if (!isFinite(minH) || !isFinite(maxH)) return [0, 23] as const
if (maxH < minH) maxH = minH
return [minH, maxH] as const
}, [events, weekDays])
const hours = React.useMemo(
() => Array.from({ length: (endHour - startHour + 1) }, (_, i) => startHour + i),
[startHour, endHour]
)
const getEventsForDayAndHour = (date: Date, hour: number) => { const getEventsForDayAndHour = (date: Date, hour: number) => {
return events.filter((event) => { return events.filter((event) => {
@ -1071,7 +1083,26 @@ function DayView({
onDrop: (date: Date, hour: number) => void onDrop: (date: Date, hour: number) => void
getColorClasses: (color: string) => { bg: string; text: string } getColorClasses: (color: string) => { bg: string; text: string }
}) { }) {
const hours = Array.from({ length: 24 }, (_, i) => i) // NOVO: calcula intervalo de horas do 1º ao último evento do dia
const [startHour, endHour] = React.useMemo(() => {
const sameDayEvents = events.filter((ev) => {
const d = ev.startTime
return (
d.getDate() === currentDate.getDate() &&
d.getMonth() === currentDate.getMonth() &&
d.getFullYear() === currentDate.getFullYear()
)
})
if (!sameDayEvents.length) return [0, 23] as const
const minH = Math.min(...sameDayEvents.map((e) => e.startTime.getHours()))
const maxH = Math.max(...sameDayEvents.map((e) => e.endTime.getHours()))
return [minH, Math.max(maxH, minH)] as const
}, [events, currentDate])
const hours = React.useMemo(
() => Array.from({ length: (endHour - startHour + 1) }, (_, i) => startHour + i),
[startHour, endHour]
)
const getEventsForHour = (hour: number) => { const getEventsForHour = (hour: number) => {
return events.filter((event) => { return events.filter((event) => {

View File

@ -1,467 +0,0 @@
"use client"
import * as React from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Trash2, Calendar, Clock, User } from "lucide-react"
import { v4 as uuidv4 } from "uuid"
import { startOfMonth, endOfMonth, eachDayOfInterval, format } from "date-fns"
import { ptBR } from "date-fns/locale"
export type CalendarEvent = {
id: string
title: string
date: string // ISO
status?: 'confirmed' | 'pending' | 'cancelled' | string
patient?: string
type?: string
}
interface ThreeDWallCalendarProps {
events: CalendarEvent[]
onAddEvent?: (e: CalendarEvent) => void
onRemoveEvent?: (id: string) => void
onOpenAddPatientForm?: () => void
panelWidth?: number
panelHeight?: number
columns?: number
}
export function ThreeDWallCalendar({
events,
onAddEvent,
onRemoveEvent,
onOpenAddPatientForm,
panelWidth = 160,
panelHeight = 120,
columns = 7,
}: ThreeDWallCalendarProps) {
const [dateRef, setDateRef] = React.useState<Date>(new Date())
const [title, setTitle] = React.useState("")
const [newDate, setNewDate] = React.useState("")
const [selectedDay, setSelectedDay] = React.useState<Date | null>(null)
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const wallRef = React.useRef<HTMLDivElement | null>(null)
// 3D tilt state
const [tiltX, setTiltX] = React.useState(18)
const [tiltY, setTiltY] = React.useState(0)
const isDragging = React.useRef(false)
const dragStart = React.useRef<{ x: number; y: number } | null>(null)
const hasDragged = React.useRef(false)
const clickStart = React.useRef<{ x: number; y: number } | null>(null)
// month days
const days = eachDayOfInterval({
start: startOfMonth(dateRef),
end: endOfMonth(dateRef),
})
const eventsForDay = (d: Date) =>
events.filter((ev) => format(new Date(ev.date), "yyyy-MM-dd") === format(d, "yyyy-MM-dd"))
const selectedDayEvents = selectedDay ? eventsForDay(selectedDay) : []
const handleDayClick = (day: Date) => {
console.log('Day clicked:', format(day, 'dd/MM/yyyy'))
setSelectedDay(day)
setIsDialogOpen(true)
}
// Add event handler
const handleAdd = () => {
if (!title.trim() || !newDate) return
onAddEvent?.({
id: uuidv4(),
title: title.trim(),
date: new Date(newDate).toISOString(),
})
setTitle("")
setNewDate("")
}
// wheel tilt
const onWheel = (e: React.WheelEvent) => {
setTiltX((t) => Math.max(0, Math.min(50, t + e.deltaY * 0.02)))
setTiltY((t) => Math.max(-45, Math.min(45, t + e.deltaX * 0.05)))
}
// drag tilt
const onPointerDown = (e: React.PointerEvent) => {
isDragging.current = true
hasDragged.current = false
dragStart.current = { x: e.clientX, y: e.clientY }
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
}
const onPointerMove = (e: React.PointerEvent) => {
if (!isDragging.current || !dragStart.current) return
const dx = e.clientX - dragStart.current.x
const dy = e.clientY - dragStart.current.y
// Se moveu mais de 5 pixels, considera como drag
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
hasDragged.current = true
}
setTiltY((t) => Math.max(-60, Math.min(60, t + dx * 0.1)))
setTiltX((t) => Math.max(0, Math.min(60, t - dy * 0.1)))
dragStart.current = { x: e.clientX, y: e.clientY }
}
const onPointerUp = () => {
isDragging.current = false
dragStart.current = null
// Reset hasDragged após um curto delay para permitir o clique ser processado
setTimeout(() => {
hasDragged.current = false
}, 100)
}
const gap = 12
const rowCount = Math.ceil(days.length / columns)
const wallCenterRow = (rowCount - 1) / 2
return (
<div className="space-y-4">
<div className="flex gap-4 items-center justify-between flex-wrap">
<div className="flex gap-2 items-center">
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1))}>
Mês Anterior
</Button>
<div className="font-semibold text-lg">{format(dateRef, "MMMM yyyy", { locale: ptBR })}</div>
<Button onClick={() => setDateRef((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1))}>
Próximo Mês
</Button>
{/* Botão Pacientes de hoje */}
<Button
variant="outline"
onClick={() => {
setSelectedDay(new Date())
setIsDialogOpen(true)
}}
>
Pacientes de hoje
</Button>
</div>
{/* Legenda de cores */}
<div className="flex gap-3 items-center text-xs">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-green-500 dark:bg-green-600"></div>
<span>Confirmado</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-yellow-500 dark:bg-yellow-600"></div>
<span>Pendente</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500 dark:bg-red-600"></div>
<span>Cancelado</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-500 dark:bg-blue-600"></div>
<span>Outros</span>
</div>
</div>
</div>
{/* Wall container */}
<div className="relative">
<div className="absolute top-2 left-2 z-10 bg-background/80 backdrop-blur-sm px-3 py-1.5 rounded-lg text-xs text-muted-foreground border border-border">
💡 Arraste para rotacionar Scroll para inclinar
</div>
<div
ref={wallRef}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="w-full overflow-auto"
style={{ perspective: 1200, maxWidth: 1100 }}
>
<div
className="mx-auto"
style={{
width: Math.max(700, columns * (panelWidth + gap)),
transformStyle: "preserve-3d",
transform: `rotateX(${tiltX}deg) rotateY(${tiltY}deg)`,
transition: "transform 120ms linear",
}}
>
<div
className="relative"
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, ${panelWidth}px)`,
gridAutoRows: `${panelHeight}px`,
gap: `${gap}px`,
transformStyle: "preserve-3d",
padding: gap,
}}
>
{days.map((day, idx) => {
const row = Math.floor(idx / columns)
const rowOffset = row - wallCenterRow
const z = Math.max(-80, 40 - Math.abs(rowOffset) * 20)
const dayEvents = eventsForDay(day)
return (
<div
key={day.toISOString()}
className="relative cursor-pointer"
style={{
transform: `translateZ(${z}px)`,
zIndex: Math.round(100 - Math.abs(rowOffset)),
}}
onPointerDown={(e) => {
clickStart.current = { x: e.clientX, y: e.clientY }
}}
onPointerUp={(e) => {
if (clickStart.current) {
const dx = Math.abs(e.clientX - clickStart.current.x)
const dy = Math.abs(e.clientY - clickStart.current.y)
// Se moveu menos de 5 pixels, é um clique
if (dx < 5 && dy < 5) {
e.stopPropagation()
handleDayClick(day)
}
clickStart.current = null
}
}}
>
<Card className="h-full overflow-visible hover:shadow-lg transition-shadow">
<CardContent className="p-2 h-full flex flex-col">
<div className="flex justify-between items-start mb-1">
<div className="text-sm font-medium">{format(day, "d")}</div>
<div className="text-[9px] text-muted-foreground">
{dayEvents.length > 0 && `${dayEvents.length} ${dayEvents.length === 1 ? 'paciente' : 'pacientes'}`}
</div>
</div>
<div className="text-[10px] text-muted-foreground mb-1">{format(day, "EEE", { locale: ptBR })}</div>
{/* events */}
<div className="relative flex-1 min-h-0">
{dayEvents.map((ev, i) => {
// Calcular tamanho da bolinha baseado na quantidade de eventos
const eventCount = dayEvents.length
const ballSize = eventCount <= 3 ? 20 :
eventCount <= 6 ? 16 :
eventCount <= 10 ? 14 :
eventCount <= 15 ? 12 : 10
const spacing = ballSize + 4
const maxPerRow = Math.floor((panelWidth - 16) / spacing)
const col = i % maxPerRow
const row = Math.floor(i / maxPerRow)
const left = 4 + (col * spacing)
const top = 4 + (row * spacing)
// Cores baseadas no status
const getStatusColor = () => {
switch(ev.status) {
case 'confirmed': return 'bg-green-500 dark:bg-green-600'
case 'pending': return 'bg-yellow-500 dark:bg-yellow-600'
case 'cancelled': return 'bg-red-500 dark:bg-red-600'
default: return 'bg-blue-500 dark:bg-blue-600'
}
}
return (
<HoverCard key={ev.id} openDelay={100}>
<HoverCardTrigger asChild>
<div
className={`absolute rounded-full ${getStatusColor()} flex items-center justify-center text-white cursor-pointer shadow-sm hover:shadow-md hover:scale-110 transition-all`}
style={{
left,
top,
width: ballSize,
height: ballSize,
fontSize: Math.max(6, ballSize / 3),
transform: `translateZ(15px)`
}}
>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-64 p-3" side="top">
<div className="space-y-2">
<div className="font-semibold text-sm">{ev.title}</div>
{ev.patient && ev.type && (
<div className="text-xs space-y-1">
<div><span className="font-medium">Paciente:</span> {ev.patient}</div>
<div><span className="font-medium">Tipo:</span> {ev.type}</div>
</div>
)}
<div className="text-xs text-muted-foreground">
{format(new Date(ev.date), "PPP 'às' p", { locale: ptBR })}
</div>
{ev.status && (
<div className="text-xs">
<span className="font-medium">Status:</span>{' '}
<span className={
ev.status === 'confirmed' ? 'text-green-600 dark:text-green-400' :
ev.status === 'pending' ? 'text-yellow-600 dark:text-yellow-400' :
ev.status === 'cancelled' ? 'text-red-600 dark:text-red-400' :
''
}>
{ev.status === 'confirmed' ? 'Confirmado' :
ev.status === 'pending' ? 'Pendente' :
ev.status === 'cancelled' ? 'Cancelado' : ev.status}
</span>
</div>
)}
{onRemoveEvent && (
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-xs hover:bg-destructive/10 hover:text-destructive"
onClick={() => onRemoveEvent(ev.id)}
>
<Trash2 className="h-3 w-3 mr-1" />
Remover
</Button>
)}
</div>
</HoverCardContent>
</HoverCard>
)
})}
</div>
</CardContent>
</Card>
</div>
)
})}
</div>
</div>
</div>
</div>
{/* Dialog de detalhes do dia */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
{/* Navegação de dias */}
<div className="flex items-center justify-between mb-2">
<Button
variant="ghost"
size="icon"
onClick={() => setSelectedDay((prev) => prev ? new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() - 1) : new Date())}
aria-label="Dia anterior"
>
&#x276E;
</Button>
<DialogTitle className="text-xl">
{selectedDay && format(selectedDay, "dd 'de' MMMM 'de' yyyy", { locale: ptBR })}
</DialogTitle>
<Button
variant="ghost"
size="icon"
onClick={() => setSelectedDay((prev) => prev ? new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() + 1) : new Date())}
aria-label="Próximo dia"
>
&#x276F;
</Button>
</div>
<DialogDescription>
{selectedDayEvents.length} {selectedDayEvents.length === 1 ? 'paciente agendado' : 'pacientes agendados'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-4">
{selectedDayEvents.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Nenhum paciente agendado para este dia
</div>
) : (
selectedDayEvents.map((ev) => {
const getStatusColor = () => {
switch(ev.status) {
case 'confirmed': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
case 'pending': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
case 'cancelled': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
default: return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
}
}
const getStatusText = () => {
switch(ev.status) {
case 'confirmed': return 'Confirmado'
case 'pending': return 'Pendente'
case 'cancelled': return 'Cancelado'
default: return ev.status || 'Sem status'
}
}
return (
<Card key={ev.id} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold">{ev.patient || ev.title}</h3>
</div>
{ev.type && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-3.5 w-3.5" />
<span>{ev.type}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>{format(new Date(ev.date), "HH:mm", { locale: ptBR })}</span>
</div>
<Badge className={getStatusColor()}>
{getStatusText()}
</Badge>
</div>
{onRemoveEvent && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
onRemoveEvent(ev.id)
}}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
)
})
)}
</div>
</DialogContent>
</Dialog>
{/* Add event form */}
<div className="flex gap-2 items-center">
{onOpenAddPatientForm ? (
<Button onClick={onOpenAddPatientForm} className="w-full">
Adicionar Paciente
</Button>
) : (
<>
<Input placeholder="Nome do paciente" value={title} onChange={(e) => setTitle(e.target.value)} />
<Input type="date" value={newDate} onChange={(e) => setNewDate(e.target.value)} />
<Button onClick={handleAdd}>Adicionar Paciente</Button>
</>
)}
</div>
</div>
)
}