riseup-squad18/src/components/DisponibilidadeMedico.old.tsx
guisilvagomes 3443e46ca3 feat: implementa chatbot AI, gerenciamento de disponibilidade médica, visualização de laudos e melhorias no painel da secretária
- Adiciona chatbot AI com interface responsiva e posicionamento otimizado
- Implementa gerenciamento completo de disponibilidade e exceções médicas
- Adiciona modal de visualização detalhada de laudos no painel do paciente
- Corrige relatórios da secretária para mostrar nomes de médicos
- Implementa mensagem de boas-vindas personalizada com nome real
- Remove mensagens duplicadas de login
- Remove arquivo cleanup-deps.ps1 desnecessário
- Atualiza README com todas as novas funcionalidades
2025-11-05 16:51:33 -03:00

738 lines
26 KiB
TypeScript

import React, { useState, useEffect } from "react";
import { Clock, Plus, Trash2, Save, Copy, Calendar as CalendarIcon, X } from "lucide-react";
import toast from "react-hot-toast";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { availabilityService, doctorService } from "../services/index";
import type {
DoctorException,
DoctorAvailability,
} from "../services/availability/types";
import { useAuth } from "../hooks/useAuth";
interface TimeSlot {
id: string;
dbId?: string; // ID do banco de dados (se já existir)
inicio: string;
fim: string;
ativo: boolean;
slotMinutes?: number;
appointmentType?: "presencial" | "telemedicina";
}
interface DaySchedule {
day: string;
dayOfWeek: number;
enabled: boolean;
slots: TimeSlot[];
}
const daysOfWeek = [
{ key: 0, label: "Domingo", dbKey: "domingo" },
{ key: 1, label: "Segunda-feira", dbKey: "segunda" },
{ key: 2, label: "Terça-feira", dbKey: "terca" },
{ key: 3, label: "Quarta-feira", dbKey: "quarta" },
{ key: 4, label: "Quinta-feira", dbKey: "quinta" },
{ key: 5, label: "Sexta-feira", dbKey: "sexta" },
{ key: 6, label: "Sábado", dbKey: "sabado" },
];
const DisponibilidadeMedico: React.FC = () => {
const { user } = useAuth();
const [doctorId, setDoctorId] = useState<string | null>(null);
const [schedule, setSchedule] = useState<Record<number, DaySchedule>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<"weekly" | "blocked">("weekly");
// States for adding/editing slots
const [showAddSlotDialog, setShowAddSlotDialog] = useState(false);
const [selectedDay, setSelectedDay] = useState<number | null>(null);
const [newSlot, setNewSlot] = useState({
inicio: "09:00",
fim: "10:00",
slotMinutes: 30,
appointmentType: "presencial" as "presencial" | "telemedicina"
});
// States for blocked dates
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
new Date()
);
const [blockedDates, setBlockedDates] = useState<Date[]>([]);
const [exceptions, setExceptions] = useState<DoctorException[]>([]);
// States for exceptions form
const [showExceptionDialog, setShowExceptionDialog] = useState(false);
const [exceptionForm, setExceptionForm] = useState({
date: format(new Date(), "yyyy-MM-dd"),
kind: "bloqueio" as "bloqueio" | "disponibilidade_extra",
start_time: "09:00",
end_time: "18:00",
wholeDayBlock: true,
reason: "",
});
// Load doctor ID from doctors table
useEffect(() => {
const loadDoctorId = async () => {
if (!user?.id) return;
try {
const doctors = await doctorService.list({ user_id: user.id });
if (doctors.length > 0) {
setDoctorId(doctors[0].id);
}
} catch (error) {
console.error("Erro ao buscar ID do médico:", error);
}
};
loadDoctorId();
}, [user?.id]);
const loadAvailability = React.useCallback(async () => {
if (!doctorId) return;
try {
setLoading(true);
const availabilities = await availabilityService.list({
doctor_id: doctorId,
});
if (availabilities && availabilities.length > 0) {
const newSchedule: Record<number, DaySchedule> = {};
// Inicializar todos os dias
daysOfWeek.forEach(({ key, label }) => {
newSchedule[key] = {
day: label,
dayOfWeek: key,
enabled: false,
slots: [],
};
});
// Agrupar disponibilidades por dia da semana
availabilities.forEach((avail: DoctorAvailability) => {
// avail.weekday agora é um número (0-6)
const dayKey = avail.weekday;
if (!newSchedule[dayKey]) return;
if (!newSchedule[dayKey].enabled) {
newSchedule[dayKey].enabled = true;
}
newSchedule[dayKey].slots.push({
id: `${dayKey}-${avail.id || Math.random().toString(36).slice(2)}`,
dbId: avail.id, // Armazenar ID do banco
inicio: avail.start_time?.slice(0, 5) || "09:00",
fim: avail.end_time?.slice(0, 5) || "17:00",
ativo: avail.active ?? true,
});
});
setSchedule(newSchedule);
} else {
// Initialize empty schedule
const newSchedule: Record<number, DaySchedule> = {};
daysOfWeek.forEach(({ key, label }) => {
newSchedule[key] = {
day: label,
dayOfWeek: key,
enabled: false,
slots: [],
};
});
setSchedule(newSchedule);
}
} catch (error) {
console.error("Erro ao carregar disponibilidade:", error);
toast.error("Erro ao carregar disponibilidade");
} finally {
setLoading(false);
}
}, [doctorId]);
const loadExceptions = React.useCallback(async () => {
if (!doctorId) return;
try {
const exceptions = await availabilityService.listExceptions({
doctor_id: doctorId,
});
setExceptions(exceptions);
const blocked = exceptions
.filter((exc: DoctorException) => exc.kind === "bloqueio" && exc.date)
.map((exc: DoctorException) => new Date(exc.date!));
setBlockedDates(blocked);
} catch (error) {
console.error("Erro ao carregar exceções:", error);
}
}, [doctorId]);
useEffect(() => {
if (doctorId) {
loadAvailability();
loadExceptions();
}
}, [doctorId, loadAvailability, loadExceptions]);
const toggleDay = (dayKey: number) => {
setSchedule((prev) => ({
...prev,
[dayKey]: {
...prev[dayKey],
enabled: !prev[dayKey].enabled,
},
}));
};
const addTimeSlot = () => {
if (selectedDay !== null) {
const newSlotId = `${selectedDay}-${Date.now()}`;
setSchedule((prev) => ({
...prev,
[selectedDay]: {
...prev[selectedDay],
slots: [
...prev[selectedDay].slots,
{
id: newSlotId,
inicio: newSlot.inicio,
fim: newSlot.fim,
ativo: true,
},
],
},
}));
setShowAddSlotDialog(false);
setNewSlot({ inicio: "09:00", fim: "10:00", slotMinutes: 30, appointmentType: "presencial" });
setSelectedDay(null);
}
};
const removeTimeSlot = async (dayKey: number, slotId: string) => {
const slot = schedule[dayKey]?.slots.find((s) => s.id === slotId);
// Se o slot tem um ID do banco, deletar imediatamente
if (slot?.dbId) {
try {
await availabilityService.delete(slot.dbId);
toast.success("Horário removido com sucesso");
} catch (error) {
console.error("Erro ao remover horário:", error);
toast.error("Erro ao remover horário");
return;
}
}
// Atualizar o estado local
setSchedule((prev) => ({
...prev,
[dayKey]: {
...prev[dayKey],
slots: prev[dayKey].slots.filter((slot) => slot.id !== slotId),
},
}));
};
const toggleSlotAvailability = (dayKey: number, slotId: string) => {
setSchedule((prev) => ({
...prev,
[dayKey]: {
...prev[dayKey],
slots: prev[dayKey].slots.map((slot) =>
slot.id === slotId ? { ...slot, ativo: !slot.ativo } : slot
),
},
}));
};
const copySchedule = (fromDay: number) => {
const sourceSchedule = schedule[fromDay];
if (!sourceSchedule.enabled || sourceSchedule.slots.length === 0) {
toast.error("Dia não tem horários configurados");
return;
}
const updatedSchedule = { ...schedule };
Object.keys(updatedSchedule).forEach((key) => {
const dayKey = Number(key);
if (dayKey !== fromDay && updatedSchedule[dayKey].enabled) {
updatedSchedule[dayKey].slots = sourceSchedule.slots.map((slot) => ({
...slot,
id: `${dayKey}-${slot.id}`,
}));
}
});
setSchedule(updatedSchedule);
toast.success("Horários copiados com sucesso!");
};
const handleSaveSchedule = async () => {
try {
setSaving(true);
if (!doctorId) {
toast.error("Médico não autenticado");
return;
}
const requests: Array<Promise<unknown>> = [];
const timeToMinutes = (t: string) => {
const [hStr, mStr] = t.split(":");
const h = Number(hStr || "0");
const m = Number(mStr || "0");
return h * 60 + m;
};
// Para cada dia, processar slots
daysOfWeek.forEach(({ key }) => {
const daySchedule = schedule[key];
if (!daySchedule || !daySchedule.enabled) {
// Se o dia foi desabilitado, deletar todos os slots existentes
daySchedule?.slots.forEach((slot) => {
if (slot.dbId) {
requests.push(availabilityService.delete(slot.dbId));
}
});
return;
}
// Processar cada slot do dia
daySchedule.slots.forEach((slot) => {
const inicio = slot.inicio
? slot.inicio.length === 5
? `${slot.inicio}:00`
: slot.inicio
: "00:00:00";
const fim = slot.fim
? slot.fim.length === 5
? `${slot.fim}:00`
: slot.fim
: "00:00:00";
const minutes = Math.max(
1,
timeToMinutes(fim.slice(0, 5)) - timeToMinutes(inicio.slice(0, 5))
);
const payload = {
weekday: key, // Agora usa número (0-6) ao invés de string
start_time: inicio.slice(0, 5), // HH:MM ao invés de HH:MM:SS
end_time: fim.slice(0, 5), // HH:MM ao invés de HH:MM:SS
slot_minutes: minutes,
appointment_type: "presencial" as const,
active: !!slot.ativo,
};
if (slot.dbId) {
// Atualizar slot existente
requests.push(availabilityService.update(slot.dbId, payload as any));
} else {
// Criar novo slot
requests.push(
availabilityService.create({
doctor_id: doctorId,
...payload,
} as any)
);
}
});
});
if (requests.length === 0) {
toast.error("Nenhuma alteração para salvar");
return;
}
const results = await Promise.allSettled(requests);
const errors: string[] = [];
let successCount = 0;
results.forEach((r, idx) => {
if (r.status === "fulfilled") {
const val = r.value as {
success?: boolean;
error?: string;
message?: string;
};
if (val && val.success) successCount++;
else
errors.push(`Item ${idx}: ${val?.error || val?.message || "Erro"}`);
} else {
errors.push(`Item ${idx}: ${r.reason?.message || String(r.reason)}`);
}
});
if (errors.length > 0) {
console.error("Erros ao salvar disponibilidades:", errors);
toast.error(
`Algumas disponibilidades não foram salvas (${errors.length})`
);
}
if (successCount > 0) {
toast.success(`${successCount} alteração(ões) salvas com sucesso!`);
await loadAvailability();
}
} catch (error) {
console.error("Erro ao salvar disponibilidade:", error);
const errorMessage =
error instanceof Error
? error.message
: "Erro ao salvar disponibilidade";
toast.error(errorMessage);
} finally {
setSaving(false);
}
};
const toggleBlockedDate = async () => {
if (!selectedDate) return;
const dateString = format(selectedDate, "yyyy-MM-dd");
const dateExists = blockedDates.some(
(d) => format(d, "yyyy-MM-dd") === dateString
);
try {
if (dateExists) {
// Remove block
const exception = exceptions.find(
(exc) =>
exc.date && format(new Date(exc.date), "yyyy-MM-dd") === dateString
);
if (exception && exception.id) {
await availabilityService.deleteException(exception.id);
setBlockedDates(
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
);
toast.success("Data desbloqueada");
}
} else {
// Add block
await availabilityService.createException({
doctor_id: doctorId!,
date: dateString,
kind: "bloqueio",
reason: "Data bloqueada pelo médico",
created_by: user?.id || doctorId!,
});
setBlockedDates([...blockedDates, selectedDate]);
toast.success("Data bloqueada");
}
loadExceptions();
} catch (error) {
console.error("Erro ao alternar bloqueio de data:", error);
toast.error("Erro ao bloquear/desbloquear data");
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Gerenciar Disponibilidade
</h2>
<p className="text-gray-600 dark:text-gray-400">
Configure seus horários de atendimento
</p>
</div>
<button
onClick={handleSaveSchedule}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? "Salvando..." : "Salvar Alterações"}
</button>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab("weekly")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === "weekly"
? "border-indigo-500 text-indigo-600 dark:text-indigo-400"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Horário Semanal
</button>
<button
onClick={() => setActiveTab("blocked")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === "blocked"
? "border-indigo-500 text-indigo-600 dark:text-indigo-400"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Exceções ({exceptions.length})
</button>
</nav>
</div>
{/* Tab Content - Weekly Schedule */}
{activeTab === "weekly" && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Horários por Dia da Semana
</h3>
<p className="text-gray-600 dark:text-gray-400">
Defina seus horários de atendimento para cada dia da semana
</p>
</div>
{daysOfWeek.map(({ key, label }) => (
<div key={key} className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={schedule[key]?.enabled || false}
onChange={() => toggleDay(key)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
</label>
<span className="text-gray-900 dark:text-white font-medium">
{label}
</span>
{schedule[key]?.enabled && (
<span className="px-2 py-1 bg-indigo-100 dark:bg-indigo-900 text-indigo-600 dark:text-indigo-400 text-xs rounded">
{schedule[key]?.slots.length || 0} horário(s)
</span>
)}
</div>
{schedule[key]?.enabled && (
<div className="flex gap-2">
<button
onClick={() => copySchedule(key)}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Copy className="h-4 w-4" />
Copiar
</button>
<button
onClick={() => {
setSelectedDay(key);
setShowAddSlotDialog(true);
}}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Adicionar Horário
</button>
</div>
)}
</div>
{schedule[key]?.enabled && (
<div className="ml-14 space-y-2">
{schedule[key]?.slots.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">
Nenhum horário configurado
</p>
) : (
schedule[key]?.slots.map((slot) => (
<div
key={slot.id}
className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50"
>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={slot.ativo}
onChange={() =>
toggleSlotAvailability(key, slot.id)
}
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
</label>
<Clock className="h-4 w-4 text-gray-400" />
<span className="text-gray-900 dark:text-white">
{slot.inicio} - {slot.fim}
</span>
{!slot.ativo && (
<span className="px-2 py-1 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 text-xs rounded">
Bloqueado
</span>
)}
</div>
<button
onClick={() => removeTimeSlot(key, slot.id)}
className="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
</button>
</div>
))
)}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Tab Content - Blocked Dates */}
{activeTab === "blocked" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Selecionar Datas
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Clique em uma data no calendário e depois no botão para
bloquear/desbloquear
</p>
<div className="space-y-4">
<input
type="date"
value={selectedDate ? format(selectedDate, "yyyy-MM-dd") : ""}
onChange={(e) => setSelectedDate(new Date(e.target.value))}
className="form-input"
/>
<button
onClick={toggleBlockedDate}
disabled={!selectedDate}
className="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
{selectedDate &&
blockedDates.some(
(d) =>
format(d, "yyyy-MM-dd") ===
format(selectedDate, "yyyy-MM-dd")
)
? "Desbloquear Data"
: "Bloquear Data"}
</button>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Datas Bloqueadas
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{blockedDates.length} data(s) bloqueada(s)
</p>
{blockedDates.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
Nenhuma data bloqueada
</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{blockedDates.map((date, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700"
>
<span className="text-gray-900 dark:text-white">
{format(date, "EEEE, dd 'de' MMMM 'de' yyyy", {
locale: ptBR,
})}
</span>
<button
onClick={() => {
setSelectedDate(date);
toggleBlockedDate();
}}
className="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Add Time Slot Dialog */}
{showAddSlotDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Adicionar Horário
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Defina o período de atendimento para{" "}
{selectedDay !== null ? schedule[selectedDay]?.day : ""}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Horário de Início
</label>
<input
type="time"
value={newSlot.inicio}
onChange={(e) =>
setNewSlot({ ...newSlot, inicio: e.target.value })
}
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Horário de Término
</label>
<input
type="time"
value={newSlot.fim}
onChange={(e) =>
setNewSlot({ ...newSlot, fim: e.target.value })
}
className="form-input"
/>
</div>
</div>
<div className="flex gap-2 mt-6">
<button
onClick={() => {
setShowAddSlotDialog(false);
setSelectedDay(null);
}}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancelar
</button>
<button
onClick={addTimeSlot}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
>
Adicionar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default DisponibilidadeMedico;