forked from RiseUP/riseup-squad20
Merge branch 'feature/ajustsFinais' into fix/user
This commit is contained in:
commit
d79a5acb07
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user