feat: Fase 3 - Sistema de vídeo chamadas

- Integração Daily.co para videochamadas
- API para criar/gerenciar salas de vídeo
- API de agendamentos (CRUD)
- Página de sessão com controles de vídeo/áudio
- Página de agenda com calendário visual
- Lista de próximos agendamentos
This commit is contained in:
2026-02-07 00:25:08 -03:00
parent b8d47b8a97
commit 0b5b2c7ae6
7 changed files with 949 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// Listar agendamentos
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: { children: true },
});
if (!user) {
return NextResponse.json({ error: 'Usuário não encontrado' }, { status: 404 });
}
let appointments;
if (user.role === 'therapist') {
// Terapeuta vê seus agendamentos
appointments = await prisma.appointment.findMany({
where: { therapistId: user.id },
include: {
child: { include: { parent: true } },
therapist: true,
},
orderBy: { scheduledAt: 'asc' },
});
} else {
// Pai vê agendamentos dos filhos
const childIds = user.children.map(c => c.id);
appointments = await prisma.appointment.findMany({
where: { childId: { in: childIds } },
include: {
child: true,
therapist: true,
},
orderBy: { scheduledAt: 'asc' },
});
}
return NextResponse.json(appointments);
} catch (error) {
console.error('Erro ao listar agendamentos:', error);
return NextResponse.json(
{ error: 'Erro ao buscar agendamentos' },
{ status: 500 }
);
}
}
// Criar agendamento
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 });
}
// Verificar se é terapeuta
const user = await prisma.user.findUnique({
where: { id: session.user.id },
});
if (!user || (user.role !== 'therapist' && user.role !== 'admin')) {
return NextResponse.json({ error: 'Sem permissão' }, { status: 403 });
}
const body = await request.json();
const { childId, scheduledAt, duration = 50, notes } = body;
if (!childId || !scheduledAt) {
return NextResponse.json(
{ error: 'childId e scheduledAt são obrigatórios' },
{ status: 400 }
);
}
const appointment = await prisma.appointment.create({
data: {
childId,
therapistId: user.id,
scheduledAt: new Date(scheduledAt),
duration,
notes,
status: 'scheduled',
},
include: {
child: true,
therapist: true,
},
});
return NextResponse.json(appointment);
} catch (error) {
console.error('Erro ao criar agendamento:', error);
return NextResponse.json(
{ error: 'Erro ao criar agendamento' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { createRoom, createMeetingToken, getRoom } from '@/lib/daily';
// Criar sala para um agendamento
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 });
}
const body = await request.json();
const { appointmentId } = body;
if (!appointmentId) {
return NextResponse.json({ error: 'appointmentId é obrigatório' }, { status: 400 });
}
// Buscar agendamento
const appointment = await prisma.appointment.findUnique({
where: { id: appointmentId },
include: {
child: { include: { parent: true } },
therapist: true,
},
});
if (!appointment) {
return NextResponse.json({ error: 'Agendamento não encontrado' }, { status: 404 });
}
// Verificar se o usuário é o pai ou terapeuta
const isParent = appointment.child.parentId === session.user.id;
const isTherapist = appointment.therapistId === session.user.id;
if (!isParent && !isTherapist) {
return NextResponse.json({ error: 'Sem permissão' }, { status: 403 });
}
// Se já tem sala, retornar
if (appointment.roomUrl && appointment.roomName) {
const existingRoom = await getRoom(appointment.roomName);
if (existingRoom) {
const token = await createMeetingToken(appointment.roomName, {
userName: session.user.name || 'Participante',
isOwner: isTherapist,
});
return NextResponse.json({
roomUrl: `${appointment.roomUrl}?t=${token.token}`,
roomName: appointment.roomName,
});
}
}
// Criar nova sala
const roomName = `iris-${appointmentId}`;
const room = await createRoom({
name: roomName,
expiryMinutes: 90, // Sessão de 50min + buffer
maxParticipants: 4,
});
// Atualizar agendamento
await prisma.appointment.update({
where: { id: appointmentId },
data: {
roomUrl: room.url,
roomName: room.name,
},
});
// Criar token
const token = await createMeetingToken(room.name, {
userName: session.user.name || 'Participante',
isOwner: isTherapist,
});
return NextResponse.json({
roomUrl: `${room.url}?t=${token.token}`,
roomName: room.name,
});
} catch (error) {
console.error('Erro ao criar sala:', error);
return NextResponse.json(
{ error: 'Erro ao criar sala de vídeo' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,297 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import {
Sparkles, ArrowLeft, Calendar, Clock, Video, User,
ChevronLeft, ChevronRight, Plus, Loader2
} from 'lucide-react';
interface Appointment {
id: string;
scheduledAt: string;
duration: number;
status: string;
roomUrl: string | null;
child: {
id: string;
name: string;
};
therapist: {
id: string;
name: string;
};
}
export default function AgendaPage() {
const { data: session } = useSession();
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
useEffect(() => {
fetchAppointments();
}, []);
const fetchAppointments = async () => {
try {
const response = await fetch('/api/appointments');
if (response.ok) {
const data = await response.json();
setAppointments(data);
}
} catch (error) {
console.error('Erro ao buscar agendamentos:', error);
} finally {
setLoading(false);
}
};
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDay = firstDay.getDay();
const days = [];
// Dias do mês anterior
for (let i = 0; i < startingDay; i++) {
const prevDate = new Date(year, month, -startingDay + i + 1);
days.push({ date: prevDate, isCurrentMonth: false });
}
// Dias do mês atual
for (let i = 1; i <= daysInMonth; i++) {
days.push({ date: new Date(year, month, i), isCurrentMonth: true });
}
// Dias do próximo mês
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
}
return days;
};
const getAppointmentsForDate = (date: Date) => {
return appointments.filter(apt => {
const aptDate = new Date(apt.scheduledAt);
return aptDate.toDateString() === date.toDateString();
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
});
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
const isFuture = (dateStr: string) => {
return new Date(dateStr) > new Date();
};
const prevMonth = () => {
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1));
};
const nextMonth = () => {
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1));
};
const monthYear = currentDate.toLocaleDateString('pt-BR', {
month: 'long',
year: 'numeric',
});
const days = getDaysInMonth(currentDate);
const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'];
// Próximos agendamentos
const upcomingAppointments = appointments
.filter(apt => isFuture(apt.scheduledAt))
.slice(0, 5);
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 px-4 lg:px-8 py-4">
<div className="max-w-7xl mx-auto flex items-center gap-4">
<Link href="/dashboard" className="text-gray-400 hover:text-gray-600">
<ArrowLeft className="w-6 h-6" />
</Link>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl gradient-rainbow flex items-center justify-center">
<Calendar className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">Agenda</h1>
<p className="text-sm text-gray-500">Seus agendamentos</p>
</div>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto p-4 lg:p-8">
<div className="grid lg:grid-cols-3 gap-8">
{/* Calendar */}
<div className="lg:col-span-2">
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-gray-900 capitalize">
{monthYear}
</h2>
<div className="flex gap-2">
<button
onClick={prevMonth}
className="p-2 hover:bg-gray-100 rounded-lg transition"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={nextMonth}
className="p-2 hover:bg-gray-100 rounded-lg transition"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{/* Week days header */}
<div className="grid grid-cols-7 mb-2">
{weekDays.map(day => (
<div key={day} className="text-center text-sm font-medium text-gray-500 py-2">
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{days.map((day, i) => {
const dayAppointments = getAppointmentsForDate(day.date);
const hasAppointments = dayAppointments.length > 0;
return (
<div
key={i}
className={`aspect-square p-1 ${
day.isCurrentMonth ? 'bg-white' : 'bg-gray-50'
}`}
>
<div
className={`h-full rounded-lg p-1 ${
isToday(day.date)
? 'bg-indigo-100 text-indigo-600'
: day.isCurrentMonth
? 'hover:bg-gray-50'
: 'text-gray-400'
}`}
>
<span className={`text-sm font-medium ${
isToday(day.date) ? 'bg-indigo-600 text-white rounded-full w-6 h-6 flex items-center justify-center' : ''
}`}>
{day.date.getDate()}
</span>
{hasAppointments && (
<div className="mt-1 space-y-0.5">
{dayAppointments.slice(0, 2).map(apt => (
<div
key={apt.id}
className="text-xs bg-indigo-500 text-white rounded px-1 py-0.5 truncate"
>
{formatTime(apt.scheduledAt)}
</div>
))}
{dayAppointments.length > 2 && (
<div className="text-xs text-gray-500">
+{dayAppointments.length - 2}
</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Upcoming appointments */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Próximas Sessões
</h2>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 text-indigo-500 animate-spin" />
</div>
) : upcomingAppointments.length === 0 ? (
<div className="text-center py-8">
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">Nenhuma sessão agendada</p>
</div>
) : (
<div className="space-y-4">
{upcomingAppointments.map(apt => (
<div key={apt.id} className="p-4 bg-gray-50 rounded-xl">
<div className="flex items-start justify-between mb-2">
<div>
<p className="font-medium text-gray-900">
{apt.child.name}
</p>
<p className="text-sm text-gray-500">
com {apt.therapist.name}
</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
apt.status === 'scheduled'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}>
{apt.status === 'scheduled' ? 'Agendado' : apt.status}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500 mb-3">
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{new Date(apt.scheduledAt).toLocaleDateString('pt-BR')}
</span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatTime(apt.scheduledAt)}
</span>
</div>
<Link
href={`/dashboard/sessao/${apt.id}`}
className="flex items-center justify-center gap-2 w-full bg-indigo-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 transition"
>
<Video className="w-4 h-4" />
Entrar na Sessão
</Link>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import {
Sparkles, ArrowLeft, Video, VideoOff, Mic, MicOff,
Phone, Settings, MessageCircle, Users, Loader2
} from 'lucide-react';
export default function SessaoPage() {
const params = useParams();
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [roomUrl, setRoomUrl] = useState<string | null>(null);
const [callFrame, setCallFrame] = useState<any>(null);
const [videoEnabled, setVideoEnabled] = useState(true);
const [audioEnabled, setAudioEnabled] = useState(true);
const appointmentId = params.id as string;
const initializeCall = useCallback(async () => {
try {
setLoading(true);
// Buscar/criar sala
const response = await fetch('/api/daily/room', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appointmentId }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Erro ao conectar');
}
const { roomUrl } = await response.json();
setRoomUrl(roomUrl);
// Carregar Daily.co iframe
const DailyIframe = (await import('@daily-co/daily-js')).default;
const frame = DailyIframe.createFrame(
document.getElementById('call-container')!,
{
iframeStyle: {
width: '100%',
height: '100%',
border: '0',
borderRadius: '12px',
},
showLeaveButton: true,
showFullscreenButton: true,
}
);
await frame.join({ url: roomUrl });
setCallFrame(frame);
frame.on('left-meeting', () => {
router.push('/dashboard');
});
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [appointmentId, router]);
useEffect(() => {
initializeCall();
return () => {
if (callFrame) {
callFrame.destroy();
}
};
}, [initializeCall]);
const toggleVideo = () => {
if (callFrame) {
callFrame.setLocalVideo(!videoEnabled);
setVideoEnabled(!videoEnabled);
}
};
const toggleAudio = () => {
if (callFrame) {
callFrame.setLocalAudio(!audioEnabled);
setAudioEnabled(!audioEnabled);
}
};
const endCall = () => {
if (callFrame) {
callFrame.leave();
}
router.push('/dashboard');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 text-indigo-500 animate-spin mx-auto mb-4" />
<p className="text-white text-lg">Conectando à sessão...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-8">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Video className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-xl font-semibold text-white mb-2">Erro ao conectar</h1>
<p className="text-gray-400 mb-6">{error}</p>
<Link
href="/dashboard"
className="inline-flex items-center gap-2 text-indigo-400 hover:text-indigo-300"
>
<ArrowLeft className="w-4 h-4" />
Voltar ao Dashboard
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-900 flex flex-col">
{/* Header */}
<header className="bg-gray-800 border-b border-gray-700 px-4 py-3">
<div className="flex items-center justify-between max-w-7xl mx-auto">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg gradient-rainbow flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
<span className="text-white font-semibold">Sessão de Terapia</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Conectado
</div>
</div>
</header>
{/* Video Container */}
<div className="flex-1 p-4">
<div id="call-container" className="w-full h-full min-h-[70vh] bg-gray-800 rounded-xl" />
</div>
{/* Controls */}
<div className="bg-gray-800 border-t border-gray-700 px-4 py-4">
<div className="flex items-center justify-center gap-4 max-w-md mx-auto">
<button
onClick={toggleAudio}
className={`w-14 h-14 rounded-full flex items-center justify-center transition ${
audioEnabled
? 'bg-gray-700 text-white hover:bg-gray-600'
: 'bg-red-500 text-white hover:bg-red-600'
}`}
>
{audioEnabled ? <Mic className="w-6 h-6" /> : <MicOff className="w-6 h-6" />}
</button>
<button
onClick={toggleVideo}
className={`w-14 h-14 rounded-full flex items-center justify-center transition ${
videoEnabled
? 'bg-gray-700 text-white hover:bg-gray-600'
: 'bg-red-500 text-white hover:bg-red-600'
}`}
>
{videoEnabled ? <Video className="w-6 h-6" /> : <VideoOff className="w-6 h-6" />}
</button>
<button
onClick={endCall}
className="w-14 h-14 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition"
>
<Phone className="w-6 h-6 rotate-135" />
</button>
</div>
</div>
</div>
);
}