style(calendário) corrigir o modal quando clica em um paciente

This commit is contained in:
Jonas Francisco 2025-11-08 00:23:51 -03:00
parent 9adf479e90
commit eea59f5063
2 changed files with 456 additions and 287 deletions

View File

@ -60,6 +60,12 @@ export default function AgendamentoPage() {
const patientsById: Record<string, any> = {}; const patientsById: Record<string, any> = {};
(patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; }); (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; });
setAppointments(arr || []); setAppointments(arr || []);
// --- LÓGICA DE TRANSFORMAÇÃO PARA O NOVO EVENTMANAGER --- // --- LÓGICA DE TRANSFORMAÇÃO PARA O NOVO EVENTMANAGER ---
@ -80,6 +86,13 @@ export default function AgendamentoPage() {
else if (status === "canceled" || status === "cancelado" || status === "cancelled") color = "red"; else if (status === "canceled" || status === "cancelado" || status === "cancelled") color = "red";
else if (status === "requested" || status === "solicitado") color = "blue"; 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 { return {
id: obj.id || uuidv4(), id: obj.id || uuidv4(),
title, title,
@ -87,6 +100,15 @@ export default function AgendamentoPage() {
startTime: start, startTime: start,
endTime: end, endTime: end,
color, color,
// Campos adicionais para visualização detalhada
patientName: patient,
professionalName: professional,
appointmentType,
status: obj.status || null,
insuranceProvider: insurance,
completedAt,
cancelledAt,
cancellationReason,
}; };
}); });
setManagerEvents(newManagerEvents); setManagerEvents(newManagerEvents);
@ -130,6 +152,128 @@ 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 // Envia atualização para a API e atualiza UI
const handleEventUpdate = async (id: string, partial: Partial<Event>) => { const handleEventUpdate = async (id: string, partial: Partial<Event>) => {
try { try {
@ -157,59 +301,32 @@ export default function AgendamentoPage() {
return ( return (
<div className="bg-background"> <div className="bg-background">
<div className="w-full"> <div className="w-full">
<div className="w-full max-w-7xl mx-auto flex flex-col gap-6 sm:gap-10 p-4 sm:p-6"> <div className="w-full max-w-full mx-0 flex flex-col gap-0 p-0 pl-4 sm:pl-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2"> <div className="relative flex items-center justify-between gap-0 p-0 py-2 sm:py-0">
{/* Cabeçalho simplificado (sem 3D) */}
<div> <div>
<h1 className="text-xl sm:text-2xl font-bold text-foreground">Calendário</h1> <h1 className="text-lg font-semibold text-foreground m-0 p-0">Calendário</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground m-0 p-0 text-xs">Navegue através do atalho: Calendário (C).</p>
Navegue através do atalho: Calendário (C).
</p>
</div>
{/* REMOVIDO: botões de abas Calendário/3D */}
</div> </div>
{/* Legenda de status (aplica-se ao EventManager) */} {/* legenda dinâmica: mostra as cores presentes nos agendamentos do dia atual */}
<div className="rounded-md border bg-card/60 p-2 sm:p-3 -mt-2 sm:-mt-4 overflow-x-auto"> <div className="sm:absolute sm:top-2 sm:right-2 mt-2 sm:mt-0 z-40">
<div className="flex flex-nowrap items-center gap-4 sm:gap-6 text-xs sm:text-sm whitespace-nowrap"> <DynamicLegend events={managerEvents} />
<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> </div>
{/* Apenas o EventManager */} <div className="w-full m-0 p-0">
<div className="flex w-full">
<div className="w-full">
{managerLoading ? ( {managerLoading ? (
<div className="flex items-center justify-center w-full min-h-[60vh] sm:min-h-[70vh]"> <div className="flex items-center justify-center w-full min-h-[70vh] m-0 p-0">
<div className="text-sm text-muted-foreground">Conectando ao calendário carregando agendamentos...</div> <div className="text-xs text-muted-foreground">Conectando ao calendário carregando agendamentos...</div>
</div> </div>
) : ( ) : (
<div className="w-full min-h-[60vh] sm:min-h-[70vh]"> <div className="w-full min-h-[80vh] m-0 p-0">
<EventManager <EventManager events={managerEvents} className="compact-event-manager" onEventUpdate={handleEventUpdate} />
events={managerEvents}
className="compact-event-manager"
onEventUpdate={handleEventUpdate}
/>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* REMOVIDO: PatientRegistrationForm (era acionado pelo 3D) */}
</div>
</div> </div>
); );
} }

View File

@ -1,6 +1,7 @@
"use client" "use client"
import React, { useState, useCallback, useMemo, useEffect } from "react" import React, { useState, useCallback, useMemo, useEffect } from "react"
import { buscarAgendamentoPorId, buscarPacientesPorIds, buscarMedicosPorIds } from "@/lib/api"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -29,6 +30,15 @@ export interface Event {
category?: string category?: string
attendees?: string[] attendees?: string[]
tags?: 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 { export interface EventManagerProps {
@ -230,6 +240,73 @@ export function EventManager({
} catch {} } 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 ( return (
<div className={cn("flex flex-col gap-4", className)}> <div className={cn("flex flex-col gap-4", className)}>
{/* Header */} {/* Header */}
@ -504,7 +581,7 @@ 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="w-full max-w-full sm:max-w-2xl md:max-w-3xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader> <DialogHeader>
<DialogTitle>{isCreating ? "Criar Evento" : "Detalhes do Agendamento"}</DialogTitle> <DialogTitle>{isCreating ? "Criar Evento" : "Detalhes do Agendamento"}</DialogTitle>
<DialogDescription> <DialogDescription>
@ -512,17 +589,16 @@ export function EventManager({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Dialog content: form when creating; read-only view when viewing */}
{isCreating ? (
<>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<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={newEvent.title ?? ""}
onChange={(e) => onChange={(e) => setNewEvent((prev) => ({ ...prev, title: e.target.value }))}
isCreating
? setNewEvent((prev) => ({ ...prev, title: e.target.value }))
: setSelectedEvent((prev) => (prev ? { ...prev, title: e.target.value } : null))
}
placeholder="Título do evento" placeholder="Título do evento"
/> />
</div> </div>
@ -531,15 +607,8 @@ 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={newEvent.description ?? ""}
onChange={(e) => onChange={(e) => setNewEvent((prev) => ({ ...prev, description: e.target.value }))}
isCreating
? setNewEvent((prev) => ({
...prev,
description: e.target.value,
}))
: setSelectedEvent((prev) => (prev ? { ...prev, description: e.target.value } : null))
}
placeholder="Descrição do evento" placeholder="Descrição do evento"
rows={3} rows={3}
/> />
@ -552,26 +621,13 @@ export function EventManager({
id="startTime" id="startTime"
type="datetime-local" type="datetime-local"
value={ value={
isCreating newEvent.startTime
? newEvent.startTime
? new Date(newEvent.startTime.getTime() - newEvent.startTime.getTimezoneOffset() * 60000) ? new Date(newEvent.startTime.getTime() - newEvent.startTime.getTimezoneOffset() * 60000)
.toISOString() .toISOString()
.slice(0, 16) .slice(0, 16)
: "" : ""
: selectedEvent
? new Date(
selectedEvent.startTime.getTime() - selectedEvent.startTime.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16)
: ""
} }
onChange={(e) => { onChange={(e) => setNewEvent((prev) => ({ ...prev, startTime: new Date(e.target.value) }))}
const date = new Date(e.target.value)
isCreating
? setNewEvent((prev) => ({ ...prev, startTime: date }))
: setSelectedEvent((prev) => (prev ? { ...prev, startTime: date } : null))
}}
/> />
</div> </div>
@ -581,39 +637,19 @@ export function EventManager({
id="endTime" id="endTime"
type="datetime-local" type="datetime-local"
value={ value={
isCreating newEvent.endTime
? newEvent.endTime
? new Date(newEvent.endTime.getTime() - newEvent.endTime.getTimezoneOffset() * 60000) ? new Date(newEvent.endTime.getTime() - newEvent.endTime.getTimezoneOffset() * 60000)
.toISOString() .toISOString()
.slice(0, 16) .slice(0, 16)
: "" : ""
: selectedEvent
? new Date(selectedEvent.endTime.getTime() - selectedEvent.endTime.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16)
: ""
} }
onChange={(e) => { onChange={(e) => setNewEvent((prev) => ({ ...prev, endTime: new Date(e.target.value) }))}
const date = new Date(e.target.value)
isCreating
? setNewEvent((prev) => ({ ...prev, endTime: date }))
: setSelectedEvent((prev) => (prev ? { ...prev, endTime: date } : null))
}}
/> />
</div> </div>
</div> </div>
{/* Campos de Categoria/Cor removidos */}
{/* Campo de Tags removido */}
</div> </div>
<DialogFooter> <DialogFooter>
{!isCreating && (
<Button variant="destructive" onClick={() => selectedEvent && handleDeleteEvent(selectedEvent.id)}>
Deletar
</Button>
)}
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
@ -624,10 +660,108 @@ export function EventManager({
> >
Cancelar Cancelar
</Button> </Button>
<Button onClick={isCreating ? handleCreateEvent : handleUpdateEvent}> <Button onClick={handleCreateEvent}>Criar</Button>
{isCreating ? "Criar" : "Salvar"} </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> </Button>
</DialogFooter> </DialogFooter>
</div>
</>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
@ -943,7 +1077,7 @@ function MonthView({
) )
} }
// Week View Component // Week View Component (simplified and stable)
function WeekView({ function WeekView({
currentDate, currentDate,
events, events,
@ -958,7 +1092,7 @@ function WeekView({
onEventClick: (event: Event) => void onEventClick: (event: Event) => void
onDragStart: (event: Event) => void onDragStart: (event: Event) => void
onDragEnd: () => void onDragEnd: () => void
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 startOfWeek = new Date(currentDate) const startOfWeek = new Date(currentDate)
@ -970,103 +1104,55 @@ function WeekView({
return day return day
}) })
// NOVO: limita intervalo de horas ao 1º e último evento da semana const getEventsForDay = (date: Date) =>
const [startHour, endHour] = React.useMemo(() => { events.filter((event) => {
let minH = Infinity const d = new Date(event.startTime)
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 ( return (
eventDate.getDate() === date.getDate() && d.getFullYear() === date.getFullYear() &&
eventDate.getMonth() === date.getMonth() && d.getMonth() === date.getMonth() &&
eventDate.getFullYear() === date.getFullYear() && d.getDate() === date.getDate()
eventHour === hour
) )
}) })
}
return ( return (
<Card className="overflow-auto"> <Card className="overflow-auto">
<div className="grid grid-cols-8 border-b"> <div className="grid grid-cols-7 border-b">
<div className="border-r p-2 text-center text-xs font-medium sm:text-sm">Hora</div>
{weekDays.map((day) => ( {weekDays.map((day) => (
<div <div key={day.toISOString()} className="border-r p-2 text-center text-xs font-medium last:border-r-0 sm:text-sm">
key={day.toISOString()} <span className="hidden sm:inline">{day.toLocaleDateString(LOCALE, { weekday: "short", timeZone: TIMEZONE })}</span>
className="border-r p-2 text-center text-xs font-medium last:border-r-0 sm:text-sm" <span className="sm:hidden">{day.toLocaleDateString(LOCALE, { weekday: "narrow", timeZone: TIMEZONE })}</span>
>
<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> </div>
<div className="grid grid-cols-8">
{hours.map((hour) => ( <div className="grid grid-cols-7">
<React.Fragment key={`hour-${hour}`}> {weekDays.map((day, idx) => {
<div const dayEvents = getEventsForDay(day)
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 ( return (
<div <div key={idx} className="min-h-40 border-r p-2 last:border-r-0">
key={`${day.toISOString()}-${hour}`} <div className="space-y-2">
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" {dayEvents.map((ev) => (
onDragOver={(e) => e.preventDefault()} <div key={ev.id} className="mb-2">
onDrop={() => onDrop(day, hour)}
>
<div className="space-y-1">
{dayEvents.map((event) => (
<EventCard <EventCard
key={event.id} event={ev}
event={event}
onEventClick={onEventClick} onEventClick={onEventClick}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
getColorClasses={getColorClasses} getColorClasses={getColorClasses}
variant="default" variant="compact"
/> />
</div>
))} ))}
</div> </div>
</div> </div>
) )
})} })}
</React.Fragment>
))}
</div> </div>
</Card> </Card>
) )
} }
// Day View Component // Day View Component (simple hourly lanes)
function DayView({ function DayView({
currentDate, currentDate,
events, events,
@ -1081,42 +1167,21 @@ function DayView({
onEventClick: (event: Event) => void onEventClick: (event: Event) => void
onDragStart: (event: Event) => void onDragStart: (event: Event) => void
onDragEnd: () => void onDragEnd: () => void
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 }
}) { }) {
// NOVO: calcula intervalo de horas do 1º ao último evento do dia const hours = Array.from({ length: 24 }, (_, i) => i)
const [startHour, endHour] = React.useMemo(() => {
const sameDayEvents = events.filter((ev) => { const getEventsForHour = (hour: number) =>
const d = ev.startTime events.filter((event) => {
const d = new Date(event.startTime)
return ( return (
d.getDate() === currentDate.getDate() && d.getFullYear() === currentDate.getFullYear() &&
d.getMonth() === currentDate.getMonth() && d.getMonth() === currentDate.getMonth() &&
d.getFullYear() === currentDate.getFullYear() d.getDate() === currentDate.getDate() &&
d.getHours() === hour
) )
}) })
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 ( return (
<Card className="overflow-auto"> <Card className="overflow-auto">
@ -1124,27 +1189,14 @@ function DayView({
{hours.map((hour) => { {hours.map((hour) => {
const hourEvents = getEventsForHour(hour) const hourEvents = getEventsForHour(hour)
return ( return (
<div <div key={hour} className="flex border-b last:border-b-0" onDragOver={(e) => e.preventDefault()} onDrop={() => onDrop(currentDate, hour)}>
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"> <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 {hour.toString().padStart(2, "0")}:00
</div> </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="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"> <div className="space-y-2">
{hourEvents.map((event) => ( {hourEvents.map((event) => (
<EventCard <EventCard key={event.id} event={event} onEventClick={onEventClick} onDragStart={onDragStart} onDragEnd={onDragEnd} getColorClasses={getColorClasses} variant="detailed" />
key={event.id}
event={event}
onEventClick={onEventClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
getColorClasses={getColorClasses}
variant="detailed"
/>
))} ))}
</div> </div>
</div> </div>