Compare commits
7 Commits
6d79ec2321
...
216b631ba7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
216b631ba7 | ||
| b42ef99471 | |||
| a2f4a37eb0 | |||
| ec11b015ee | |||
| 280d314b5d | |||
| b302bf1c66 | |||
| add30c54a3 |
@ -2,30 +2,27 @@
|
|||||||
|
|
||||||
// Imports mantidos
|
// Imports mantidos
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
// --- Imports do EventManager (NOVO) - MANTIDOS ---
|
// --- Imports do EventManager (NOVO) - MANTIDOS ---
|
||||||
import { EventManager, type Event } from "@/components/features/general/event-manager";
|
import { EventManager, type Event } from "@/components/features/general/event-manager";
|
||||||
import { v4 as uuidv4 } from 'uuid'; // Usado para IDs de fallback
|
import { v4 as uuidv4 } from 'uuid'; // Usado para IDs de fallback
|
||||||
|
|
||||||
// Imports mantidos
|
// Imports mantidos
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { mockWaitingList } from "@/lib/mocks/appointment-mocks";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { ThreeDWallCalendar, CalendarEvent } from "@/components/ui/three-dwall-calendar"; // Calendário 3D mantido
|
|
||||||
import { PatientRegistrationForm } from "@/components/features/forms/patient-registration-form";
|
|
||||||
|
|
||||||
const ListaEspera = dynamic(
|
|
||||||
() => import("@/components/features/agendamento/ListaEspera"),
|
|
||||||
{ ssr: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function AgendamentoPage() {
|
export default function AgendamentoPage() {
|
||||||
const { user, token } = useAuth();
|
|
||||||
const [appointments, setAppointments] = useState<any[]>([]);
|
const [appointments, setAppointments] = useState<any[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<"calendar" | "3d">("calendar");
|
// REMOVIDO: abas e 3D → não há mais alternância de abas
|
||||||
const [threeDEvents, setThreeDEvents] = useState<CalendarEvent[]>([]);
|
// const [activeTab, setActiveTab] = useState<"calendar" | "3d">("calendar");
|
||||||
|
|
||||||
|
// REMOVIDO: estados do 3D e formulário do paciente (eram usados pelo 3D)
|
||||||
|
// const [threeDEvents, setThreeDEvents] = useState<CalendarEvent[]>([]);
|
||||||
|
// const [showPatientForm, setShowPatientForm] = useState(false);
|
||||||
|
|
||||||
|
// --- NOVO ESTADO ---
|
||||||
|
// Estado para alimentar o NOVO EventManager com dados da API
|
||||||
|
const [managerEvents, setManagerEvents] = useState<Event[]>([]);
|
||||||
|
const [managerLoading, setManagerLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
// Padroniza idioma da página para pt-BR (afeta componentes que usam o lang do documento)
|
// Padroniza idioma da página para pt-BR (afeta componentes que usam o lang do documento)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -42,21 +39,6 @@ export default function AgendamentoPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- NOVO ESTADO ---
|
|
||||||
// Estado para alimentar o NOVO EventManager com dados da API
|
|
||||||
const [managerEvents, setManagerEvents] = useState<Event[]>([]);
|
|
||||||
const [managerLoading, setManagerLoading] = useState<boolean>(true);
|
|
||||||
|
|
||||||
// Estado para o formulário de registro de paciente
|
|
||||||
const [showPatientForm, setShowPatientForm] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "c") setActiveTab("calendar");
|
|
||||||
if (event.key === "3") setActiveTab("3d");
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -67,8 +49,8 @@ export default function AgendamentoPage() {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!arr || !arr.length) {
|
if (!arr || !arr.length) {
|
||||||
setAppointments([]);
|
setAppointments([]);
|
||||||
setThreeDEvents([]);
|
// REMOVIDO: setThreeDEvents([])
|
||||||
setManagerEvents([]); // Limpa o novo calendário
|
setManagerEvents([]);
|
||||||
setManagerLoading(false);
|
setManagerLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -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 patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente';
|
||||||
const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim();
|
const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim();
|
||||||
|
|
||||||
// Mapeamento de cores padronizado:
|
// Mapeamento de cores padronizado
|
||||||
// azul = solicitado; verde = confirmado; laranja = pendente; vermelho = cancelado; azul como fallback
|
|
||||||
const status = String(obj.status || "").toLowerCase();
|
const status = String(obj.status || "").toLowerCase();
|
||||||
let color: Event["color"] = "blue";
|
let color: Event["color"] = "blue";
|
||||||
if (status === "confirmed" || status === "confirmado") color = "green";
|
if (status === "confirmed" || status === "confirmado") color = "green";
|
||||||
@ -112,27 +93,12 @@ export default function AgendamentoPage() {
|
|||||||
setManagerLoading(false);
|
setManagerLoading(false);
|
||||||
// --- FIM DA LÓGICA ---
|
// --- FIM DA LÓGICA ---
|
||||||
|
|
||||||
// Convert to 3D calendar events (MANTIDO 100%)
|
// REMOVIDO: conversão para 3D e setThreeDEvents
|
||||||
const threeDEvents: CalendarEvent[] = (arr || []).map((obj: any) => {
|
|
||||||
const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null;
|
|
||||||
const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente';
|
|
||||||
const appointmentType = obj.appointment_type ?? obj.type ?? 'Consulta';
|
|
||||||
const title = `${patient}: ${appointmentType}`.trim();
|
|
||||||
return {
|
|
||||||
id: obj.id || String(Date.now()),
|
|
||||||
title,
|
|
||||||
date: scheduled ? new Date(scheduled).toISOString() : new Date().toISOString(),
|
|
||||||
status: obj.status || 'pending',
|
|
||||||
patient,
|
|
||||||
type: appointmentType,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setThreeDEvents(threeDEvents);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[AgendamentoPage] falha ao carregar agendamentos', err);
|
console.warn('[AgendamentoPage] falha ao carregar agendamentos', err);
|
||||||
setAppointments([]);
|
setAppointments([]);
|
||||||
setThreeDEvents([]);
|
// REMOVIDO: setThreeDEvents([])
|
||||||
setManagerEvents([]); // Limpa o novo calendário
|
setManagerEvents([]);
|
||||||
setManagerLoading(false);
|
setManagerLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@ -154,12 +120,38 @@ export default function AgendamentoPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddEvent = (event: CalendarEvent) => {
|
// Mapeia cor do calendário -> status da API
|
||||||
setThreeDEvents((prev) => [...prev, event]);
|
const statusFromColor = (color?: string) => {
|
||||||
|
switch ((color || "").toLowerCase()) {
|
||||||
|
case "green": return "confirmed";
|
||||||
|
case "orange": return "pending";
|
||||||
|
case "red": return "canceled";
|
||||||
|
default: return "requested";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveEvent = (id: string) => {
|
// Envia atualização para a API e atualiza UI
|
||||||
setThreeDEvents((prev) => prev.filter((e) => e.id !== id));
|
const handleEventUpdate = async (id: string, partial: Partial<Event>) => {
|
||||||
|
try {
|
||||||
|
const payload: any = {};
|
||||||
|
if (partial.startTime) payload.scheduled_at = partial.startTime.toISOString();
|
||||||
|
if (partial.startTime && partial.endTime) {
|
||||||
|
const minutes = Math.max(1, Math.round((partial.endTime.getTime() - partial.startTime.getTime()) / 60000));
|
||||||
|
payload.duration_minutes = minutes;
|
||||||
|
}
|
||||||
|
if (partial.color) payload.status = statusFromColor(partial.color);
|
||||||
|
if (typeof partial.description === "string") payload.notes = partial.description;
|
||||||
|
|
||||||
|
if (Object.keys(payload).length) {
|
||||||
|
const api = await import('@/lib/api');
|
||||||
|
await api.atualizarAgendamento(id, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otimista: reflete mudanças locais
|
||||||
|
setManagerEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...partial } : e)));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Calendário] Falha ao atualizar agendamento na API:", e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -167,39 +159,17 @@ export default function AgendamentoPage() {
|
|||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<div className="flex w-full flex-col gap-10 p-6">
|
<div className="flex w-full flex-col gap-10 p-6">
|
||||||
<div className="flex flex-row justify-between items-center">
|
<div className="flex flex-row justify-between items-center">
|
||||||
{/* Todo o cabeçalho foi mantido */}
|
{/* Cabeçalho simplificado (sem 3D) */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<h1 className="text-2xl font-bold text-foreground">Calendário</h1>
|
||||||
{activeTab === "calendar" ? "Calendário" : activeTab === "3d" ? "Calendário 3D" : "Lista de Espera"}
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Navegue através dos atalhos: Calendário (C), Fila de espera (F) ou 3D (3).
|
Navegue através do atalho: Calendário (C).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2 items-center">
|
{/* REMOVIDO: botões de abas Calendário/3D */}
|
||||||
<div className="flex flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={"outline"}
|
|
||||||
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-l-[100px] rounded-r-none"
|
|
||||||
onClick={() => setActiveTab("calendar")}
|
|
||||||
>
|
|
||||||
Calendário
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={"outline"}
|
|
||||||
className="bg-muted hover:bg-primary! hover:text-white! transition-colors rounded-r-[100px] rounded-l-none"
|
|
||||||
onClick={() => setActiveTab("3d")}
|
|
||||||
>
|
|
||||||
3D
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legenda de status (estilo Google Calendar) */}
|
{/* Legenda de status (aplica-se ao EventManager) */}
|
||||||
<div className="rounded-md border bg-card/60 p-2 sm:p-3 -mt-4">
|
<div className="rounded-md border bg-card/60 p-2 sm:p-3 -mt-4">
|
||||||
<div className="flex flex-wrap items-center gap-6 text-sm">
|
<div className="flex flex-wrap items-center gap-6 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -210,49 +180,35 @@ export default function AgendamentoPage() {
|
|||||||
<span aria-hidden className="h-3 w-3 rounded-full bg-green-500 ring-2 ring-green-500/30" />
|
<span aria-hidden className="h-3 w-3 rounded-full bg-green-500 ring-2 ring-green-500/30" />
|
||||||
<span className="text-foreground">Confirmado</span>
|
<span className="text-foreground">Confirmado</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Novo: Cancelado (vermelho) */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span aria-hidden className="h-3 w-3 rounded-full bg-red-500 ring-2 ring-red-500/30" />
|
||||||
|
<span className="text-foreground">Cancelado</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* --- AQUI ESTÁ A SUBSTITUIÇÃO --- */}
|
{/* Apenas o EventManager */}
|
||||||
{activeTab === "calendar" ? (
|
|
||||||
<div className="flex w-full">
|
<div className="flex w-full">
|
||||||
{/* mostra loading até managerEvents ser preenchido (API integrada desde a entrada) */}
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{managerLoading ? (
|
{managerLoading ? (
|
||||||
<div className="flex items-center justify-center w-full min-h-[70vh]">
|
<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 className="text-sm text-muted-foreground">Conectando ao calendário — carregando agendamentos...</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// EventManager ocupa a área principal e já recebe events da API
|
|
||||||
<div className="w-full min-h-[70vh]">
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Formulário de Registro de Paciente */}
|
{/* REMOVIDO: PatientRegistrationForm (era acionado pelo 3D) */}
|
||||||
<PatientRegistrationForm
|
|
||||||
open={showPatientForm}
|
|
||||||
onOpenChange={setShowPatientForm}
|
|
||||||
mode="create"
|
|
||||||
onSaved={(newPaciente) => {
|
|
||||||
console.log('[Calendar] Novo paciente registrado:', newPaciente);
|
|
||||||
setShowPatientForm(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -145,6 +145,11 @@ export default function DoutoresPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
|
// NOVO: Ordenação e filtros
|
||||||
|
const [sortBy, setSortBy] = useState<"name_asc" | "name_desc" | "recent" | "oldest">("name_asc");
|
||||||
|
const [stateFilter, setStateFilter] = useState<string>("");
|
||||||
|
const [cityFilter, setCityFilter] = useState<string>("");
|
||||||
|
const [specialtyFilter, setSpecialtyFilter] = useState<string>("");
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -272,47 +277,87 @@ export default function DoutoresPage() {
|
|||||||
};
|
};
|
||||||
}, [searchTimeout]);
|
}, [searchTimeout]);
|
||||||
|
|
||||||
// Lista de médicos a exibir (busca ou filtro local)
|
// NOVO: Opções dinâmicas
|
||||||
|
const stateOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Set((doctors || []).map((d) => (d.state || "").trim()).filter(Boolean)),
|
||||||
|
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
|
||||||
|
[doctors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cityOptions = useMemo(() => {
|
||||||
|
const base = (doctors || []).filter((d) => !stateFilter || String(d.state) === stateFilter);
|
||||||
|
return Array.from(
|
||||||
|
new Set(base.map((d) => (d.city || "").trim()).filter(Boolean)),
|
||||||
|
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
|
||||||
|
}, [doctors, stateFilter]);
|
||||||
|
|
||||||
|
const specialtyOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Set((doctors || []).map((d) => (d.especialidade || "").trim()).filter(Boolean)),
|
||||||
|
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
|
||||||
|
[doctors],
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOVO: Índice para ordenação por "tempo" (ordem de carregamento)
|
||||||
|
const indexById = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
(doctors || []).forEach((d, i) => map.set(String(d.id), i));
|
||||||
|
return map;
|
||||||
|
}, [doctors]);
|
||||||
|
|
||||||
|
// Lista de médicos a exibir com busca + filtros + ordenação
|
||||||
const displayedDoctors = useMemo(() => {
|
const displayedDoctors = useMemo(() => {
|
||||||
console.log('🔍 Filtro - search:', search, 'searchMode:', searchMode, 'doctors:', doctors.length, 'searchResults:', searchResults.length);
|
console.log('🔍 Filtro - search:', search, 'searchMode:', searchMode, 'doctors:', doctors.length, 'searchResults:', searchResults.length);
|
||||||
|
|
||||||
// Se não tem busca, mostra todos os médicos
|
|
||||||
if (!search.trim()) return doctors;
|
|
||||||
|
|
||||||
const q = search.toLowerCase().trim();
|
const q = search.toLowerCase().trim();
|
||||||
const qDigits = q.replace(/\D/g, "");
|
const qDigits = q.replace(/\D/g, "");
|
||||||
|
|
||||||
// Se estamos em modo de busca (servidor), filtra os resultados da busca
|
|
||||||
const sourceList = searchMode ? searchResults : doctors;
|
const sourceList = searchMode ? searchResults : doctors;
|
||||||
console.log('🔍 Usando sourceList:', searchMode ? 'searchResults' : 'doctors', '- tamanho:', sourceList.length);
|
|
||||||
|
|
||||||
const filtered = sourceList.filter((d) => {
|
// 1) Busca
|
||||||
// Busca por nome
|
const afterSearch = !q
|
||||||
|
? sourceList
|
||||||
|
: sourceList.filter((d) => {
|
||||||
const byName = (d.full_name || "").toLowerCase().includes(q);
|
const byName = (d.full_name || "").toLowerCase().includes(q);
|
||||||
|
|
||||||
// Busca por CRM (remove formatação se necessário)
|
|
||||||
const byCrm = qDigits.length >= 3 && (d.crm || "").replace(/\D/g, "").includes(qDigits);
|
const byCrm = qDigits.length >= 3 && (d.crm || "").replace(/\D/g, "").includes(qDigits);
|
||||||
|
|
||||||
// Busca por ID (UUID completo ou parcial)
|
|
||||||
const byId = (d.id || "").toLowerCase().includes(q);
|
const byId = (d.id || "").toLowerCase().includes(q);
|
||||||
|
|
||||||
// Busca por email
|
|
||||||
const byEmail = (d.email || "").toLowerCase().includes(q);
|
const byEmail = (d.email || "").toLowerCase().includes(q);
|
||||||
|
|
||||||
// Busca por especialidade
|
|
||||||
const byEspecialidade = (d.especialidade || "").toLowerCase().includes(q);
|
const byEspecialidade = (d.especialidade || "").toLowerCase().includes(q);
|
||||||
|
|
||||||
const match = byName || byCrm || byId || byEmail || byEspecialidade;
|
const match = byName || byCrm || byId || byEmail || byEspecialidade;
|
||||||
if (match) {
|
if (match) console.log('✅ Match encontrado:', d.full_name, d.id);
|
||||||
console.log('✅ Match encontrado:', d.full_name, d.id, 'por:', { byName, byCrm, byId, byEmail, byEspecialidade });
|
|
||||||
}
|
|
||||||
|
|
||||||
return match;
|
return match;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('🔍 Resultados filtrados:', filtered.length);
|
// 2) Filtros de localização e especialidade
|
||||||
return filtered;
|
const afterFilters = afterSearch.filter((d) => {
|
||||||
}, [doctors, search, searchMode, searchResults]);
|
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
|
// Dados paginados
|
||||||
const paginatedDoctors = useMemo(() => {
|
const paginatedDoctors = useMemo(() => {
|
||||||
@ -323,10 +368,10 @@ export default function DoutoresPage() {
|
|||||||
|
|
||||||
const totalPages = Math.ceil(displayedDoctors.length / itemsPerPage);
|
const totalPages = Math.ceil(displayedDoctors.length / itemsPerPage);
|
||||||
|
|
||||||
// Reset para página 1 quando mudar a busca ou itens por página
|
// Reset página ao mudar busca/filtros/ordenação
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [search, itemsPerPage, searchMode]);
|
}, [search, itemsPerPage, searchMode, stateFilter, cityFilter, specialtyFilter, sortBy]);
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@ -440,7 +485,7 @@ export default function DoutoresPage() {
|
|||||||
<p className="text-muted-foreground">Gerencie os médicos da sua clínica</p>
|
<p className="text-muted-foreground">Gerencie os médicos da sua clínica</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
@ -473,6 +518,59 @@ export default function DoutoresPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* NOVO: Ordenar por */}
|
||||||
|
<select
|
||||||
|
aria-label="Ordenar por"
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="name_asc">Nome (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}>
|
<Button onClick={handleAdd} disabled={loading}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Novo Médico
|
Novo Médico
|
||||||
|
|||||||
@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@ -54,6 +53,11 @@ export default function PacientesPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
|
// Ordenação e filtros adicionais
|
||||||
|
const [sortBy, setSortBy] = useState<"name_asc" | "name_desc" | "recent" | "oldest">("name_asc");
|
||||||
|
const [stateFilter, setStateFilter] = useState<string>("");
|
||||||
|
const [cityFilter, setCityFilter] = useState<string>("");
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -77,27 +81,72 @@ export default function PacientesPage() {
|
|||||||
loadAll();
|
loadAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Opções dinâmicas para Estado e Cidade
|
||||||
|
const stateOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Set((patients || []).map((p) => (p.state || "").trim()).filter(Boolean)),
|
||||||
|
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
|
||||||
|
[patients],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cityOptions = useMemo(() => {
|
||||||
|
const base = (patients || []).filter((p) => !stateFilter || String(p.state) === stateFilter);
|
||||||
|
return Array.from(
|
||||||
|
new Set(base.map((p) => (p.city || "").trim()).filter(Boolean)),
|
||||||
|
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
|
||||||
|
}, [patients, stateFilter]);
|
||||||
|
|
||||||
|
// Índice para ordenar por "tempo" (ordem de carregamento)
|
||||||
|
const indexById = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
(patients || []).forEach((p, i) => map.set(String(p.id), i));
|
||||||
|
return map;
|
||||||
|
}, [patients]);
|
||||||
|
|
||||||
|
// Substitui o filtered anterior: aplica busca + filtros + ordenação
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search.trim()) return patients;
|
let base = patients;
|
||||||
|
|
||||||
|
// Busca
|
||||||
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase().trim();
|
const q = search.toLowerCase().trim();
|
||||||
const qDigits = q.replace(/\D/g, "");
|
const qDigits = q.replace(/\D/g, "");
|
||||||
|
base = patients.filter((p) => {
|
||||||
return patients.filter((p) => {
|
|
||||||
// Busca por nome
|
|
||||||
const byName = (p.full_name || "").toLowerCase().includes(q);
|
const byName = (p.full_name || "").toLowerCase().includes(q);
|
||||||
|
|
||||||
// Busca por CPF (remove formatação)
|
|
||||||
const byCPF = qDigits.length >= 3 && (p.cpf || "").replace(/\D/g, "").includes(qDigits);
|
const byCPF = qDigits.length >= 3 && (p.cpf || "").replace(/\D/g, "").includes(qDigits);
|
||||||
|
|
||||||
// Busca por ID (UUID completo ou parcial)
|
|
||||||
const byId = (p.id || "").toLowerCase().includes(q);
|
const byId = (p.id || "").toLowerCase().includes(q);
|
||||||
|
|
||||||
// Busca por email
|
|
||||||
const byEmail = (p.email || "").toLowerCase().includes(q);
|
const byEmail = (p.email || "").toLowerCase().includes(q);
|
||||||
|
|
||||||
return byName || byCPF || byId || byEmail;
|
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
|
// Dados paginados
|
||||||
const paginatedData = useMemo(() => {
|
const paginatedData = useMemo(() => {
|
||||||
@ -108,10 +157,10 @@ export default function PacientesPage() {
|
|||||||
|
|
||||||
const totalPages = Math.ceil(filtered.length / itemsPerPage);
|
const totalPages = Math.ceil(filtered.length / itemsPerPage);
|
||||||
|
|
||||||
// Reset para página 1 quando mudar a busca ou itens por página
|
// Reset página ao mudar filtros/ordenadores
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [search, itemsPerPage]);
|
}, [search, itemsPerPage, stateFilter, cityFilter, sortBy]);
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@ -214,7 +263,8 @@ export default function PacientesPage() {
|
|||||||
<p className="text-muted-foreground">Gerencie os pacientes</p>
|
<p className="text-muted-foreground">Gerencie os pacientes</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* Busca */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@ -225,7 +275,52 @@ export default function PacientesPage() {
|
|||||||
onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()}
|
onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={() => void handleBuscarServidor()} className="hover:bg-primary hover:text-white">Buscar</Button>
|
<Button variant="secondary" onClick={() => void handleBuscarServidor()} className="hover:bg-primary hover:text-white">
|
||||||
|
Buscar
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Ordenar por */}
|
||||||
|
<select
|
||||||
|
aria-label="Ordenar por"
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="name_asc">Nome (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}>
|
<Button onClick={handleAdd}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Novo paciente
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -355,9 +355,9 @@ const ProfissionalPage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pid = a.patient_id || a.patient || a.patient_id_raw || a.patientId || null;
|
const pid = a.patient_id || (a as any).patient || a.patient_id_raw || a.patientId || null;
|
||||||
const patientObj = pid ? patientMap.get(String(pid)) : null;
|
const patientObj = pid ? patientMap.get(String(pid)) : null;
|
||||||
const patientName = patientObj?.full_name || a.patient || a.patient_name || String(pid) || 'Paciente';
|
const patientName = patientObj?.full_name || (a as any).patient || a.patient_name || String(pid) || 'Paciente';
|
||||||
const patientIdVal = pid || null;
|
const patientIdVal = pid || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -786,7 +786,7 @@ const ProfissionalPage = () => {
|
|||||||
const todayEvents = getTodayEvents();
|
const todayEvents = getTodayEvents();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6 w-full">
|
<section className="bg-card shadow-md rounded-lg border border-border p-6 overflow-x-hidden">{/* adicionada overflow-x-hidden */}
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-xl sm:text-2xl font-bold">Agenda do Dia</h2>
|
<h2 className="text-xl sm:text-2xl font-bold">Agenda do Dia</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -819,8 +819,8 @@ const ProfissionalPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lista de Pacientes do Dia - Responsiva */}
|
{/* Lista de Pacientes do Dia */}
|
||||||
<div className="space-y-3 sm:space-y-4 max-h-[calc(100vh-450px)] overflow-y-auto pr-2">
|
<div className="space-y-4 max-h-[calc(100vh-450px)] overflow-y-auto overflow-x-hidden pr-2">{/* adicionada overflow-x-hidden */}
|
||||||
{todayEvents.length === 0 ? (
|
{todayEvents.length === 0 ? (
|
||||||
<div className="text-center py-6 sm:py-8 text-gray-600 dark:text-muted-foreground">
|
<div className="text-center py-6 sm:py-8 text-gray-600 dark:text-muted-foreground">
|
||||||
<CalendarIcon className="h-10 sm:h-12 w-10 sm:w-12 mx-auto mb-3 sm:mb-4 text-gray-400 dark:text-muted-foreground/50" />
|
<CalendarIcon className="h-10 sm:h-12 w-10 sm:w-12 mx-auto mb-3 sm:mb-4 text-gray-400 dark:text-muted-foreground/50" />
|
||||||
@ -1658,7 +1658,7 @@ const ProfissionalPage = () => {
|
|||||||
function LaudoViewer({ laudo, onClose }: { laudo: any; onClose: () => void }) {
|
function LaudoViewer({ laudo, onClose }: { laudo: any; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||||
<div className="bg-background rounded-lg shadow-xl w-full h-full md:h-auto md:rounded-lg md:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
<div className="bg-background rounded-lg shadow-xl w-full h-full md:rounded-lg md:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,72 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RotateCcw } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
export default function HeaderAgenda() {
|
export default function HeaderAgenda() {
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const isAg = pathname?.startsWith("/agenda");
|
|
||||||
const isPr = pathname?.startsWith("/procedimento");
|
|
||||||
const isFi = pathname?.startsWith("/financeiro");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b bg-background border-border">
|
<header className="border-b bg-background border-border">
|
||||||
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
|
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
|
||||||
<h1 className="text-[18px] font-semibold text-foreground">Novo Agendamento</h1>
|
<h1 className="text-[18px] font-semibold text-foreground">Novo Agendamento</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<nav
|
|
||||||
role="tablist"
|
|
||||||
aria-label="Navegação de Agendamento"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="/agenda"
|
|
||||||
role="tab"
|
|
||||||
className={`px-4 py-1.5 text-[13px] font-medium border rounded-md ${
|
|
||||||
isAg
|
|
||||||
? "bg-primary text-white border-primary dark:bg-primary dark:text-white"
|
|
||||||
: "text-foreground hover:bg-muted border-input"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Agendamento
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/procedimento"
|
|
||||||
role="tab"
|
|
||||||
className={`px-4 py-1.5 text-[13px] font-medium border rounded-md ${
|
|
||||||
isPr
|
|
||||||
? "bg-primary text-white border-primary dark:bg-primary dark:text-white"
|
|
||||||
: "text-foreground hover:bg-muted border-input"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Procedimento
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/financeiro"
|
|
||||||
role="tab"
|
|
||||||
className={`px-4 py-1.5 text-[13px] font-medium border rounded-md ${
|
|
||||||
isFi
|
|
||||||
? "bg-primary text-white border-primary dark:bg-primary dark:text-white"
|
|
||||||
: "text-foreground hover:bg-muted border-input"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Financeiro
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Voltar para Calendário"
|
|
||||||
onClick={() => router.push("/calendar")}
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background text-muted-foreground hover:bg-primary hover:text-white hover:border-primary transition-colors"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { ChevronLeft, ChevronRight, Plus, Calendar, Clock, Grid3x3, List, Search, X } from "lucide-react"
|
import { ChevronLeft, ChevronRight, Calendar, Clock, Grid3x3, List, Search, X } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface Event {
|
export interface Event {
|
||||||
@ -343,17 +343,6 @@ export function EventManager({
|
|||||||
<span className="ml-1">Lista</span>
|
<span className="ml-1">Lista</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setIsCreating(true)
|
|
||||||
setIsDialogOpen(true)
|
|
||||||
}}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Novo Evento
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -515,11 +504,11 @@ export function EventManager({
|
|||||||
|
|
||||||
{/* Event Dialog */}
|
{/* Event Dialog */}
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogContent className="max-w-md max-h[90vh] overflow-y-auto">
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isCreating ? "Criar Evento" : "Detalhes do Evento"}</DialogTitle>
|
<DialogTitle>{isCreating ? "Criar Evento" : "Detalhes do Agendamento"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isCreating ? "Adicione um novo evento ao seu calendário" : "Visualizar e editar detalhes do evento"}
|
{isCreating ? "Adicione um novo evento ao seu calendário" : "Visualizar e editar detalhes do agendamento"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -528,7 +517,7 @@ export function EventManager({
|
|||||||
<Label htmlFor="title">Título</Label>
|
<Label htmlFor="title">Título</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
value={isCreating ? newEvent.title : selectedEvent?.title}
|
value={isCreating ? (newEvent.title ?? "") : (selectedEvent?.title ?? "")}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
isCreating
|
isCreating
|
||||||
? setNewEvent((prev) => ({ ...prev, title: e.target.value }))
|
? setNewEvent((prev) => ({ ...prev, title: e.target.value }))
|
||||||
@ -542,7 +531,7 @@ export function EventManager({
|
|||||||
<Label htmlFor="description">Descrição</Label>
|
<Label htmlFor="description">Descrição</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={isCreating ? newEvent.description : selectedEvent?.description}
|
value={isCreating ? (newEvent.description ?? "") : (selectedEvent?.description ?? "")}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
isCreating
|
isCreating
|
||||||
? setNewEvent((prev) => ({
|
? setNewEvent((prev) => ({
|
||||||
@ -972,7 +961,7 @@ function WeekView({
|
|||||||
getColorClasses: (color: string) => { bg: string; text: string }
|
getColorClasses: (color: string) => { bg: string; text: string }
|
||||||
}) {
|
}) {
|
||||||
const startOfWeek = new Date(currentDate)
|
const startOfWeek = new Date(currentDate)
|
||||||
startOfWeek.setDate(currentDate.getDay())
|
startOfWeek.setDate(currentDate.getDate() - currentDate.getDay())
|
||||||
|
|
||||||
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
||||||
const day = new Date(startOfWeek)
|
const day = new Date(startOfWeek)
|
||||||
@ -980,7 +969,30 @@ function WeekView({
|
|||||||
return day
|
return day
|
||||||
})
|
})
|
||||||
|
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
// NOVO: limita intervalo de horas ao 1º e último evento da semana
|
||||||
|
const [startHour, endHour] = React.useMemo(() => {
|
||||||
|
let minH = Infinity
|
||||||
|
let maxH = -Infinity
|
||||||
|
for (const ev of events) {
|
||||||
|
const d = ev.startTime
|
||||||
|
const sameWeekDay = weekDays.some(wd =>
|
||||||
|
d.getFullYear() === wd.getFullYear() &&
|
||||||
|
d.getMonth() === wd.getMonth() &&
|
||||||
|
d.getDate() === wd.getDate()
|
||||||
|
)
|
||||||
|
if (!sameWeekDay) continue
|
||||||
|
minH = Math.min(minH, d.getHours())
|
||||||
|
maxH = Math.max(maxH, ev.endTime.getHours())
|
||||||
|
}
|
||||||
|
if (!isFinite(minH) || !isFinite(maxH)) return [0, 23] as const
|
||||||
|
if (maxH < minH) maxH = minH
|
||||||
|
return [minH, maxH] as const
|
||||||
|
}, [events, weekDays])
|
||||||
|
|
||||||
|
const hours = React.useMemo(
|
||||||
|
() => Array.from({ length: (endHour - startHour + 1) }, (_, i) => startHour + i),
|
||||||
|
[startHour, endHour]
|
||||||
|
)
|
||||||
|
|
||||||
const getEventsForDayAndHour = (date: Date, hour: number) => {
|
const getEventsForDayAndHour = (date: Date, hour: number) => {
|
||||||
return events.filter((event) => {
|
return events.filter((event) => {
|
||||||
@ -1071,7 +1083,26 @@ function DayView({
|
|||||||
onDrop: (date: Date, hour: number) => void
|
onDrop: (date: Date, hour: number) => void
|
||||||
getColorClasses: (color: string) => { bg: string; text: string }
|
getColorClasses: (color: string) => { bg: string; text: string }
|
||||||
}) {
|
}) {
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
// NOVO: calcula intervalo de horas do 1º ao último evento do dia
|
||||||
|
const [startHour, endHour] = React.useMemo(() => {
|
||||||
|
const sameDayEvents = events.filter((ev) => {
|
||||||
|
const d = ev.startTime
|
||||||
|
return (
|
||||||
|
d.getDate() === currentDate.getDate() &&
|
||||||
|
d.getMonth() === currentDate.getMonth() &&
|
||||||
|
d.getFullYear() === currentDate.getFullYear()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (!sameDayEvents.length) return [0, 23] as const
|
||||||
|
const minH = Math.min(...sameDayEvents.map((e) => e.startTime.getHours()))
|
||||||
|
const maxH = Math.max(...sameDayEvents.map((e) => e.endTime.getHours()))
|
||||||
|
return [minH, Math.max(maxH, minH)] as const
|
||||||
|
}, [events, currentDate])
|
||||||
|
|
||||||
|
const hours = React.useMemo(
|
||||||
|
() => Array.from({ length: (endHour - startHour + 1) }, (_, i) => startHour + i),
|
||||||
|
[startHour, endHour]
|
||||||
|
)
|
||||||
|
|
||||||
const getEventsForHour = (hour: number) => {
|
const getEventsForHour = (hour: number) => {
|
||||||
return events.filter((event) => {
|
return events.filter((event) => {
|
||||||
|
|||||||
@ -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