312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { ChevronLeft, ChevronRight, Plus, Clock, User, Calendar as CalendarIcon } from 'lucide-react';
|
|
|
|
interface Appointment {
|
|
id: string;
|
|
patient: string;
|
|
time: string;
|
|
duration: number;
|
|
type: 'consulta' | 'exame' | 'retorno';
|
|
status: 'confirmed' | 'pending' | 'absent';
|
|
professional: string;
|
|
notes: string;
|
|
}
|
|
|
|
interface Professional {
|
|
id: string;
|
|
name: string;
|
|
specialty: string;
|
|
}
|
|
|
|
interface AgendaCalendarProps {
|
|
professionals: Professional[];
|
|
appointments: Appointment[];
|
|
onAddAppointment: () => void;
|
|
onEditAppointment: (appointment: Appointment) => void;
|
|
}
|
|
|
|
export default function AgendaCalendar({
|
|
professionals,
|
|
appointments,
|
|
onAddAppointment,
|
|
onEditAppointment
|
|
}: AgendaCalendarProps) {
|
|
const [view, setView] = useState<'day' | 'week' | 'month'>('week');
|
|
const [selectedProfessional, setSelectedProfessional] = useState('all');
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
|
|
const timeSlots = Array.from({ length: 11 }, (_, i) => {
|
|
const hour = i + 8; // Das 8h às 18h
|
|
return [`${hour.toString().padStart(2, '0')}:00`, `${hour.toString().padStart(2, '0')}:30`];
|
|
}).flat();
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'confirmed': return 'bg-green-100 border-green-500 text-green-800';
|
|
case 'pending': return 'bg-yellow-100 border-yellow-500 text-yellow-800';
|
|
case 'absent': return 'bg-red-100 border-red-500 text-red-800';
|
|
default: return 'bg-gray-100 border-gray-500 text-gray-800';
|
|
}
|
|
};
|
|
|
|
const getTypeIcon = (type: string) => {
|
|
switch (type) {
|
|
case 'consulta': return '🩺';
|
|
case 'exame': return '📋';
|
|
case 'retorno': return '↩️';
|
|
default: return '📅';
|
|
}
|
|
};
|
|
|
|
const formatDate = (date: Date) => {
|
|
return date.toLocaleDateString('pt-BR', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
timeZone: 'America/Sao_Paulo'
|
|
});
|
|
};
|
|
|
|
const navigateDate = (direction: 'prev' | 'next') => {
|
|
const newDate = new Date(currentDate);
|
|
if (view === 'day') {
|
|
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
|
} else if (view === 'week') {
|
|
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
|
|
} else {
|
|
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
|
|
}
|
|
setCurrentDate(newDate);
|
|
};
|
|
|
|
const goToToday = () => {
|
|
setCurrentDate(new Date());
|
|
};
|
|
|
|
|
|
const filteredAppointments = selectedProfessional === 'all'
|
|
? appointments
|
|
: appointments.filter(app => app.professional === selectedProfessional);
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow">
|
|
<div className="p-4 border-b border-gray-200">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
|
<h2 className="text-xl font-semibold text-gray-900 mb-4 sm:mb-0">Agenda</h2>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<select
|
|
value={selectedProfessional}
|
|
onChange={(e) => setSelectedProfessional(e.target.value)}
|
|
className="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="all">Todos os profissionais</option>
|
|
{professionals.map(prof => (
|
|
<option key={prof.id} value={prof.id}>{prof.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
<div className="inline-flex rounded-md shadow-sm">
|
|
<button
|
|
type="button"
|
|
onClick={() => setView('day')}
|
|
className={`px-3 py-2 text-sm font-medium rounded-l-md ${
|
|
view === 'day'
|
|
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
|
: 'bg-white text-gray-700 border border-gray-300'
|
|
}`}
|
|
>
|
|
Dia
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setView('week')}
|
|
className={`px-3 py-2 text-sm font-medium -ml-px ${
|
|
view === 'week'
|
|
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
|
: 'bg-white text-gray-700 border border-gray-300'
|
|
}`}
|
|
>
|
|
Semana
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setView('month')}
|
|
className={`px-3 py-2 text-sm font-medium -ml-px rounded-r-md ${
|
|
view === 'month'
|
|
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
|
: 'bg-white text-gray-700 border border-gray-300'
|
|
}`}
|
|
>
|
|
Mês
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={onAddAppointment}
|
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Novo Agendamento
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 border-b border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-4">
|
|
<button
|
|
onClick={() => navigateDate('prev')}
|
|
className="p-1 rounded-md hover:bg-gray-100"
|
|
>
|
|
<ChevronLeft className="h-5 w-5 text-gray-600" />
|
|
</button>
|
|
<h3 className="text-lg font-medium text-gray-900">
|
|
{formatDate(currentDate)}
|
|
</h3>
|
|
<button
|
|
onClick={() => navigateDate('next')}
|
|
className="p-1 rounded-md hover:bg-gray-100"
|
|
>
|
|
<ChevronRight className="h-5 w-5 text-gray-600" />
|
|
</button>
|
|
<button
|
|
onClick={goToToday}
|
|
className="ml-4 px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100"
|
|
>
|
|
Hoje
|
|
</button>
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
Atalhos: 'C' para calendário, 'F' para fila de espera
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{}
|
|
{view !== 'month' && (
|
|
<div className="overflow-auto">
|
|
<div className="min-w-full">
|
|
<div className="flex">
|
|
<div className="w-20 flex-shrink-0 border-r border-gray-200">
|
|
<div className="h-12 border-b border-gray-200 flex items-center justify-center text-sm font-medium text-gray-500">
|
|
Hora
|
|
</div>
|
|
{timeSlots.map(time => (
|
|
<div key={time} className="h-16 border-b border-gray-200 flex items-center justify-center text-sm text-gray-500">
|
|
{time}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="h-12 border-b border-gray-200 flex items-center justify-center text-sm font-medium text-gray-500">
|
|
{currentDate.toLocaleDateString('pt-BR', { weekday: 'long', timeZone: 'America/Sao_Paulo' })}
|
|
</div>
|
|
<div className="relative">
|
|
{timeSlots.map(time => (
|
|
<div key={time} className="h-16 border-b border-gray-200"></div>
|
|
))}
|
|
|
|
{filteredAppointments.map(app => {
|
|
// parse appointment time in Brazil timezone
|
|
const d = new Date(app.time);
|
|
// extract hour/minute in America/Sao_Paulo using Intl.DateTimeFormat
|
|
const parts = new Intl.DateTimeFormat('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'America/Sao_Paulo' }).formatToParts(d);
|
|
const hourPart = parts.find(p => p.type === 'hour')?.value ?? '00';
|
|
const minutePart = parts.find(p => p.type === 'minute')?.value ?? '00';
|
|
const hour = parseInt(hourPart, 10);
|
|
const minute = parseInt(minutePart, 10);
|
|
|
|
return (
|
|
<div
|
|
key={app.id}
|
|
className={`absolute left-1 right-1 border-l-4 rounded p-2 shadow-sm cursor-pointer ${getStatusColor(app.status)}`}
|
|
style={{
|
|
top: `${((hour - 8) * 64 + (minute / 60) * 64) + 48}px`,
|
|
height: `${(app.duration / 60) * 64}px`,
|
|
}}
|
|
onClick={() => onEditAppointment(app)}
|
|
>
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<div className="font-medium flex items-center">
|
|
<User className="h-3 w-3 mr-1" />
|
|
{app.patient}
|
|
</div>
|
|
<div className="text-xs flex items-center mt-1">
|
|
<Clock className="h-3 w-3 mr-1" />
|
|
{String(hour).padStart(2,'0')}:{String(minute).padStart(2,'0')} - {app.type} {getTypeIcon(app.type)}
|
|
</div>
|
|
<div className="text-xs mt-1">
|
|
{professionals.find(p => p.id === app.professional)?.name}
|
|
</div>
|
|
</div>
|
|
<div className="text-xs capitalize">
|
|
{app.status === 'confirmed' ? 'confirmado' : app.status === 'pending' ? 'pendente' : 'ausente'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{}
|
|
{view === 'month' && (
|
|
<div className="p-4">
|
|
<div className="space-y-4">
|
|
{filteredAppointments.map(app => {
|
|
const d = new Date(app.time);
|
|
const parts = new Intl.DateTimeFormat('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'America/Sao_Paulo' }).formatToParts(d);
|
|
const hourPart = parts.find(p => p.type === 'hour')?.value ?? '00';
|
|
const minutePart = parts.find(p => p.type === 'minute')?.value ?? '00';
|
|
const hours = String(hourPart).padStart(2,'0');
|
|
const minutes = String(minutePart).padStart(2,'0');
|
|
|
|
return (
|
|
<div key={app.id} className={`border-l-4 p-4 rounded-lg shadow-sm ${getStatusColor(app.status)}`}>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
|
<div className="flex items-center">
|
|
<User className="h-4 w-4 mr-2" />
|
|
<span className="font-medium">{app.patient}</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<Clock className="h-4 w-4 mr-2" />
|
|
<span>{hours}:{minutes} - {app.type} {getTypeIcon(app.type)}</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<span className="text-sm">{professionals.find(p => p.id === app.professional)?.name}</span>
|
|
</div>
|
|
</div>
|
|
{app.notes && (
|
|
<div className="mt-2 text-sm text-gray-600">
|
|
{app.notes}
|
|
</div>
|
|
)}
|
|
<div className="mt-2 flex justify-end">
|
|
<button
|
|
onClick={() => onEditAppointment(app)}
|
|
className="text-blue-600 hover:text-blue-800 text-sm"
|
|
>
|
|
Editar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |