Compare commits
No commits in common. "d79a5acb07bca996bba922cc11dba4c2701c6309" and "5d78e9f066e7e7a1c5109d1cab94bf2f4ebdcc31" have entirely different histories.
d79a5acb07
...
5d78e9f066
@ -55,16 +55,10 @@ export default function AgendamentoPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean)));
|
||||
const patients = (patientIds && patientIds.length) ? await api.buscarPacientesPorIds(patientIds) : [];
|
||||
const patientsById: Record<string, any> = {};
|
||||
(patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; });
|
||||
|
||||
// Tentar enriquecer com médicos/profissionais quando houver doctor_id
|
||||
const doctorIds = Array.from(new Set(arr.map((a: any) => a.doctor_id).filter(Boolean)));
|
||||
const doctors = (doctorIds && doctorIds.length) ? await api.buscarMedicosPorIds(doctorIds) : [];
|
||||
const doctorsById: Record<string, any> = {};
|
||||
(doctors || []).forEach((d: any) => { if (d && d.id) doctorsById[String(d.id)] = d; });
|
||||
const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean)));
|
||||
const patients = (patientIds && patientIds.length) ? await api.buscarPacientesPorIds(patientIds) : [];
|
||||
const patientsById: Record<string, any> = {};
|
||||
(patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; });
|
||||
|
||||
setAppointments(arr || []);
|
||||
|
||||
@ -86,13 +80,6 @@ export default function AgendamentoPage() {
|
||||
else if (status === "canceled" || status === "cancelado" || status === "cancelled") color = "red";
|
||||
else if (status === "requested" || status === "solicitado") color = "blue";
|
||||
|
||||
const professional = (doctorsById[String(obj.doctor_id)]?.full_name) || obj.doctor_name || obj.professional_name || obj.professional || obj.executante || 'Profissional';
|
||||
const appointmentType = obj.appointment_type || obj.type || obj.appointmentType || '';
|
||||
const insurance = obj.insurance_provider || obj.insurance || obj.convenio || obj.insuranceProvider || null;
|
||||
const completedAt = obj.completed_at || obj.completedAt || null;
|
||||
const cancelledAt = obj.cancelled_at || obj.cancelledAt || null;
|
||||
const cancellationReason = obj.cancellation_reason || obj.cancellationReason || obj.cancel_reason || null;
|
||||
|
||||
return {
|
||||
id: obj.id || uuidv4(),
|
||||
title,
|
||||
@ -100,15 +87,6 @@ export default function AgendamentoPage() {
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
color,
|
||||
// Campos adicionais para visualização detalhada
|
||||
patientName: patient,
|
||||
professionalName: professional,
|
||||
appointmentType,
|
||||
status: obj.status || null,
|
||||
insuranceProvider: insurance,
|
||||
completedAt,
|
||||
cancelledAt,
|
||||
cancellationReason,
|
||||
};
|
||||
});
|
||||
setManagerEvents(newManagerEvents);
|
||||
@ -152,128 +130,6 @@ export default function AgendamentoPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Componente auxiliar: legenda dinâmica que lista as cores/statuss presentes nos agendamentos
|
||||
function DynamicLegend({ events }: { events: Event[] }) {
|
||||
// Mapa de classes para cores conhecidas
|
||||
const colorClassMap: Record<string, string> = {
|
||||
blue: "bg-blue-500 ring-blue-500/20",
|
||||
green: "bg-green-500 ring-green-500/20",
|
||||
orange: "bg-orange-500 ring-orange-500/20",
|
||||
red: "bg-red-500 ring-red-500/20",
|
||||
purple: "bg-purple-500 ring-purple-500/20",
|
||||
pink: "bg-pink-500 ring-pink-500/20",
|
||||
teal: "bg-teal-400 ring-teal-400/20",
|
||||
}
|
||||
|
||||
const hashToColor = (s: string) => {
|
||||
// gera cor hex simples a partir de hash da string
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i)
|
||||
const c = (h & 0x00ffffff).toString(16).toUpperCase()
|
||||
return "#" + "00000".substring(0, 6 - c.length) + c
|
||||
}
|
||||
|
||||
// Agrupa por cor e coleta os status associados
|
||||
const entries = new Map<string, Set<string>>()
|
||||
for (const ev of events) {
|
||||
const col = (ev.color || "blue").toString()
|
||||
const st = (ev.status || statusFromColor(ev.color) || "").toString().toLowerCase()
|
||||
if (!entries.has(col)) entries.set(col, new Set())
|
||||
if (st) entries.get(col)!.add(st)
|
||||
}
|
||||
|
||||
// Painel principal: sempre exibe os 3 status primários (Solicitado, Confirmado, Cancelado)
|
||||
const statusDisplay = (s: string) => {
|
||||
switch (s) {
|
||||
case "requested":
|
||||
case "request":
|
||||
case "solicitado":
|
||||
return "Solicitado"
|
||||
case "confirmed":
|
||||
case "confirmado":
|
||||
return "Confirmado"
|
||||
case "canceled":
|
||||
case "cancelled":
|
||||
case "cancelado":
|
||||
return "Cancelado"
|
||||
case "pending":
|
||||
case "pendente":
|
||||
return "Pendente"
|
||||
case "governo":
|
||||
case "government":
|
||||
return "Governo"
|
||||
default:
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Ordem preferencial para exibição (tenta manter Solicitação/Confirmado/Cancelado em primeiro)
|
||||
const priorityList = [
|
||||
'solicitado','requested',
|
||||
'confirmed','confirmado',
|
||||
'pending','pendente',
|
||||
'canceled','cancelled','cancelado',
|
||||
'governo','government'
|
||||
]
|
||||
|
||||
const items = Array.from(entries.entries()).map(([col, statuses]) => {
|
||||
const statusArr = Array.from(statuses)
|
||||
let priority = 999
|
||||
for (const s of statusArr) {
|
||||
const idx = priorityList.indexOf(s)
|
||||
if (idx >= 0) priority = Math.min(priority, idx)
|
||||
}
|
||||
// if none matched, leave priority high so they appear after known statuses
|
||||
return { col, statuses: statusArr, priority }
|
||||
})
|
||||
|
||||
items.sort((a, b) => a.priority - b.priority || a.col.localeCompare(b.col))
|
||||
|
||||
// Separar itens extras (fora os três principais) para renderizar depois
|
||||
const primaryColors = new Set(['blue', 'green', 'red'])
|
||||
const extras = items.filter(i => !primaryColors.has(i.col.toLowerCase()))
|
||||
|
||||
return (
|
||||
<div className="max-w-full sm:max-w-[520px] rounded-lg border border-slate-700 bg-gradient-to-b from-card/70 to-card/50 px-3 py-2 shadow-md flex items-center gap-4 text-sm overflow-x-auto whitespace-nowrap">
|
||||
{/* Bloco grande com os três status principais sempre visíveis e responsivos */}
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full bg-blue-500 ring-1 ring-white/6" />
|
||||
<span className="text-foreground text-xs sm:text-sm font-medium">Solicitado</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full bg-green-500 ring-1 ring-white/6" />
|
||||
<span className="text-foreground text-xs sm:text-sm font-medium">Confirmado</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full bg-red-500 ring-1 ring-white/6" />
|
||||
<span className="text-foreground text-xs sm:text-sm font-medium">Cancelado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Itens extras detectados dinamicamente (menores) */}
|
||||
{extras.length > 0 && (
|
||||
<div className="flex items-center gap-3 ml-3 flex-wrap">
|
||||
{extras.map(({ col, statuses }) => {
|
||||
const statusList = statuses.map(statusDisplay).filter(Boolean).join(', ')
|
||||
const cls = colorClassMap[col.toLowerCase()]
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
{cls ? (
|
||||
<span aria-hidden className={`h-2 w-2 rounded-full ${cls} ring-1`} />
|
||||
) : (
|
||||
<span aria-hidden className="h-2 w-2 rounded-full ring-1" style={{ backgroundColor: hashToColor(col) }} />
|
||||
)}
|
||||
<span className="text-foreground text-xs">{statusList || col}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Envia atualização para a API e atualiza UI
|
||||
const handleEventUpdate = async (id: string, partial: Partial<Event>) => {
|
||||
try {
|
||||
@ -301,31 +157,58 @@ export default function AgendamentoPage() {
|
||||
return (
|
||||
<div className="bg-background">
|
||||
<div className="w-full">
|
||||
<div className="w-full max-w-full mx-0 flex flex-col gap-0 p-0 pl-4 sm:pl-6">
|
||||
<div className="relative flex items-center justify-between gap-0 p-0 py-2 sm:py-0">
|
||||
<div className="w-full max-w-7xl mx-auto flex flex-col gap-6 sm:gap-10 p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
|
||||
{/* Cabeçalho simplificado (sem 3D) */}
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground m-0 p-0">Calendário</h1>
|
||||
<p className="text-muted-foreground m-0 p-0 text-xs">Navegue através do atalho: Calendário (C).</p>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-foreground">Calendário</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Navegue através do atalho: Calendário (C).
|
||||
</p>
|
||||
</div>
|
||||
{/* REMOVIDO: botões de abas Calendário/3D */}
|
||||
</div>
|
||||
|
||||
{/* legenda dinâmica: mostra as cores presentes nos agendamentos do dia atual */}
|
||||
<div className="sm:absolute sm:top-2 sm:right-2 mt-2 sm:mt-0 z-40">
|
||||
<DynamicLegend events={managerEvents} />
|
||||
{/* Legenda de status (aplica-se ao EventManager) */}
|
||||
<div className="rounded-md border bg-card/60 p-2 sm:p-3 -mt-2 sm:-mt-4 overflow-x-auto">
|
||||
<div className="flex flex-nowrap items-center gap-4 sm:gap-6 text-xs sm:text-sm whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden className="h-3 w-3 rounded-full bg-blue-500 ring-2 ring-blue-500/30" />
|
||||
<span className="text-foreground">Solicitado</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
<div className="w-full m-0 p-0">
|
||||
{managerLoading ? (
|
||||
<div className="flex items-center justify-center w-full min-h-[70vh] m-0 p-0">
|
||||
<div className="text-xs text-muted-foreground">Conectando ao calendário — carregando agendamentos...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full min-h-[80vh] m-0 p-0">
|
||||
<EventManager events={managerEvents} className="compact-event-manager" onEventUpdate={handleEventUpdate} />
|
||||
</div>
|
||||
)}
|
||||
{/* Apenas o EventManager */}
|
||||
<div className="flex w-full">
|
||||
<div className="w-full">
|
||||
{managerLoading ? (
|
||||
<div className="flex items-center justify-center w-full min-h-[60vh] sm:min-h-[70vh]">
|
||||
<div className="text-sm text-muted-foreground">Conectando ao calendário — carregando agendamentos...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full min-h-[60vh] sm:min-h-[70vh]">
|
||||
<EventManager
|
||||
events={managerEvents}
|
||||
className="compact-event-manager"
|
||||
onEventUpdate={handleEventUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* REMOVIDO: PatientRegistrationForm (era acionado pelo 3D) */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect } from "react"
|
||||
import { buscarAgendamentoPorId, buscarPacientesPorIds, buscarMedicosPorIds } from "@/lib/api"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@ -30,15 +29,6 @@ export interface Event {
|
||||
category?: string
|
||||
attendees?: string[]
|
||||
tags?: string[]
|
||||
// Additional appointment fields (optional)
|
||||
patientName?: string
|
||||
professionalName?: string
|
||||
appointmentType?: string
|
||||
status?: string
|
||||
insuranceProvider?: string | null
|
||||
completedAt?: string | Date | null
|
||||
cancelledAt?: string | Date | null
|
||||
cancellationReason?: string | null
|
||||
}
|
||||
|
||||
export interface EventManagerProps {
|
||||
@ -240,73 +230,6 @@ export function EventManager({
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
// Quando um evento é selecionado para visualização, buscar dados completos do agendamento
|
||||
// para garantir que patient/professional/tags/attendees/status estejam preenchidos.
|
||||
useEffect(() => {
|
||||
if (!selectedEvent || isCreating) return
|
||||
let cancelled = false
|
||||
|
||||
const enrich = async () => {
|
||||
try {
|
||||
const full = await buscarAgendamentoPorId(selectedEvent.id).catch(() => null)
|
||||
if (cancelled || !full) return
|
||||
|
||||
// Tentar resolver nomes de paciente e profissional a partir de IDs quando possível
|
||||
let patientName = selectedEvent.patientName
|
||||
if ((!patientName || patientName === "—") && full.patient_id) {
|
||||
const pList = await buscarPacientesPorIds([full.patient_id as any]).catch(() => [])
|
||||
if (pList && pList.length) patientName = (pList[0] as any).full_name || (pList[0] as any).fullName || (pList[0] as any).name
|
||||
}
|
||||
|
||||
let professionalName = selectedEvent.professionalName
|
||||
if ((!professionalName || professionalName === "—") && full.doctor_id) {
|
||||
const dList = await buscarMedicosPorIds([full.doctor_id as any]).catch(() => [])
|
||||
if (dList && dList.length) professionalName = (dList[0] as any).full_name || (dList[0] as any).fullName || (dList[0] as any).name
|
||||
}
|
||||
|
||||
const merged: Event = {
|
||||
...selectedEvent,
|
||||
// priorizar valores vindos do backend quando existirem
|
||||
title: ((full as any).title as any) || selectedEvent.title,
|
||||
description: ((full as any).notes as any) || ((full as any).patient_notes as any) || selectedEvent.description,
|
||||
patientName: patientName || selectedEvent.patientName,
|
||||
professionalName: professionalName || selectedEvent.professionalName,
|
||||
appointmentType: ((full as any).appointment_type as any) || selectedEvent.appointmentType,
|
||||
status: ((full as any).status as any) || selectedEvent.status,
|
||||
insuranceProvider: ((full as any).insurance_provider as any) ?? selectedEvent.insuranceProvider,
|
||||
completedAt: ((full as any).completed_at as any) ?? selectedEvent.completedAt,
|
||||
cancelledAt: ((full as any).cancelled_at as any) ?? selectedEvent.cancelledAt,
|
||||
cancellationReason: ((full as any).cancellation_reason as any) ?? selectedEvent.cancellationReason,
|
||||
attendees: ((full as any).attendees as any) || ((full as any).participants as any) || selectedEvent.attendees,
|
||||
tags: ((full as any).tags as any) || selectedEvent.tags,
|
||||
}
|
||||
|
||||
if (!cancelled) setSelectedEvent(merged)
|
||||
} catch (err) {
|
||||
// não bloquear UI em caso de falha
|
||||
console.warn('[EventManager] Falha ao enriquecer agendamento:', err)
|
||||
}
|
||||
}
|
||||
|
||||
enrich()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedEvent, isCreating])
|
||||
|
||||
// Remove trechos redundantes como "Status: requested." que às vezes vêm concatenados na descrição
|
||||
const sanitizeDescription = (d?: string | null) => {
|
||||
if (!d) return null
|
||||
try {
|
||||
// Remove qualquer segmento "Status: ..." seguido opcionalmente de ponto
|
||||
const cleaned = String(d).replace(/Status:\s*[^\.\n]+\.?/gi, "").trim()
|
||||
return cleaned || null
|
||||
} catch (e) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
{/* Header */}
|
||||
@ -581,7 +504,7 @@ export function EventManager({
|
||||
|
||||
{/* Event Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="w-full max-w-full sm:max-w-2xl md:max-w-3xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isCreating ? "Criar Evento" : "Detalhes do Agendamento"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@ -589,179 +512,122 @@ export function EventManager({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Dialog content: form when creating; read-only view when viewing */}
|
||||
{isCreating ? (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Título</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newEvent.title ?? ""}
|
||||
onChange={(e) => setNewEvent((prev) => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Título do evento"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Título</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={isCreating ? (newEvent.title ?? "") : (selectedEvent?.title ?? "")}
|
||||
onChange={(e) =>
|
||||
isCreating
|
||||
? setNewEvent((prev) => ({ ...prev, title: e.target.value }))
|
||||
: setSelectedEvent((prev) => (prev ? { ...prev, title: e.target.value } : null))
|
||||
}
|
||||
placeholder="Título do evento"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Descrição</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newEvent.description ?? ""}
|
||||
onChange={(e) => setNewEvent((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Descrição do evento"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Descrição</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={isCreating ? (newEvent.description ?? "") : (selectedEvent?.description ?? "")}
|
||||
onChange={(e) =>
|
||||
isCreating
|
||||
? setNewEvent((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}))
|
||||
: setSelectedEvent((prev) => (prev ? { ...prev, description: e.target.value } : null))
|
||||
}
|
||||
placeholder="Descrição do evento"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startTime">Início</Label>
|
||||
<Input
|
||||
id="startTime"
|
||||
type="datetime-local"
|
||||
value={
|
||||
newEvent.startTime
|
||||
? new Date(newEvent.startTime.getTime() - newEvent.startTime.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => setNewEvent((prev) => ({ ...prev, startTime: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endTime">Fim</Label>
|
||||
<Input
|
||||
id="endTime"
|
||||
type="datetime-local"
|
||||
value={
|
||||
newEvent.endTime
|
||||
? new Date(newEvent.endTime.getTime() - newEvent.endTime.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => setNewEvent((prev) => ({ ...prev, endTime: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDialogOpen(false)
|
||||
setIsCreating(false)
|
||||
setSelectedEvent(null)
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startTime">Início</Label>
|
||||
<Input
|
||||
id="startTime"
|
||||
type="datetime-local"
|
||||
value={
|
||||
isCreating
|
||||
? newEvent.startTime
|
||||
? new Date(newEvent.startTime.getTime() - newEvent.startTime.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: ""
|
||||
: selectedEvent
|
||||
? new Date(
|
||||
selectedEvent.startTime.getTime() - selectedEvent.startTime.getTimezoneOffset() * 60000,
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const date = new Date(e.target.value)
|
||||
isCreating
|
||||
? setNewEvent((prev) => ({ ...prev, startTime: date }))
|
||||
: setSelectedEvent((prev) => (prev ? { ...prev, startTime: date } : null))
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreateEvent}>Criar</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Read-only compact view: title + stacked details + descrição abaixo */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg sm:text-xl font-semibold">{selectedEvent?.title || "—"}</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-3 sm:p-4 rounded-md border bg-card/5 text-sm text-muted-foreground">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<div className="text-[12px] text-muted-foreground">Profissional</div>
|
||||
<div className="mt-1 text-sm font-medium break-words">{selectedEvent?.professionalName || "—"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[12px] text-muted-foreground">Paciente</div>
|
||||
<div className="mt-1 text-sm font-medium break-words">{selectedEvent?.patientName || "—"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[12px] text-muted-foreground">Tipo</div>
|
||||
<div className="mt-1 text-sm font-medium break-words">{selectedEvent?.appointmentType || "—"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[12px] text-muted-foreground">Status</div>
|
||||
<div className="mt-1 text-sm font-medium break-words">{selectedEvent?.status || "—"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[12px] text-muted-foreground">Data</div>
|
||||
<div className="mt-1 text-sm font-medium break-words">{(() => {
|
||||
const formatDate = (d?: string | Date) => {
|
||||
if (!d) return "—"
|
||||
try {
|
||||
const dt = d instanceof Date ? d : new Date(d)
|
||||
if (isNaN(dt.getTime())) return "—"
|
||||
return dt.toLocaleString(LOCALE, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: TIMEZONE })
|
||||
} catch (e) {
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
return formatDate(selectedEvent?.startTime)
|
||||
})()}</div>
|
||||
</div>
|
||||
|
||||
{selectedEvent?.completedAt && (
|
||||
<div>
|
||||
<div className="text-[12px] text-muted-foreground">Concluído em</div>
|
||||
<div className="mt-1 text-sm font-medium break-words">{(() => {
|
||||
const dt = selectedEvent.completedAt
|
||||
try {
|
||||
const d = dt instanceof Date ? dt : new Date(dt as any)
|
||||
return isNaN(d.getTime()) ? "—" : d.toLocaleString(LOCALE, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: TIMEZONE })
|
||||
} catch { return "—" }
|
||||
})()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEvent?.cancelledAt && (
|
||||
<div>
|
||||
<div className="text-[12px] text-muted-foreground">Cancelado em</div>
|
||||
<div className="mt-1 text-sm font-medium break-words">{(() => {
|
||||
const dt = selectedEvent.cancelledAt
|
||||
try {
|
||||
const d = dt instanceof Date ? dt : new Date(dt as any)
|
||||
return isNaN(d.getTime()) ? "—" : d.toLocaleString(LOCALE, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: TIMEZONE })
|
||||
} catch { return "—" }
|
||||
})()}</div>
|
||||
<div className="text-[12px] text-muted-foreground mt-2">Motivo do cancelamento</div>
|
||||
<div className="mt-1 text-sm font-medium break-words">{selectedEvent?.cancellationReason || "—"}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Observações</Label>
|
||||
<div className="min-h-[80px] sm:min-h-[120px] p-3 rounded-md border bg-muted/5 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{sanitizeDescription(selectedEvent?.description) ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDialogOpen(false)
|
||||
setIsCreating(false)
|
||||
setSelectedEvent(null)
|
||||
}}
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endTime">Fim</Label>
|
||||
<Input
|
||||
id="endTime"
|
||||
type="datetime-local"
|
||||
value={
|
||||
isCreating
|
||||
? newEvent.endTime
|
||||
? new Date(newEvent.endTime.getTime() - newEvent.endTime.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: ""
|
||||
: selectedEvent
|
||||
? new Date(selectedEvent.endTime.getTime() - selectedEvent.endTime.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const date = new Date(e.target.value)
|
||||
isCreating
|
||||
? setNewEvent((prev) => ({ ...prev, endTime: date }))
|
||||
: setSelectedEvent((prev) => (prev ? { ...prev, endTime: date } : null))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campos de Categoria/Cor removidos */}
|
||||
|
||||
{/* Campo de Tags removido */}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{!isCreating && (
|
||||
<Button variant="destructive" onClick={() => selectedEvent && handleDeleteEvent(selectedEvent.id)}>
|
||||
Deletar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDialogOpen(false)
|
||||
setIsCreating(false)
|
||||
setSelectedEvent(null)
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={isCreating ? handleCreateEvent : handleUpdateEvent}>
|
||||
{isCreating ? "Criar" : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
@ -1077,7 +943,7 @@ function MonthView({
|
||||
)
|
||||
}
|
||||
|
||||
// Week View Component (simplified and stable)
|
||||
// Week View Component
|
||||
function WeekView({
|
||||
currentDate,
|
||||
events,
|
||||
@ -1092,7 +958,7 @@ function WeekView({
|
||||
onEventClick: (event: Event) => void
|
||||
onDragStart: (event: Event) => void
|
||||
onDragEnd: () => void
|
||||
onDrop: (date: Date, hour?: number) => void
|
||||
onDrop: (date: Date, hour: number) => void
|
||||
getColorClasses: (color: string) => { bg: string; text: string }
|
||||
}) {
|
||||
const startOfWeek = new Date(currentDate)
|
||||
@ -1104,55 +970,103 @@ function WeekView({
|
||||
return day
|
||||
})
|
||||
|
||||
const getEventsForDay = (date: Date) =>
|
||||
events.filter((event) => {
|
||||
const d = new Date(event.startTime)
|
||||
// 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) => {
|
||||
const eventDate = new Date(event.startTime)
|
||||
const eventHour = eventDate.getHours()
|
||||
return (
|
||||
d.getFullYear() === date.getFullYear() &&
|
||||
d.getMonth() === date.getMonth() &&
|
||||
d.getDate() === date.getDate()
|
||||
eventDate.getDate() === date.getDate() &&
|
||||
eventDate.getMonth() === date.getMonth() &&
|
||||
eventDate.getFullYear() === date.getFullYear() &&
|
||||
eventHour === hour
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-auto">
|
||||
<div className="grid grid-cols-7 border-b">
|
||||
<div className="grid grid-cols-8 border-b">
|
||||
<div className="border-r p-2 text-center text-xs font-medium sm:text-sm">Hora</div>
|
||||
{weekDays.map((day) => (
|
||||
<div key={day.toISOString()} className="border-r p-2 text-center text-xs font-medium last:border-r-0 sm:text-sm">
|
||||
<span className="hidden sm:inline">{day.toLocaleDateString(LOCALE, { weekday: "short", timeZone: TIMEZONE })}</span>
|
||||
<span className="sm:hidden">{day.toLocaleDateString(LOCALE, { weekday: "narrow", timeZone: TIMEZONE })}</span>
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className="border-r p-2 text-center text-xs font-medium last:border-r-0 sm:text-sm"
|
||||
>
|
||||
<div className="hidden sm:block">{day.toLocaleDateString(LOCALE, { weekday: "short", timeZone: TIMEZONE })}</div>
|
||||
<div className="sm:hidden">{day.toLocaleDateString(LOCALE, { weekday: "narrow", timeZone: TIMEZONE })}</div>
|
||||
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
{day.toLocaleDateString(LOCALE, { month: "short", day: "numeric", timeZone: TIMEZONE })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7">
|
||||
{weekDays.map((day, idx) => {
|
||||
const dayEvents = getEventsForDay(day)
|
||||
return (
|
||||
<div key={idx} className="min-h-40 border-r p-2 last:border-r-0">
|
||||
<div className="space-y-2">
|
||||
{dayEvents.map((ev) => (
|
||||
<div key={ev.id} className="mb-2">
|
||||
<EventCard
|
||||
event={ev}
|
||||
onEventClick={onEventClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
getColorClasses={getColorClasses}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-8">
|
||||
{hours.map((hour) => (
|
||||
<React.Fragment key={`hour-${hour}`}>
|
||||
<div
|
||||
key={`time-${hour}`}
|
||||
className="border-b border-r p-1 text-[10px] text-muted-foreground sm:p-2 sm:text-xs"
|
||||
>
|
||||
{hour.toString().padStart(2, "0")}:00
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{weekDays.map((day) => {
|
||||
const dayEvents = getEventsForDayAndHour(day, hour)
|
||||
return (
|
||||
<div
|
||||
key={`${day.toISOString()}-${hour}`}
|
||||
className="min-h-12 border-b border-r p-0.5 transition-colors hover:bg-accent/50 last:border-r-0 sm:min-h-16 sm:p-1"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDrop(day, hour)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{dayEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onEventClick={onEventClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
getColorClasses={getColorClasses}
|
||||
variant="default"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Day View Component (simple hourly lanes)
|
||||
// Day View Component
|
||||
function DayView({
|
||||
currentDate,
|
||||
events,
|
||||
@ -1167,21 +1081,42 @@ function DayView({
|
||||
onEventClick: (event: Event) => void
|
||||
onDragStart: (event: Event) => void
|
||||
onDragEnd: () => void
|
||||
onDrop: (date: Date, hour?: number) => void
|
||||
onDrop: (date: Date, hour: number) => void
|
||||
getColorClasses: (color: string) => { bg: string; text: string }
|
||||
}) {
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
const getEventsForHour = (hour: number) =>
|
||||
events.filter((event) => {
|
||||
const d = new Date(event.startTime)
|
||||
// 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.getFullYear() === currentDate.getFullYear() &&
|
||||
d.getMonth() === currentDate.getMonth() &&
|
||||
d.getDate() === currentDate.getDate() &&
|
||||
d.getHours() === hour
|
||||
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) => {
|
||||
const eventDate = new Date(event.startTime)
|
||||
const eventHour = eventDate.getHours()
|
||||
return (
|
||||
eventDate.getDate() === currentDate.getDate() &&
|
||||
eventDate.getMonth() === currentDate.getMonth() &&
|
||||
eventDate.getFullYear() === currentDate.getFullYear() &&
|
||||
eventHour === hour
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-auto">
|
||||
@ -1189,14 +1124,27 @@ function DayView({
|
||||
{hours.map((hour) => {
|
||||
const hourEvents = getEventsForHour(hour)
|
||||
return (
|
||||
<div key={hour} className="flex border-b last:border-b-0" onDragOver={(e) => e.preventDefault()} onDrop={() => onDrop(currentDate, hour)}>
|
||||
<div
|
||||
key={hour}
|
||||
className="flex border-b last:border-b-0"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDrop(currentDate, hour)}
|
||||
>
|
||||
<div className="w-14 flex-shrink-0 border-r p-2 text-xs text-muted-foreground sm:w-20 sm:p-3 sm:text-sm">
|
||||
{hour.toString().padStart(2, "0")}:00
|
||||
</div>
|
||||
<div className="min-h-16 flex-1 p-1 transition-colors hover:bg-accent/50 sm:min-h-20 sm:p-2">
|
||||
<div className="space-y-2">
|
||||
{hourEvents.map((event) => (
|
||||
<EventCard key={event.id} event={event} onEventClick={onEventClick} onDragStart={onDragStart} onDragEnd={onDragEnd} getColorClasses={getColorClasses} variant="detailed" />
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onEventClick={onEventClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
getColorClasses={getColorClasses}
|
||||
variant="detailed"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user