fix/appoiments #67
@ -2,30 +2,27 @@
|
||||
|
||||
// Imports mantidos
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// --- Imports do EventManager (NOVO) - MANTIDOS ---
|
||||
import { EventManager, type Event } from "@/components/features/general/event-manager";
|
||||
import { v4 as uuidv4 } from 'uuid'; // Usado para IDs de fallback
|
||||
|
||||
// Imports mantidos
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { mockWaitingList } from "@/lib/mocks/appointment-mocks";
|
||||
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() {
|
||||
const { user, token } = useAuth();
|
||||
const [appointments, setAppointments] = useState<any[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<"calendar" | "3d">("calendar");
|
||||
const [threeDEvents, setThreeDEvents] = useState<CalendarEvent[]>([]);
|
||||
// REMOVIDO: abas e 3D → não há mais alternância de abas
|
||||
// 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)
|
||||
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(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
@ -67,8 +49,8 @@ export default function AgendamentoPage() {
|
||||
if (!mounted) return;
|
||||
if (!arr || !arr.length) {
|
||||
setAppointments([]);
|
||||
setThreeDEvents([]);
|
||||
setManagerEvents([]); // Limpa o novo calendário
|
||||
// REMOVIDO: setThreeDEvents([])
|
||||
setManagerEvents([]);
|
||||
setManagerLoading(false);
|
||||
return;
|
||||
}
|
||||
@ -90,8 +72,7 @@ export default function AgendamentoPage() {
|
||||
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();
|
||||
|
||||
// Mapeamento de cores padronizado:
|
||||
// azul = solicitado; verde = confirmado; laranja = pendente; vermelho = cancelado; azul como fallback
|
||||
// Mapeamento de cores padronizado
|
||||
const status = String(obj.status || "").toLowerCase();
|
||||
let color: Event["color"] = "blue";
|
||||
if (status === "confirmed" || status === "confirmado") color = "green";
|
||||
@ -112,27 +93,12 @@ export default function AgendamentoPage() {
|
||||
setManagerLoading(false);
|
||||
// --- FIM DA LÓGICA ---
|
||||
|
||||
// Convert to 3D calendar events (MANTIDO 100%)
|
||||
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);
|
||||
// REMOVIDO: conversão para 3D e setThreeDEvents
|
||||
} catch (err) {
|
||||
console.warn('[AgendamentoPage] falha ao carregar agendamentos', err);
|
||||
setAppointments([]);
|
||||
setThreeDEvents([]);
|
||||
setManagerEvents([]); // Limpa o novo calendário
|
||||
// REMOVIDO: setThreeDEvents([])
|
||||
setManagerEvents([]);
|
||||
setManagerLoading(false);
|
||||
}
|
||||
})();
|
||||
@ -154,12 +120,38 @@ export default function AgendamentoPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEvent = (event: CalendarEvent) => {
|
||||
setThreeDEvents((prev) => [...prev, event]);
|
||||
// Mapeia cor do calendário -> status da API
|
||||
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) => {
|
||||
setThreeDEvents((prev) => prev.filter((e) => e.id !== id));
|
||||
// Envia atualização para a API e atualiza UI
|
||||
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 (
|
||||
@ -167,39 +159,17 @@ export default function AgendamentoPage() {
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex w-full flex-col gap-10 p-6">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
{/* Todo o cabeçalho foi mantido */}
|
||||
{/* Cabeçalho simplificado (sem 3D) */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{activeTab === "calendar" ? "Calendário" : activeTab === "3d" ? "Calendário 3D" : "Lista de Espera"}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">Calendário</h1>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex space-x-2 items-center">
|
||||
<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>
|
||||
{/* REMOVIDO: botões de abas Calendário/3D */}
|
||||
</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="flex flex-wrap items-center gap-6 text-sm">
|
||||
<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 className="text-foreground">Confirmado</span>
|
||||
</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>
|
||||
|
||||
{/* --- AQUI ESTÁ A SUBSTITUIÇÃO --- */}
|
||||
{activeTab === "calendar" ? (
|
||||
{/* Apenas o EventManager */}
|
||||
<div className="flex w-full">
|
||||
{/* mostra loading até managerEvents ser preenchido (API integrada desde a entrada) */}
|
||||
<div className="w-full">
|
||||
{managerLoading ? (
|
||||
<div className="flex items-center justify-center w-full min-h-[70vh]">
|
||||
<div className="text-sm text-muted-foreground">Conectando ao calendário — carregando agendamentos...</div>
|
||||
</div>
|
||||
) : (
|
||||
// EventManager ocupa a área principal e já recebe events da API
|
||||
<div className="w-full min-h-[70vh]">
|
||||
<EventManager events={managerEvents} className="compact-event-manager" />
|
||||
<EventManager
|
||||
events={managerEvents}
|
||||
className="compact-event-manager"
|
||||
onEventUpdate={handleEventUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "3d" ? (
|
||||
// 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>
|
||||
|
||||
{/* Formulário de Registro de Paciente */}
|
||||
<PatientRegistrationForm
|
||||
open={showPatientForm}
|
||||
onOpenChange={setShowPatientForm}
|
||||
mode="create"
|
||||
onSaved={(newPaciente) => {
|
||||
console.log('[Calendar] Novo paciente registrado:', newPaciente);
|
||||
setShowPatientForm(false);
|
||||
}}
|
||||
/>
|
||||
{/* REMOVIDO: PatientRegistrationForm (era acionado pelo 3D) */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -520,24 +520,25 @@ export default function RelatoriosPage() {
|
||||
|
||||
{/* Performance por médico */}
|
||||
<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>
|
||||
<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>
|
||||
<table className="w-full text-sm mt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-medium">Médico</th>
|
||||
<th className="text-left font-medium">Consultas</th>
|
||||
<th className="text-left font-medium">Absenteísmo (%)</th>
|
||||
<tr className="text-muted-foreground border-b border-border">
|
||||
<th className="text-left font-medium py-3 px-2 md:px-0">Médico</th>
|
||||
<th className="text-center font-medium py-3 px-2 md:px-0">Consultas</th>
|
||||
<th className="text-center font-medium py-3 px-2 md:px-0">Absenteísmo (%)</th>
|
||||
</tr>
|
||||
</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 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>
|
||||
@ -545,6 +546,7 @@ export default function RelatoriosPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -145,6 +145,11 @@ export default function DoutoresPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
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() {
|
||||
setLoading(true);
|
||||
@ -272,47 +277,87 @@ export default function DoutoresPage() {
|
||||
};
|
||||
}, [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(() => {
|
||||
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 qDigits = q.replace(/\D/g, "");
|
||||
|
||||
// Se estamos em modo de busca (servidor), filtra os resultados da busca
|
||||
const sourceList = searchMode ? searchResults : doctors;
|
||||
console.log('🔍 Usando sourceList:', searchMode ? 'searchResults' : 'doctors', '- tamanho:', sourceList.length);
|
||||
|
||||
const filtered = sourceList.filter((d) => {
|
||||
// Busca por nome
|
||||
// 1) Busca
|
||||
const afterSearch = !q
|
||||
? sourceList
|
||||
: 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);
|
||||
|
||||
// Busca por ID (UUID completo ou parcial)
|
||||
const byId = (d.id || "").toLowerCase().includes(q);
|
||||
|
||||
// Busca por email
|
||||
const byEmail = (d.email || "").toLowerCase().includes(q);
|
||||
|
||||
// Busca por especialidade
|
||||
const byEspecialidade = (d.especialidade || "").toLowerCase().includes(q);
|
||||
|
||||
const match = byName || byCrm || byId || byEmail || byEspecialidade;
|
||||
if (match) {
|
||||
console.log('✅ Match encontrado:', d.full_name, d.id, 'por:', { byName, byCrm, byId, byEmail, byEspecialidade });
|
||||
}
|
||||
|
||||
if (match) console.log('✅ Match encontrado:', d.full_name, d.id);
|
||||
return match;
|
||||
});
|
||||
|
||||
console.log('🔍 Resultados filtrados:', filtered.length);
|
||||
return filtered;
|
||||
}, [doctors, search, searchMode, searchResults]);
|
||||
// 2) Filtros de localização e especialidade
|
||||
const afterFilters = afterSearch.filter((d) => {
|
||||
if (stateFilter && String(d.state) !== stateFilter) return false;
|
||||
if (cityFilter && String(d.city) !== cityFilter) return false;
|
||||
if (specialtyFilter && String(d.especialidade) !== specialtyFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3) Ordenação
|
||||
const sorted = [...afterFilters];
|
||||
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
|
||||
const paginatedDoctors = useMemo(() => {
|
||||
@ -323,10 +368,10 @@ export default function DoutoresPage() {
|
||||
|
||||
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(() => {
|
||||
setCurrentPage(1);
|
||||
}, [search, itemsPerPage, searchMode]);
|
||||
}, [search, itemsPerPage, searchMode, stateFilter, cityFilter, specialtyFilter, sortBy]);
|
||||
|
||||
function handleAdd() {
|
||||
setEditingId(null);
|
||||
@ -440,7 +485,7 @@ export default function DoutoresPage() {
|
||||
<p className="text-muted-foreground">Gerencie os médicos da sua clínica</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@ -473,6 +518,59 @@ export default function DoutoresPage() {
|
||||
</Button>
|
||||
)}
|
||||
</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 (A–Z)</option>
|
||||
<option value="name_desc">Nome (Z–A)</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}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Novo Médico
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@ -54,6 +53,11 @@ export default function PacientesPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
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() {
|
||||
try {
|
||||
setLoading(true);
|
||||
@ -77,27 +81,72 @@ export default function PacientesPage() {
|
||||
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(() => {
|
||||
if (!search.trim()) return patients;
|
||||
let base = patients;
|
||||
|
||||
// Busca
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase().trim();
|
||||
const qDigits = q.replace(/\D/g, "");
|
||||
|
||||
return patients.filter((p) => {
|
||||
// Busca por nome
|
||||
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);
|
||||
|
||||
// Busca por ID (UUID completo ou parcial)
|
||||
const byId = (p.id || "").toLowerCase().includes(q);
|
||||
|
||||
// Busca por email
|
||||
const byEmail = (p.email || "").toLowerCase().includes(q);
|
||||
|
||||
return byName || byCPF || byId || byEmail;
|
||||
});
|
||||
}, [patients, search]);
|
||||
}
|
||||
|
||||
// Filtros por UF e cidade
|
||||
const withLocation = base.filter((p) => {
|
||||
if (stateFilter && String(p.state) !== stateFilter) return false;
|
||||
if (cityFilter && String(p.city) !== cityFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 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
|
||||
const paginatedData = useMemo(() => {
|
||||
@ -108,10 +157,10 @@ export default function PacientesPage() {
|
||||
|
||||
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(() => {
|
||||
setCurrentPage(1);
|
||||
}, [search, itemsPerPage]);
|
||||
}, [search, itemsPerPage, stateFilter, cityFilter, sortBy]);
|
||||
|
||||
function handleAdd() {
|
||||
setEditingId(null);
|
||||
@ -214,7 +263,8 @@ export default function PacientesPage() {
|
||||
<p className="text-muted-foreground">Gerencie os pacientes</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Busca */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@ -225,7 +275,52 @@ export default function PacientesPage() {
|
||||
onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()}
|
||||
/>
|
||||
</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 (A–Z)</option>
|
||||
<option value="name_desc">Nome (Z–A)</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}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Novo paciente
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
387
susconecta/app/laudos/[id]/page.tsx
Normal file
387
susconecta/app/laudos/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -932,6 +932,7 @@ export default function PacientePage() {
|
||||
const [selectedReport, setSelectedReport] = useState<any | null>(null)
|
||||
|
||||
function ExamesLaudos() {
|
||||
const router = useRouter()
|
||||
const [reports, setReports] = useState<any[] | null>(null)
|
||||
const [loadingReports, setLoadingReports] = useState(false)
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -1449,95 +1450,7 @@ export default function PacientePage() {
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<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>
|
||||
{/* Modal removed - now using dedicated page /app/laudos/[id] */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -244,8 +244,12 @@ export default function ResultadosClient() {
|
||||
}
|
||||
|
||||
const onlyAvail = (res?.slots || []).filter((s: any) => s.available)
|
||||
const nowMs = Date.now()
|
||||
for (const s of onlyAvail) {
|
||||
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 bucket = days.find(d => d.dateKey === key)
|
||||
if (!bucket) continue
|
||||
@ -260,7 +264,6 @@ export default function ResultadosClient() {
|
||||
|
||||
// compute nearest slot (earliest available in the returned window, but after now)
|
||||
let nearest: { iso: string; label: string } | null = null
|
||||
const nowMs = Date.now()
|
||||
const allSlots = days.flatMap(d => d.horarios || [])
|
||||
const futureSorted = allSlots
|
||||
.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 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)
|
||||
return formatted
|
||||
} 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)
|
||||
return slots
|
||||
}
|
||||
} catch (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)
|
||||
return slots
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import ProtectedRoute from "@/components/shared/ProtectedRoute";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api";
|
||||
import { ENV_CONFIG } from '@/lib/env-config';
|
||||
import { useReports } from "@/hooks/useReports";
|
||||
import { CreateReportData } from "@/types/report-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -36,7 +37,6 @@ import {
|
||||
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { ENV_CONFIG } from '@/lib/env-config';
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
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 appts = await listarAgendamentos(q).catch(() => []);
|
||||
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));
|
||||
}
|
||||
} catch (e) {
|
||||
@ -211,6 +211,7 @@ const ProfissionalPage = () => {
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
// 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]);
|
||||
|
||||
// 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 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;
|
||||
|
||||
return {
|
||||
@ -429,6 +430,9 @@ const ProfissionalPage = () => {
|
||||
const [commPhoneNumber, setCommPhoneNumber] = useState('');
|
||||
const [commMessage, setCommMessage] = useState('');
|
||||
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 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) => {
|
||||
@ -720,14 +786,14 @@ const ProfissionalPage = () => {
|
||||
const todayEvents = getTodayEvents();
|
||||
|
||||
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">
|
||||
<h2 className="text-2xl font-bold">Agenda do Dia</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold">Agenda do Dia</h2>
|
||||
</div>
|
||||
|
||||
{/* Navegação de Data */}
|
||||
<div className="flex items-center justify-between mb-6 p-4 bg-blue-50 rounded-lg dark:bg-muted">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Navegação de Data - Responsiva */}
|
||||
<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 gap-2 sm:gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -736,7 +802,7 @@ const ProfissionalPage = () => {
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</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)}
|
||||
</h3>
|
||||
<Button
|
||||
@ -747,20 +813,19 @@ const ProfissionalPage = () => {
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-muted-foreground">
|
||||
{todayEvents.length} consulta{todayEvents.length !== 1 ? 's' : ''} agendada{todayEvents.length !== 1 ? 's' : ''}
|
||||
<div className="text-xs sm:text-sm text-gray-600 dark:text-muted-foreground whitespace-nowrap">
|
||||
{todayEvents.length} consulta{todayEvents.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="text-center 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" />
|
||||
<p className="text-lg mb-2">Nenhuma consulta agendada para este dia</p>
|
||||
<p className="text-sm">Agenda livre para este dia</p>
|
||||
<div className="text-center py-6 sm:py-8 text-gray-600 dark:text-muted-foreground">
|
||||
<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-base sm:text-lg mb-2">Nenhuma consulta agendada para este dia</p>
|
||||
<p className="text-xs sm:text-sm">Agenda livre para este dia</p>
|
||||
</div>
|
||||
) : (
|
||||
todayEvents.map((appointment) => {
|
||||
@ -768,47 +833,46 @@ const ProfissionalPage = () => {
|
||||
return (
|
||||
<div
|
||||
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) }}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-center">
|
||||
<div className="flex 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 gap-2">
|
||||
<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) }}
|
||||
></div>
|
||||
<div>
|
||||
<div className="font-medium flex items-center">
|
||||
<User className="h-4 w-4 mr-2 text-gray-500 dark:text-muted-foreground" />
|
||||
{appointment.title}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm sm:text-base flex items-center gap-2">
|
||||
<User className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500 dark:text-muted-foreground flex-shrink-0" />
|
||||
<span className="truncate">{appointment.title}</span>
|
||||
</div>
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-2 text-gray-500 dark:text-muted-foreground" />
|
||||
<span className="font-medium">{appointment.time}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 text-sm sm:text-base">{appointment.time}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<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) }}
|
||||
>
|
||||
{appointment.type}
|
||||
</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="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
|
||||
<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>
|
||||
@ -1594,7 +1658,7 @@ const ProfissionalPage = () => {
|
||||
function LaudoViewer({ laudo, onClose }: { laudo: any; onClose: () => void }) {
|
||||
return (
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div>
|
||||
@ -2625,19 +2689,19 @@ const ProfissionalPage = () => {
|
||||
|
||||
|
||||
const renderComunicacaoSection = () => (
|
||||
<div className="bg-card shadow-md rounded-lg p-6">
|
||||
<h2 className="text-2xl font-bold mb-4 text-foreground">Comunicação com o Paciente</h2>
|
||||
<div className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6 w-full">
|
||||
<h2 className="text-xl sm:text-2xl font-bold mb-4">Comunicação com o Paciente</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="patientSelect">Paciente *</Label>
|
||||
<Label htmlFor="patientSelect" className="text-xs sm:text-sm">Paciente *</Label>
|
||||
<Select
|
||||
value={commPatientId ?? ''}
|
||||
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);
|
||||
setCommPatientId(v);
|
||||
setCommResponses([]);
|
||||
setCommResponsesError(null);
|
||||
if (!v) {
|
||||
setCommPhoneNumber('');
|
||||
return;
|
||||
@ -2655,9 +2719,10 @@ const ProfissionalPage = () => {
|
||||
console.warn('[ProfissionalPage] erro ao preencher telefone do paciente selecionado', e);
|
||||
setCommPhoneNumber('');
|
||||
}
|
||||
void loadCommResponses(String(v));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger className="w-full text-xs sm:text-sm">
|
||||
<SelectValue placeholder="-- nenhum --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -2672,20 +2737,49 @@ const ProfissionalPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phoneNumber">Número (phone_number)</Label>
|
||||
<Input id="phoneNumber" placeholder="+5511999999999" value={commPhoneNumber} readOnly disabled className="bg-muted/50" />
|
||||
<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 text-xs sm:text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Mensagem (message)</Label>
|
||||
<textarea id="message" className="w-full p-2 border rounded" rows={5} value={commMessage} onChange={(e) => setCommMessage(e.target.value)} />
|
||||
<Label htmlFor="message" className="text-xs sm:text-sm">Mensagem (message)</Label>
|
||||
<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 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'}
|
||||
</Button>
|
||||
</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>
|
||||
@ -2693,24 +2787,24 @@ const ProfissionalPage = () => {
|
||||
|
||||
|
||||
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 */}
|
||||
<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>
|
||||
<h2 className="text-3xl font-bold">Meu Perfil</h2>
|
||||
<p className="text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold">Meu Perfil</h2>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
|
||||
</div>
|
||||
{!isEditingProfile ? (
|
||||
<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)}
|
||||
>
|
||||
✏️ Editar Perfil
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<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}
|
||||
>
|
||||
✓ Salvar
|
||||
@ -2718,6 +2812,7 @@ const ProfissionalPage = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancelEdit}
|
||||
className="flex-1 sm:flex-initial text-xs sm:text-sm"
|
||||
>
|
||||
✕ Cancelar
|
||||
</Button>
|
||||
@ -2725,21 +2820,21 @@ const ProfissionalPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid de 3 colunas (2 + 1) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Grid de 3 colunas (2 + 1) - Responsivo */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{/* 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 */}
|
||||
<div className="border border-border rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Informações Pessoais</h3>
|
||||
<div className="border border-border rounded-lg p-4 sm:p-6">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-4">Informações Pessoais</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Nome Completo */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
Nome Completo
|
||||
</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"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
@ -2749,18 +2844,18 @@ const ProfissionalPage = () => {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input
|
||||
value={profileData.email || ""}
|
||||
onChange={(e) => handleProfileChange('email', e.target.value)}
|
||||
className="mt-2"
|
||||
className="mt-2 text-xs sm:text-sm"
|
||||
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"}
|
||||
</div>
|
||||
)}
|
||||
@ -2768,18 +2863,18 @@ const ProfissionalPage = () => {
|
||||
|
||||
{/* Telefone */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
Telefone
|
||||
</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input
|
||||
value={profileData.telefone || ""}
|
||||
onChange={(e) => handleProfileChange('telefone', e.target.value)}
|
||||
className="mt-2"
|
||||
className="mt-2 text-xs sm:text-sm"
|
||||
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"}
|
||||
</div>
|
||||
)}
|
||||
@ -2787,10 +2882,10 @@ const ProfissionalPage = () => {
|
||||
|
||||
{/* CRM */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
CRM
|
||||
</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"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
@ -2800,18 +2895,18 @@ const ProfissionalPage = () => {
|
||||
|
||||
{/* Especialidade */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
Especialidade
|
||||
</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input
|
||||
value={profileData.especialidade || ""}
|
||||
onChange={(e) => handleProfileChange('especialidade', e.target.value)}
|
||||
className="mt-2"
|
||||
className="mt-2 text-xs sm:text-sm"
|
||||
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"}
|
||||
</div>
|
||||
)}
|
||||
@ -2820,24 +2915,24 @@ const ProfissionalPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Endereço e Contato */}
|
||||
<div className="border border-border rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Endereço e Contato</h3>
|
||||
<div className="border border-border rounded-lg p-4 sm:p-6">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-4">Endereço e Contato</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Logradouro */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
Logradouro
|
||||
</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input
|
||||
value={profileData.endereco || ""}
|
||||
onChange={(e) => handleProfileChange('endereco', e.target.value)}
|
||||
className="mt-2"
|
||||
className="mt-2 text-xs sm:text-sm"
|
||||
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"}
|
||||
</div>
|
||||
)}
|
||||
@ -2845,18 +2940,18 @@ const ProfissionalPage = () => {
|
||||
|
||||
{/* Cidade */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
Cidade
|
||||
</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input
|
||||
value={profileData.cidade || ""}
|
||||
onChange={(e) => handleProfileChange('cidade', e.target.value)}
|
||||
className="mt-2"
|
||||
className="mt-2 text-xs sm:text-sm"
|
||||
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"}
|
||||
</div>
|
||||
)}
|
||||
@ -2864,18 +2959,18 @@ const ProfissionalPage = () => {
|
||||
|
||||
{/* CEP */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
CEP
|
||||
</Label>
|
||||
{isEditingProfile ? (
|
||||
<Input
|
||||
value={profileData.cep || ""}
|
||||
onChange={(e) => handleProfileChange('cep', e.target.value)}
|
||||
className="mt-2"
|
||||
className="mt-2 text-xs sm:text-sm"
|
||||
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"}
|
||||
</div>
|
||||
)}
|
||||
@ -2886,18 +2981,18 @@ const ProfissionalPage = () => {
|
||||
|
||||
{/* Coluna Direita - Foto do Perfil */}
|
||||
<div>
|
||||
<div className="border border-border rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Foto do Perfil</h3>
|
||||
<div className="border border-border rounded-lg p-4 sm:p-6">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-4">Foto do Perfil</h3>
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-2xl font-bold">
|
||||
<Avatar className="h-20 w-20 sm:h-24 sm:w-24">
|
||||
<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'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<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'}
|
||||
</p>
|
||||
</div>
|
||||
@ -2915,23 +3010,23 @@ const ProfissionalPage = () => {
|
||||
return renderCalendarioSection();
|
||||
case 'pacientes':
|
||||
return (
|
||||
<section className="bg-card shadow-md rounded-lg border border-border p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">Pacientes</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<section className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6 w-full">
|
||||
<h2 className="text-xl sm:text-2xl font-bold mb-4">Pacientes</h2>
|
||||
<div className="overflow-x-auto w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>CPF</TableHead>
|
||||
<TableHead>Idade</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">Nome</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">CPF</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">Idade</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pacientes.map((paciente) => (
|
||||
<TableRow key={paciente.id ?? paciente.cpf}>
|
||||
<TableCell>{paciente.nome}</TableCell>
|
||||
<TableCell>{paciente.cpf}</TableCell>
|
||||
<TableCell>{getPatientAge(paciente) ? `${getPatientAge(paciente)} anos` : '-'}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{paciente.nome}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{paciente.cpf}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{getPatientAge(paciente) ? `${getPatientAge(paciente)} anos` : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@ -2941,8 +3036,8 @@ const ProfissionalPage = () => {
|
||||
);
|
||||
case 'laudos':
|
||||
return renderLaudosSection();
|
||||
// case 'comunicacao':
|
||||
// return renderComunicacaoSection();
|
||||
case 'comunicacao':
|
||||
return renderComunicacaoSection();
|
||||
case 'perfil':
|
||||
return renderPerfilSection();
|
||||
default:
|
||||
@ -2950,82 +3045,139 @@ const ProfissionalPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredUserType={["profissional"]}>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<header className="bg-card shadow-md rounded-lg border border-border p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<div className="flex flex-col min-h-screen">
|
||||
{/* Header - Responsivo */}
|
||||
<header className="bg-card shadow-md border-b border-border sticky top-0 z-40">
|
||||
<div className="px-4 py-3 md:px-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap md:flex-nowrap">
|
||||
{/* Logo/Avatar Section */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 md:flex-none">
|
||||
<Avatar className="h-10 w-10 md:h-12 md:w-12 flex-shrink-0">
|
||||
<AvatarImage src={(profileData as any).fotoUrl || undefined} alt={profileData.nome} />
|
||||
<AvatarFallback className="bg-muted">
|
||||
<User className="h-5 w-5" />
|
||||
<AvatarFallback className="bg-muted text-xs md:text-sm">
|
||||
<User className="h-4 w-4 md:h-5 md:w-5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground truncate">Conta do profissional</p>
|
||||
<h2 className="text-lg font-semibold leading-none truncate">{profileData.nome}</h2>
|
||||
<p className="text-sm text-muted-foreground truncate">{(profileData.crm ? `CRM: ${profileData.crm}` : '') + (profileData.especialidade ? ` • ${profileData.especialidade}` : '')}</p>
|
||||
{user?.email && (
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Actions - Mobile hidden on small screens */}
|
||||
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
|
||||
<SimpleThemeToggle />
|
||||
<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">
|
||||
|
||||
{/* 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}
|
||||
className="text-red-600 border-red-600 hover:bg-red-50 cursor-pointer dark:hover:bg-red-600 dark:hover:text-white"
|
||||
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>
|
||||
|
||||
{/* Mobile menu items - visible when sidebarOpen */}
|
||||
{sidebarOpen && (
|
||||
<div className="md:hidden flex flex-col gap-2 mt-3 pt-3 border-t border-border">
|
||||
<Button asChild variant="default" size="sm" className="w-full bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
<Link href="/">Início</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={logout}
|
||||
size="sm"
|
||||
className="w-full text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-600 dark:hover:text-white"
|
||||
>
|
||||
Sair
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-[220px_1fr] gap-6">
|
||||
{}
|
||||
<aside className="md:sticky md:top-8 h-fit">
|
||||
<nav className="bg-card shadow-md rounded-lg border border-border p-3 space-y-1">
|
||||
<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={`${
|
||||
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
|
||||
variant={activeSection === 'calendario' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('calendario')}
|
||||
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => {
|
||||
setActiveSection('calendario');
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<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
|
||||
variant={activeSection === 'pacientes' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('pacientes')}
|
||||
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => {
|
||||
setActiveSection('pacientes');
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Pacientes
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeSection === 'laudos' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('laudos')}
|
||||
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => {
|
||||
setActiveSection('laudos');
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Laudos
|
||||
</Button>
|
||||
{/* Comunicação removida - campos embaixo do calendário */}
|
||||
{/* <Button
|
||||
<Button
|
||||
variant={activeSection === 'comunicacao' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('comunicacao')}
|
||||
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => {
|
||||
setActiveSection('comunicacao');
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Comunicação
|
||||
</Button> */}
|
||||
SMS
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeSection === 'perfil' ? 'default' : 'ghost'}
|
||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => setActiveSection('perfil')}
|
||||
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||
onClick={() => {
|
||||
setActiveSection('perfil');
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Meu Perfil
|
||||
@ -3033,11 +3185,12 @@ const ProfissionalPage = () => {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 min-w-0 w-full">
|
||||
<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>
|
||||
<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()}
|
||||
</main>
|
||||
@ -3157,21 +3310,21 @@ const ProfissionalPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
{/* Modal de ação para editar evento */}
|
||||
{showActionModal && selectedEvent && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
|
||||
<div className="bg-card border border-border p-6 rounded-lg w-96">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
<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-4 sm:p-6 rounded-lg w-full max-w-md">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-2">
|
||||
Consulta de {selectedEvent.title}
|
||||
</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}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
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" />
|
||||
Editar
|
||||
@ -3181,7 +3334,7 @@ const ProfissionalPage = () => {
|
||||
<Button
|
||||
onClick={() => setShowActionModal(false)}
|
||||
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
|
||||
</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="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 */}
|
||||
<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
|
||||
onClick={() => {
|
||||
const prev = new Date(selectedDayDate);
|
||||
prev.setDate(prev.getDate() - 1);
|
||||
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"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<ChevronLeft className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</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', {
|
||||
weekday: 'long',
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
month: 'short'
|
||||
})}
|
||||
</h2>
|
||||
|
||||
@ -3222,20 +3374,18 @@ const ProfissionalPage = () => {
|
||||
next.setDate(next.getDate() + 1);
|
||||
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"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
<ChevronRight className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
|
||||
<div className="w-12" />
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<X className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -3247,16 +3397,16 @@ const ProfissionalPage = () => {
|
||||
|
||||
if (dayEvents.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CalendarIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<p className="text-lg">Nenhuma consulta agendada para este dia</p>
|
||||
<div className="text-center py-6 sm:py-8 text-muted-foreground">
|
||||
<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-base sm:text-lg">Nenhuma consulta agendada para este dia</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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' : ''}
|
||||
</p>
|
||||
{dayEvents.map((appointment) => {
|
||||
@ -3264,20 +3414,20 @@ const ProfissionalPage = () => {
|
||||
return (
|
||||
<div
|
||||
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) }}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<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" />
|
||||
{appointment.title}
|
||||
</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}
|
||||
</span>
|
||||
</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">
|
||||
<Clock className="h-4 w-4" />
|
||||
{appointment.time}
|
||||
@ -3325,3 +3475,4 @@ const getShortId = (id?: string) => {
|
||||
};
|
||||
|
||||
export default ProfissionalPage;
|
||||
|
||||
|
||||
@ -1,72 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<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>
|
||||
</header>
|
||||
);
|
||||
|
||||
@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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";
|
||||
|
||||
interface FormData {
|
||||
@ -91,6 +92,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
||||
const [lockedDurationFromSlot, setLockedDurationFromSlot] = useState(false);
|
||||
const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false);
|
||||
const [exceptionDialogMessage, setExceptionDialogMessage] = useState<string | null>(null);
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
|
||||
// Helpers to convert between ISO (server) and input[type=datetime-local] value
|
||||
const isoToDatetimeLocal = (iso?: string | null) => {
|
||||
@ -554,6 +556,42 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
||||
// 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 { name, value } = event.target;
|
||||
|
||||
@ -684,6 +722,9 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
||||
} catch (e) {}
|
||||
|
||||
|
||||
// ref to the appointment date input
|
||||
const appointmentDateRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
return (
|
||||
<form className="space-y-8">
|
||||
{/* Exception dialog shown when a blocking exception exists for selected date */}
|
||||
@ -863,10 +904,50 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<Calendar className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<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} />
|
||||
<Input
|
||||
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 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">
|
||||
{loadingSlots ? (
|
||||
<div className="col-span-3">Carregando horários...</div>
|
||||
) : availableSlots && availableSlots.length ? (
|
||||
availableSlots.map((s) => {
|
||||
) : filteredAvailableSlots && filteredAvailableSlots.length ? (
|
||||
filteredAvailableSlots.map((s) => {
|
||||
const dt = new Date(s.datetime);
|
||||
const hh = String(dt.getHours()).padStart(2, '0');
|
||||
const mm = String(dt.getMinutes()).padStart(2, '0');
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
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"
|
||||
|
||||
export interface Event {
|
||||
@ -343,17 +343,6 @@ export function EventManager({
|
||||
<span className="ml-1">Lista</span>
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@ -515,11 +504,11 @@ export function EventManager({
|
||||
|
||||
{/* Event Dialog */}
|
||||
<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>
|
||||
<DialogTitle>{isCreating ? "Criar Evento" : "Detalhes do Evento"}</DialogTitle>
|
||||
<DialogTitle>{isCreating ? "Criar Evento" : "Detalhes do Agendamento"}</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
@ -528,7 +517,7 @@ export function EventManager({
|
||||
<Label htmlFor="title">Título</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={isCreating ? newEvent.title : selectedEvent?.title}
|
||||
value={isCreating ? (newEvent.title ?? "") : (selectedEvent?.title ?? "")}
|
||||
onChange={(e) =>
|
||||
isCreating
|
||||
? setNewEvent((prev) => ({ ...prev, title: e.target.value }))
|
||||
@ -542,7 +531,7 @@ export function EventManager({
|
||||
<Label htmlFor="description">Descrição</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={isCreating ? newEvent.description : selectedEvent?.description}
|
||||
value={isCreating ? (newEvent.description ?? "") : (selectedEvent?.description ?? "")}
|
||||
onChange={(e) =>
|
||||
isCreating
|
||||
? setNewEvent((prev) => ({
|
||||
@ -972,7 +961,7 @@ function WeekView({
|
||||
getColorClasses: (color: string) => { bg: string; text: string }
|
||||
}) {
|
||||
const startOfWeek = new Date(currentDate)
|
||||
startOfWeek.setDate(currentDate.getDay())
|
||||
startOfWeek.setDate(currentDate.getDate() - currentDate.getDay())
|
||||
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
||||
const day = new Date(startOfWeek)
|
||||
@ -980,7 +969,30 @@ function WeekView({
|
||||
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) => {
|
||||
return events.filter((event) => {
|
||||
@ -1071,7 +1083,26 @@ function DayView({
|
||||
onDrop: (date: Date, hour: number) => void
|
||||
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) => {
|
||||
return events.filter((event) => {
|
||||
|
||||
@ -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"
|
||||
>
|
||||
❮
|
||||
</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"
|
||||
>
|
||||
❯
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user