3725 lines
169 KiB
TypeScript
3725 lines
169 KiB
TypeScript
"use client";
|
|
import React, { useState, useRef, useEffect } from "react";
|
|
import Image from "next/image";
|
|
import SignatureCanvas from "react-signature-canvas";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import ProtectedRoute from "@/components/shared/ProtectedRoute";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { useAvatarUrl } from "@/hooks/useAvatarUrl";
|
|
import { UploadAvatar } from '@/components/ui/upload-avatar';
|
|
import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico, listarDisponibilidades, DoctorAvailability, deletarDisponibilidade, listarExcecoes, DoctorException, deletarExcecao } from "@/lib/api";
|
|
import { ENV_CONFIG } from '@/lib/env-config';
|
|
import { useReports } from "@/hooks/useReports";
|
|
import { CreateReportData } from "@/types/report-types";
|
|
import { createAndNotifyReport } from "@/lib/reportService";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
|
|
import AvailabilityForm from '@/components/features/forms/availability-form';
|
|
import ExceptionForm from '@/components/features/forms/exception-form';
|
|
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
|
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
|
import { User, FolderOpen, X, Users, MessageSquare, ClipboardList, Plus, Edit, ChevronLeft, ChevronRight, Clock, FileCheck, Upload, Download, Eye, History, Stethoscope, Pill, Activity, Search } from "lucide-react"
|
|
import { Calendar as CalendarIcon, FileText, Settings } from "lucide-react";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
|
|
|
|
import dynamic from "next/dynamic";
|
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
|
import interactionPlugin from "@fullcalendar/interaction";
|
|
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
|
|
import { PatientRegistrationForm } from "@/components/features/forms/patient-registration-form";
|
|
|
|
const FullCalendar = dynamic(() => import("@fullcalendar/react"), {
|
|
ssr: false,
|
|
});
|
|
|
|
// pacientes will be loaded inside the component (hooks must run in component body)
|
|
|
|
// removed static medico placeholder; will load real profile for logged-in user
|
|
|
|
|
|
const colorsByType = {
|
|
Rotina: "#4dabf7",
|
|
Cardiologia: "#f76c6c",
|
|
Otorrino: "#f7b84d",
|
|
Pediatria: "#6cf78b",
|
|
Dermatologia: "#9b59b6",
|
|
Oftalmologia: "#2ecc71"
|
|
};
|
|
|
|
// Função para traduzir dias da semana
|
|
function translateWeekday(w?: string) {
|
|
if (!w) return '';
|
|
const key = w.toString().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '');
|
|
const map: Record<string, string> = {
|
|
'segunda': 'Segunda',
|
|
'terca': 'Terça',
|
|
'quarta': 'Quarta',
|
|
'quinta': 'Quinta',
|
|
'sexta': 'Sexta',
|
|
'sabado': 'Sábado',
|
|
'domingo': 'Domingo',
|
|
'monday': 'Segunda',
|
|
'tuesday': 'Terça',
|
|
'wednesday': 'Quarta',
|
|
'thursday': 'Quinta',
|
|
'friday': 'Sexta',
|
|
'saturday': 'Sábado',
|
|
'sunday': 'Domingo',
|
|
};
|
|
return map[key] ?? w;
|
|
}
|
|
|
|
// Helpers para normalizar dados de paciente (suporta schema antigo e novo)
|
|
const getPatientName = (p: any) => p?.full_name ?? p?.nome ?? '';
|
|
const getPatientCpf = (p: any) => p?.cpf ?? '';
|
|
const getPatientSex = (p: any) => p?.sex ?? p?.sexo ?? '';
|
|
const getPatientId = (p: any) => p?.id ?? '';
|
|
const getPatientAge = (p: any) => {
|
|
if (!p) return '';
|
|
// Prefer birth_date (ISO) to calcular idade
|
|
const bd = p?.birth_date ?? p?.data_nascimento ?? p?.birthDate;
|
|
if (bd) {
|
|
const d = new Date(bd);
|
|
if (!isNaN(d.getTime())) {
|
|
const age = Math.floor((Date.now() - d.getTime()) / (1000 * 60 * 60 * 24 * 365.25));
|
|
return `${age}`;
|
|
}
|
|
}
|
|
// Fallback para campo idade/idade_anterior
|
|
return p?.idade ?? p?.age ?? '';
|
|
};
|
|
|
|
// Normaliza número de telefone para E.164 básico (prioriza +55 quando aplicável)
|
|
const normalizePhoneNumber = (raw?: string) => {
|
|
if (!raw || typeof raw !== 'string') return '';
|
|
// Remover tudo que não for dígito
|
|
const digits = raw.replace(/\D+/g, '');
|
|
if (!digits) return '';
|
|
// Já tem código de país (começa com 55)
|
|
if (digits.startsWith('55') && digits.length >= 11) return '+' + digits;
|
|
// Se tiver 10 ou 11 dígitos (DDD + número), assume Brasil e prefixa +55
|
|
if (digits.length === 10 || digits.length === 11) return '+55' + digits;
|
|
// Se tiver outros formatos pequenos, apenas prefixa +
|
|
return '+' + digits;
|
|
};
|
|
|
|
// Helpers para normalizar campos do laudo/relatório
|
|
const getReportPatientName = (r: any) => r?.paciente?.full_name ?? r?.paciente?.nome ?? r?.patient?.full_name ?? r?.patient?.nome ?? r?.patient_name ?? r?.patient_full_name ?? '';
|
|
const getReportPatientId = (r: any) => r?.paciente?.id ?? r?.patient?.id ?? r?.patient_id ?? r?.patientId ?? r?.patient_id_raw ?? r?.patient_id ?? r?.id ?? '';
|
|
const getReportPatientCpf = (r: any) => r?.paciente?.cpf ?? r?.patient?.cpf ?? r?.patient_cpf ?? '';
|
|
const getReportExecutor = (r: any) => r?.executante ?? r?.requested_by ?? r?.requestedBy ?? r?.created_by ?? r?.createdBy ?? r?.requested_by_name ?? r?.executor ?? '';
|
|
const getReportExam = (r: any) => r?.exame ?? r?.exam ?? r?.especialidade ?? r?.cid_code ?? r?.report_type ?? '-';
|
|
const getReportDate = (r: any) => r?.data ?? r?.created_at ?? r?.due_at ?? r?.report_date ?? '';
|
|
const formatReportDate = (raw?: string) => {
|
|
if (!raw) return '-';
|
|
try {
|
|
const d = new Date(raw);
|
|
if (isNaN(d.getTime())) return raw;
|
|
return d.toLocaleDateString('pt-BR');
|
|
} catch (e) {
|
|
return raw;
|
|
}
|
|
};
|
|
|
|
const ProfissionalPage = () => {
|
|
const router = useRouter();
|
|
const { logout, user, token } = useAuth();
|
|
const { toast } = useToast();
|
|
const [activeSection, setActiveSection] = useState('calendario');
|
|
const [pacienteSelecionado, setPacienteSelecionado] = useState<any>(null);
|
|
|
|
// Estados para edição de laudo
|
|
const [isEditingLaudoForPatient, setIsEditingLaudoForPatient] = useState(false);
|
|
const [patientForLaudo, setPatientForLaudo] = useState<any>(null);
|
|
|
|
// Estados para o perfil do médico
|
|
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
|
const [doctorId, setDoctorId] = useState<string | null>(null);
|
|
|
|
// Estados para disponibilidades e exceções do médico logado
|
|
const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>([]);
|
|
const [exceptions, setExceptions] = useState<DoctorException[]>([]);
|
|
const [availabilitiesForCreate, setAvailabilitiesForCreate] = useState<DoctorAvailability[]>([]);
|
|
const [availLoading, setAvailLoading] = useState(false);
|
|
const [exceptLoading, setExceptLoading] = useState(false);
|
|
const [editingAvailability, setEditingAvailability] = useState<DoctorAvailability | null>(null);
|
|
const [editingException, setEditingException] = useState<DoctorException | null>(null);
|
|
const [showAvailabilityForm, setShowAvailabilityForm] = useState(false);
|
|
const [showExceptionForm, setShowExceptionForm] = useState(false);
|
|
|
|
// Hook para carregar automaticamente o avatar do médico
|
|
const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(doctorId);
|
|
// Removemos o placeholder extenso — inicializamos com valores minimalistas e vazios.
|
|
const [profileData, setProfileData] = useState({
|
|
nome: '',
|
|
email: user?.email || '',
|
|
telefone: '',
|
|
endereco: '',
|
|
cidade: '',
|
|
cep: '',
|
|
crm: '',
|
|
especialidade: '',
|
|
// biografia field removed — not present in Medico records
|
|
fotoUrl: ''
|
|
});
|
|
|
|
// pacientes carregados dinamicamente (hooks devem ficar dentro do componente)
|
|
const [pacientes, setPacientes] = useState<any[]>([]);
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
(async () => {
|
|
try {
|
|
if (!user || !user.id) {
|
|
if (mounted) setPacientes([]);
|
|
return;
|
|
}
|
|
|
|
const assignmentsMod = await import('@/lib/assignment');
|
|
if (!assignmentsMod || typeof assignmentsMod.listAssignmentsForUser !== 'function') {
|
|
if (mounted) setPacientes([]);
|
|
return;
|
|
}
|
|
|
|
const assignments = await assignmentsMod.listAssignmentsForUser(user.id || '');
|
|
const assignedIds = Array.isArray(assignments) ? assignments.map((a:any) => String(a.patient_id)).filter(Boolean) : [];
|
|
|
|
// Collect patient ids from both assignments and any appointments for this doctor
|
|
const patientIdSet = new Set<string>(assignedIds);
|
|
|
|
// Try to resolve the doctor's id so we can fetch appointments
|
|
let resolvedDoctorId = doctorId || null;
|
|
if (!resolvedDoctorId && user?.email) {
|
|
try {
|
|
const docs = await buscarMedicos(user.email).catch(() => []);
|
|
if (Array.isArray(docs) && docs.length > 0) {
|
|
const chosen = docs.find(d => String((d as any).user_id) === String(user.id)) || docs[0];
|
|
resolvedDoctorId = (chosen as any)?.id ?? null;
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (resolvedDoctorId) {
|
|
try {
|
|
// listarAgendamentos expects a query string like 'doctor_id=eq.<id>&limit=200'
|
|
const q = `doctor_id=eq.${encodeURIComponent(String(resolvedDoctorId))}&select=patient_id&limit=200`;
|
|
const appts = await listarAgendamentos(q).catch(() => []);
|
|
for (const a of (appts || [])) {
|
|
const pid = (a as any).patient_id ?? null;
|
|
if (pid) patientIdSet.add(String(pid));
|
|
}
|
|
} catch (e) {
|
|
console.warn('[ProfissionalPage] falha ao buscar agendamentos para resolver pacientes:', e);
|
|
}
|
|
}
|
|
|
|
const patientIds = Array.from(patientIdSet.values());
|
|
if (!patientIds.length) {
|
|
if (mounted) setPacientes([]);
|
|
return;
|
|
}
|
|
|
|
const patients = await buscarPacientesPorIds(patientIds);
|
|
const normalized = (patients || []).map((p: any) => ({
|
|
...p,
|
|
nome: p.full_name ?? (p as any).nome ?? '',
|
|
cpf: p.cpf ?? '',
|
|
idade: getPatientAge(p) // preencher idade para a tabela de pacientes
|
|
}));
|
|
if (mounted) setPacientes(normalized);
|
|
} catch (err) {
|
|
console.warn('[ProfissionalPage] falha ao carregar pacientes atribuídos:', err);
|
|
if (mounted) setPacientes([]);
|
|
}
|
|
})();
|
|
return () => { mounted = false; };
|
|
// Re-run when user id becomes available so patients assigned to the logged-in doctor are loaded
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [user?.id]);
|
|
|
|
// Carregar perfil do médico correspondente ao usuário logado
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
(async () => {
|
|
try {
|
|
if (!user || !user.email) return;
|
|
// Tenta buscar médicos pelo email do usuário (buscarMedicos lida com queries por email)
|
|
const docs = await buscarMedicos(user.email);
|
|
if (!mounted) return;
|
|
if (Array.isArray(docs) && docs.length > 0) {
|
|
// preferir registro cujo user_id bate com user.id
|
|
let chosen = docs.find(d => String((d as any).user_id) === String(user.id)) || docs[0];
|
|
if (chosen) {
|
|
// store the doctor's id so we can update it later
|
|
try { setDoctorId((chosen as any).id ?? null); } catch {};
|
|
// Especialidade pode vir como 'specialty' (inglês), 'especialidade' (pt),
|
|
// ou até uma lista/array. Normalizamos para string.
|
|
const rawSpecialty = (chosen as any).specialty ?? (chosen as any).especialidade ?? (chosen as any).especialidades ?? (chosen as any).especiality;
|
|
let specialtyStr = '';
|
|
if (Array.isArray(rawSpecialty)) {
|
|
specialtyStr = rawSpecialty.join(', ');
|
|
} else if (rawSpecialty) {
|
|
specialtyStr = String(rawSpecialty);
|
|
}
|
|
|
|
// Foto pode vir como 'foto_url' ou 'fotoUrl' ou 'avatar_url'
|
|
const foto = (chosen as any).foto_url || (chosen as any).fotoUrl || (chosen as any).avatar_url || '';
|
|
|
|
setProfileData((prev) => ({
|
|
...prev,
|
|
nome: (chosen as any).full_name || (chosen as any).nome_social || prev.nome || user?.email?.split('@')[0] || '',
|
|
email: (chosen as any).email || user?.email || prev.email,
|
|
telefone: (chosen as any).phone_mobile || (chosen as any).celular || (chosen as any).telefone || (chosen as any).phone || (chosen as any).mobile || (user as any)?.user_metadata?.phone || prev.telefone,
|
|
endereco: (chosen as any).street || (chosen as any).endereco || prev.endereco,
|
|
cidade: (chosen as any).city || (chosen as any).cidade || prev.cidade,
|
|
cep: (chosen as any).cep || prev.cep,
|
|
// store raw CRM (only the number) to avoid double-prefixing when rendering
|
|
crm: (chosen as any).crm ? String((chosen as any).crm).replace(/^(?:CRM\s*)+/i, '').trim() : (prev.crm || ''),
|
|
especialidade: specialtyStr || prev.especialidade || '',
|
|
// biografia removed: prefer to ignore observacoes/curriculo_url here
|
|
// (if needed elsewhere, render directly from chosen.observacoes)
|
|
fotoUrl: foto || prev.fotoUrl || ''
|
|
}));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[ProfissionalPage] falha ao carregar perfil do médico pelo email:', e);
|
|
}
|
|
})();
|
|
return () => { mounted = false; };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [user?.email, user?.id]);
|
|
|
|
// Sincroniza a URL do avatar recuperada com o profileData
|
|
useEffect(() => {
|
|
if (retrievedAvatarUrl) {
|
|
setProfileData(prev => ({
|
|
...prev,
|
|
fotoUrl: retrievedAvatarUrl
|
|
}));
|
|
}
|
|
}, [retrievedAvatarUrl]);
|
|
|
|
// Carregar disponibilidades e exceções do médico logado
|
|
const reloadAvailabilities = async (medId?: string) => {
|
|
const id = medId || doctorId;
|
|
if (!id) return;
|
|
try {
|
|
setAvailLoading(true);
|
|
const avails = await listarDisponibilidades({ doctorId: id, active: true });
|
|
setAvailabilities(Array.isArray(avails) ? avails : []);
|
|
} catch (e) {
|
|
console.warn('[ProfissionalPage] Erro ao carregar disponibilidades:', e);
|
|
setAvailabilities([]);
|
|
} finally {
|
|
setAvailLoading(false);
|
|
}
|
|
};
|
|
|
|
const reloadExceptions = async (medId?: string) => {
|
|
const id = medId || doctorId;
|
|
if (!id) return;
|
|
try {
|
|
setExceptLoading(true);
|
|
console.log('[ProfissionalPage] Recarregando exceções para médico:', id);
|
|
const excepts = await listarExcecoes({ doctorId: id });
|
|
console.log('[ProfissionalPage] Exceções carregadas:', excepts);
|
|
setExceptions(Array.isArray(excepts) ? excepts : []);
|
|
} catch (e) {
|
|
console.warn('[ProfissionalPage] Erro ao carregar exceções:', e);
|
|
setExceptions([]);
|
|
} finally {
|
|
setExceptLoading(false);
|
|
}
|
|
};
|
|
|
|
// Carrega disponibilidades quando doctorId muda
|
|
useEffect(() => {
|
|
if (doctorId) {
|
|
reloadAvailabilities(doctorId);
|
|
reloadExceptions(doctorId);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [doctorId]);
|
|
|
|
|
|
// Estados para campos principais da consulta
|
|
const [consultaAtual, setConsultaAtual] = useState({
|
|
patient_id: "",
|
|
order_number: "",
|
|
exam: "",
|
|
diagnosis: "",
|
|
conclusion: "",
|
|
cid_code: "",
|
|
content_html: "",
|
|
content_json: {},
|
|
status: "draft",
|
|
requested_by: "",
|
|
due_at: new Date().toISOString(),
|
|
hide_date: true,
|
|
hide_signature: true
|
|
});
|
|
|
|
|
|
|
|
const [events, setEvents] = useState<any[]>([]);
|
|
// Load real appointments for the logged in doctor and map to calendar events
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
(async () => {
|
|
try {
|
|
// If we already have a doctorId (set earlier), use it. Otherwise try to resolve from the logged user
|
|
let docId = doctorId;
|
|
if (!docId && user && user.email) {
|
|
// buscarMedicos may return the doctor's record including id
|
|
try {
|
|
const docs = await buscarMedicos(user.email).catch(() => []);
|
|
if (Array.isArray(docs) && docs.length > 0) {
|
|
const chosen = docs.find(d => String((d as any).user_id) === String(user.id)) || docs[0];
|
|
docId = (chosen as any)?.id ?? null;
|
|
if (mounted && !doctorId) setDoctorId(docId);
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (!docId) {
|
|
// nothing to fetch yet
|
|
return;
|
|
}
|
|
|
|
// Fetch appointments for this doctor. We'll ask for future and recent past appointments
|
|
// using a simple filter: doctor_id=eq.<docId>&order=scheduled_at.asc&limit=200
|
|
const qs = `?select=*&doctor_id=eq.${encodeURIComponent(String(docId))}&order=scheduled_at.asc&limit=200`;
|
|
const appts = await listarAgendamentos(qs).catch(() => []);
|
|
|
|
if (!mounted) return;
|
|
|
|
// Enrich appointments with patient names (batch fetch) and map to UI events
|
|
const patientIds = Array.from(new Set((appts || []).map((x:any) => String(x.patient_id || x.patient_id_raw || '').trim()).filter(Boolean)));
|
|
let patientMap = new Map<string, any>();
|
|
if (patientIds.length) {
|
|
try {
|
|
const patients = await buscarPacientesPorIds(patientIds).catch(() => []);
|
|
for (const p of patients || []) {
|
|
if (p && p.id) patientMap.set(String(p.id), p);
|
|
}
|
|
} catch (e) {
|
|
console.warn('[ProfissionalPage] falha ao buscar pacientes para eventos:', e);
|
|
}
|
|
}
|
|
|
|
const mapped = (appts || []).map((a: any, idx: number) => {
|
|
const scheduled = a.scheduled_at || a.time || a.created_at || null;
|
|
// Use local date components to avoid UTC shift when showing the appointment day/time
|
|
let datePart = new Date().toISOString().split('T')[0];
|
|
let timePart = '';
|
|
if (scheduled) {
|
|
try {
|
|
const d = new Date(scheduled);
|
|
// build local date string YYYY-MM-DD using local getters
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
datePart = `${y}-${m}-${day}`;
|
|
timePart = `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const pid = a.patient_id || (a as any).patient || a.patient_id_raw || a.patientId || null;
|
|
const patientObj = pid ? patientMap.get(String(pid)) : null;
|
|
const patientName = patientObj?.full_name || (a as any).patient || a.patient_name || String(pid) || 'Paciente';
|
|
const patientIdVal = pid || null;
|
|
|
|
return {
|
|
id: a.id ?? `srv-${idx}-${String(a.scheduled_at || a.created_at || idx)}`,
|
|
title: patientName || 'Paciente',
|
|
type: a.appointment_type || 'Consulta',
|
|
time: timePart || '',
|
|
date: datePart,
|
|
pacienteId: patientIdVal,
|
|
color: colorsByType[a.specialty as keyof typeof colorsByType] || '#4dabf7',
|
|
raw: a,
|
|
};
|
|
});
|
|
|
|
setEvents(mapped);
|
|
|
|
// Helper: parse 'YYYY-MM-DD' into a local Date to avoid UTC parsing which can shift day
|
|
const parseYMDToLocal = (ymd?: string) => {
|
|
if (!ymd || typeof ymd !== 'string') return new Date();
|
|
const parts = ymd.split('-').map(Number);
|
|
if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return new Date(ymd);
|
|
const [y, m, d] = parts;
|
|
return new Date(y, (m || 1) - 1, d || 1);
|
|
};
|
|
|
|
// Set calendar view to nearest upcoming appointment (or today)
|
|
try {
|
|
const now = Date.now();
|
|
const upcoming = mapped.find((m:any) => {
|
|
if (!m.raw) return false;
|
|
const s = m.raw.scheduled_at || m.raw.time || m.raw.created_at;
|
|
if (!s) return false;
|
|
const t = new Date(s).getTime();
|
|
return !isNaN(t) && t >= now;
|
|
});
|
|
if (upcoming) {
|
|
setCurrentCalendarDate(parseYMDToLocal(upcoming.date));
|
|
} else if (mapped.length) {
|
|
// fallback: show the date of the first appointment
|
|
setCurrentCalendarDate(parseYMDToLocal(mapped[0].date));
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
} catch (err) {
|
|
console.warn('[ProfissionalPage] falha ao carregar agendamentos do servidor:', err);
|
|
// Keep mocked/empty events if fetch fails
|
|
}
|
|
})();
|
|
return () => { mounted = false; };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [doctorId]);
|
|
const [editingEvent, setEditingEvent] = useState<any>(null);
|
|
const [showPopup, setShowPopup] = useState(false);
|
|
const [showActionModal, setShowActionModal] = useState(false);
|
|
const [showDayModal, setShowDayModal] = useState(false);
|
|
const [selectedDayDate, setSelectedDayDate] = useState<Date | null>(null);
|
|
const [showPatientForm, setShowPatientForm] = useState(false);
|
|
const [step, setStep] = useState(1);
|
|
const [newEvent, setNewEvent] = useState({
|
|
title: "",
|
|
type: "",
|
|
time: "",
|
|
pacienteId: ""
|
|
});
|
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
|
const [selectedEvent, setSelectedEvent] = useState<any>(null);
|
|
const [currentCalendarDate, setCurrentCalendarDate] = useState(new Date());
|
|
|
|
const [commPhoneNumber, setCommPhoneNumber] = useState('');
|
|
const [commMessage, setCommMessage] = useState('');
|
|
const [commPatientId, setCommPatientId] = useState<string | null>(null);
|
|
const [commResponses, setCommResponses] = useState<any[]>([]);
|
|
const [commResponsesLoading, setCommResponsesLoading] = useState(false);
|
|
const [commResponsesError, setCommResponsesError] = useState<string | null>(null);
|
|
const [smsSending, setSmsSending] = useState(false);
|
|
|
|
const handleSave = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
event.preventDefault();
|
|
setSmsSending(true);
|
|
try {
|
|
// Validate required fields
|
|
if (!commPhoneNumber || !commPhoneNumber.trim()) throw new Error('O campo phone_number é obrigatório');
|
|
if (!commMessage || !commMessage.trim()) throw new Error('O campo message é obrigatório');
|
|
|
|
const payload: any = { phone_number: commPhoneNumber.trim(), message: commMessage.trim() };
|
|
if (commPatientId) payload.patient_id = commPatientId;
|
|
|
|
const headers: Record<string,string> = { 'Content-Type': 'application/json' };
|
|
// include any default headers from ENV_CONFIG if present (e.g. apikey)
|
|
if ((ENV_CONFIG as any)?.DEFAULT_HEADERS) Object.assign(headers, (ENV_CONFIG as any).DEFAULT_HEADERS);
|
|
// include Authorization if we have a token (user session)
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
// Ensure apikey is present (frontend only has ANON key in this project)
|
|
if (!headers.apikey && (ENV_CONFIG as any)?.SUPABASE_ANON_KEY) {
|
|
headers.apikey = (ENV_CONFIG as any).SUPABASE_ANON_KEY;
|
|
}
|
|
// Ensure Accept header
|
|
headers['Accept'] = 'application/json';
|
|
|
|
// Normalizar número antes de enviar (E.164 básico)
|
|
const normalized = normalizePhoneNumber(commPhoneNumber);
|
|
if (!normalized) throw new Error('Número inválido após normalização');
|
|
payload.phone_number = normalized;
|
|
|
|
// Debug: log payload and headers with secrets masked to help diagnose issues
|
|
try {
|
|
const masked = { ...headers } as Record<string, any>;
|
|
if (masked.apikey && typeof masked.apikey === 'string') masked.apikey = `${masked.apikey.slice(0,4)}...${masked.apikey.slice(-4)}`;
|
|
if (masked.Authorization) masked.Authorization = 'Bearer <<token-present>>';
|
|
console.debug('[ProfissionalPage] Enviando SMS -> url:', `${(ENV_CONFIG as any).SUPABASE_URL}/functions/v1/send-sms`, 'payload:', payload, 'headers(masked):', masked);
|
|
} catch (e) {
|
|
// ignore logging errors
|
|
}
|
|
|
|
const res = await fetch(`${(ENV_CONFIG as any).SUPABASE_URL}/functions/v1/send-sms`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
const body = await res.json().catch(() => null);
|
|
if (!res.ok) {
|
|
// If server returned 5xx and we sent a patient_id, try a single retry without patient_id
|
|
if (res.status >= 500 && payload.patient_id) {
|
|
try {
|
|
const fallback = { phone_number: payload.phone_number, message: payload.message };
|
|
console.debug('[ProfissionalPage] 5xx ao enviar com patient_id — tentando reenviar sem patient_id', { fallback });
|
|
const retryRes = await fetch(`${(ENV_CONFIG as any).SUPABASE_URL}/functions/v1/send-sms`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(fallback),
|
|
});
|
|
const retryBody = await retryRes.json().catch(() => null);
|
|
if (retryRes.ok) {
|
|
alert('SMS enviado com sucesso (sem patient_id)');
|
|
setCommPhoneNumber('');
|
|
setCommMessage('');
|
|
setCommPatientId(null);
|
|
return;
|
|
} else {
|
|
throw new Error(retryBody?.message || retryBody?.error || `Erro ao enviar SMS (retry ${retryRes.status})`);
|
|
}
|
|
} catch (retryErr) {
|
|
console.warn('[ProfissionalPage] Reenvio sem patient_id falhou', retryErr);
|
|
throw new Error(body?.message || body?.error || `Erro ao enviar SMS (${res.status})`);
|
|
}
|
|
}
|
|
throw new Error(body?.message || body?.error || `Erro ao enviar SMS (${res.status})`);
|
|
}
|
|
|
|
// success feedback
|
|
alert('SMS enviado com sucesso');
|
|
// clear fields
|
|
setCommPhoneNumber('');
|
|
setCommMessage('');
|
|
setCommPatientId(null);
|
|
} catch (err: any) {
|
|
alert(String(err?.message || err || 'Falha ao enviar SMS'));
|
|
} finally {
|
|
setSmsSending(false);
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
};
|
|
|
|
const loadCommResponses = async (patientId?: string) => {
|
|
const pid = patientId ?? commPatientId;
|
|
if (!pid) {
|
|
setCommResponses([]);
|
|
setCommResponsesError('Selecione um paciente para ver respostas');
|
|
return;
|
|
}
|
|
setCommResponsesLoading(true);
|
|
setCommResponsesError(null);
|
|
try {
|
|
// 1) tentar buscar por patient_id (o comportamento ideal)
|
|
const qs = new URLSearchParams();
|
|
qs.set('patient_id', `eq.${String(pid)}`);
|
|
qs.set('order', 'created_at.desc');
|
|
const url = `${(ENV_CONFIG as any).REST}/messages?${qs.toString()}`;
|
|
const headers: Record<string,string> = { 'Accept': 'application/json' };
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
if ((ENV_CONFIG as any)?.SUPABASE_ANON_KEY) headers['apikey'] = (ENV_CONFIG as any).SUPABASE_ANON_KEY;
|
|
const r = await fetch(url, { method: 'GET', headers });
|
|
let data = await r.json().catch(() => []);
|
|
data = Array.isArray(data) ? data : [];
|
|
|
|
// 2) Se não houver mensagens por patient_id, tentar buscar por número (from/to)
|
|
if ((!data || data.length === 0) && commPhoneNumber) {
|
|
try {
|
|
const norm = normalizePhoneNumber(commPhoneNumber);
|
|
if (norm) {
|
|
// Primeiro tenta buscar mensagens onde `from` é o número
|
|
const qsFrom = new URLSearchParams();
|
|
qsFrom.set('from', `eq.${String(norm)}`);
|
|
qsFrom.set('order', 'created_at.desc');
|
|
const urlFrom = `${(ENV_CONFIG as any).REST}/messages?${qsFrom.toString()}`;
|
|
const rf = await fetch(urlFrom, { method: 'GET', headers });
|
|
const dataFrom = await rf.json().catch(() => []);
|
|
if (Array.isArray(dataFrom) && dataFrom.length) {
|
|
data = dataFrom;
|
|
} else {
|
|
// se nada, tenta `to` (caso o provedor grave a direção inversa)
|
|
const qsTo = new URLSearchParams();
|
|
qsTo.set('to', `eq.${String(norm)}`);
|
|
qsTo.set('order', 'created_at.desc');
|
|
const urlTo = `${(ENV_CONFIG as any).REST}/messages?${qsTo.toString()}`;
|
|
const rt = await fetch(urlTo, { method: 'GET', headers });
|
|
const dataTo = await rt.json().catch(() => []);
|
|
if (Array.isArray(dataTo) && dataTo.length) data = dataTo;
|
|
}
|
|
}
|
|
} catch (phoneErr) {
|
|
// não bloqueara o fluxo principal; apenas log
|
|
console.warn('[ProfissionalPage] fallback por telefone falhou', phoneErr);
|
|
}
|
|
}
|
|
|
|
setCommResponses(Array.isArray(data) ? data : []);
|
|
} catch (e: any) {
|
|
setCommResponsesError(String(e?.message || e || 'Falha ao buscar respostas'));
|
|
setCommResponses([]);
|
|
} finally {
|
|
setCommResponsesLoading(false);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
const handleEditarLaudo = (paciente: any) => {
|
|
setPatientForLaudo(paciente);
|
|
setIsEditingLaudoForPatient(true);
|
|
setActiveSection('laudos');
|
|
};
|
|
|
|
|
|
const navigateDate = (direction: 'prev' | 'next') => {
|
|
const newDate = new Date(currentCalendarDate);
|
|
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
|
setCurrentCalendarDate(newDate);
|
|
};
|
|
|
|
const goToToday = () => {
|
|
setCurrentCalendarDate(new Date());
|
|
};
|
|
|
|
const formatDate = (date: Date) => {
|
|
return date.toLocaleDateString('pt-BR', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric'
|
|
});
|
|
};
|
|
|
|
// Filtrar eventos do dia atual
|
|
const getTodayEvents = () => {
|
|
const today = currentCalendarDate.toISOString().split('T')[0];
|
|
return events
|
|
.filter(event => event.date === today)
|
|
.sort((a, b) => a.time.localeCompare(b.time));
|
|
};
|
|
|
|
const getStatusColor = (type: string) => {
|
|
return colorsByType[type as keyof typeof colorsByType] || "#4dabf7";
|
|
};
|
|
|
|
// Funções para o perfil
|
|
const handleProfileChange = (field: string, value: string) => {
|
|
setProfileData(prev => ({
|
|
...prev,
|
|
[field]: value
|
|
}));
|
|
};
|
|
|
|
const handleSaveProfile = () => {
|
|
(async () => {
|
|
if (!doctorId) {
|
|
alert('Não foi possível localizar o registro do médico para atualizar.');
|
|
setIsEditingProfile(false);
|
|
return;
|
|
}
|
|
|
|
// Build payload mapping UI fields to DB columns
|
|
const payload: any = {};
|
|
if (profileData.email) payload.email = profileData.email;
|
|
if (profileData.telefone) payload.phone_mobile = profileData.telefone;
|
|
if (profileData.endereco) payload.street = profileData.endereco;
|
|
if (profileData.cidade) payload.city = profileData.cidade;
|
|
if (profileData.cep) payload.cep = profileData.cep;
|
|
if (profileData.especialidade) payload.specialty = profileData.especialidade || profileData.especialidade;
|
|
if (profileData.fotoUrl) payload.foto_url = profileData.fotoUrl;
|
|
|
|
// Don't allow updating full_name or crm from this UI
|
|
|
|
try {
|
|
const updated = await atualizarMedico(doctorId, payload as any);
|
|
console.debug('[ProfissionalPage] médico atualizado:', updated);
|
|
alert('Perfil atualizado com sucesso!');
|
|
} catch (err: any) {
|
|
console.error('[ProfissionalPage] falha ao atualizar médico:', err);
|
|
// Mostrar mensagem amigável (o erro já é tratado em lib/api)
|
|
alert(err?.message || 'Falha ao atualizar perfil. Verifique logs.');
|
|
} finally {
|
|
setIsEditingProfile(false);
|
|
}
|
|
})();
|
|
};
|
|
|
|
const handleCancelEdit = () => {
|
|
setIsEditingProfile(false);
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDateClick = (arg: any) => {
|
|
setSelectedDate(arg.dateStr);
|
|
setNewEvent({ title: "", type: "", time: "", pacienteId: "" });
|
|
setStep(1);
|
|
setEditingEvent(null);
|
|
setShowPopup(true);
|
|
};
|
|
|
|
|
|
const handleAddEvent = () => {
|
|
const paciente = pacientes.find(p => p.nome === newEvent.title);
|
|
const eventToAdd = {
|
|
id: Date.now(),
|
|
title: newEvent.title,
|
|
type: newEvent.type,
|
|
time: newEvent.time,
|
|
date: selectedDate || currentCalendarDate.toISOString().split('T')[0],
|
|
pacienteId: paciente ? paciente.cpf : "",
|
|
color: colorsByType[newEvent.type as keyof typeof colorsByType] || "#4dabf7"
|
|
};
|
|
setEvents((prev) => [...prev, eventToAdd]);
|
|
setShowPopup(false);
|
|
};
|
|
|
|
|
|
const handleEditEvent = () => {
|
|
setEvents((prevEvents) =>
|
|
prevEvents.map((ev) =>
|
|
ev.id.toString() === editingEvent.id.toString()
|
|
? {
|
|
...ev,
|
|
title: newEvent.title,
|
|
type: newEvent.type,
|
|
time: newEvent.time,
|
|
color: colorsByType[newEvent.type as keyof typeof colorsByType] || "#4dabf7"
|
|
}
|
|
: ev
|
|
)
|
|
);
|
|
setEditingEvent(null);
|
|
setShowPopup(false);
|
|
setShowActionModal(false);
|
|
};
|
|
|
|
|
|
const handleNextStep = () => {
|
|
if (step < 3) setStep(step + 1);
|
|
else editingEvent ? handleEditEvent() : handleAddEvent();
|
|
};
|
|
|
|
|
|
const handleEventClick = (clickInfo: any) => {
|
|
setSelectedEvent(clickInfo.event);
|
|
setShowActionModal(true);
|
|
};
|
|
|
|
|
|
const handleDeleteEvent = () => {
|
|
if (!selectedEvent) return;
|
|
setEvents((prevEvents) =>
|
|
prevEvents.filter((ev: any) => ev.id.toString() !== selectedEvent.id.toString())
|
|
);
|
|
setShowActionModal(false);
|
|
};
|
|
|
|
|
|
const handleStartEdit = () => {
|
|
if (!selectedEvent) return;
|
|
setEditingEvent(selectedEvent);
|
|
setNewEvent({
|
|
title: selectedEvent.title,
|
|
type: selectedEvent.extendedProps.type,
|
|
time: selectedEvent.extendedProps.time,
|
|
pacienteId: selectedEvent.extendedProps.pacienteId || ""
|
|
});
|
|
setStep(1);
|
|
setShowActionModal(false);
|
|
setShowPopup(true);
|
|
};
|
|
|
|
|
|
const renderEventContent = (eventInfo: any) => {
|
|
const bg = eventInfo.event.backgroundColor || eventInfo.event.extendedProps?.color || "#4dabf7";
|
|
|
|
return (
|
|
<div
|
|
className="flex items-center gap-1 text-xs p-1 rounded cursor-pointer"
|
|
style={{
|
|
backgroundColor: bg,
|
|
color: "#fff",
|
|
maxWidth: "100%",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis"
|
|
}}
|
|
title={`${eventInfo.event.title} • ${eventInfo.event.extendedProps.type} • ${eventInfo.event.extendedProps.time}`}
|
|
>
|
|
<span className="truncate">{eventInfo.event.title}</span>
|
|
<span>•</span>
|
|
<span className="truncate">{eventInfo.event.extendedProps.type}</span>
|
|
<span>•</span>
|
|
<span>{eventInfo.event.extendedProps.time}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
const renderCalendarioSection = () => {
|
|
const todayEvents = getTodayEvents();
|
|
|
|
return (
|
|
<section className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6 overflow-x-hidden">
|
|
<div className="flex justify-between items-center mb-3 sm:mb-4">
|
|
<h2 className="text-lg sm:text-xl md:text-2xl font-bold">Agenda do Dia</h2>
|
|
</div>
|
|
|
|
{/* Navegação de Data - Responsiva */}
|
|
<div className="flex flex-col gap-2 sm:gap-3 mb-4 sm:mb-6 p-2 sm:p-3 md:p-4 bg-muted rounded-lg">
|
|
<div className="flex items-center gap-1.5 sm:gap-2 md:gap-4 flex-wrap">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => navigateDate('prev')}
|
|
className="p-1.5 sm:p-2 hover-primary-blue cursor-pointer h-auto"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<h3 className="text-sm sm:text-base md:text-lg font-medium text-foreground flex-1 line-clamp-1">
|
|
{formatDate(currentCalendarDate)}
|
|
</h3>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => navigateDate('next')}
|
|
className="p-1.5 sm:p-2 hover-primary-blue cursor-pointer h-auto"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="text-xs sm:text-sm text-gray-600 dark:text-muted-foreground">
|
|
<span className="font-semibold">{todayEvents.length}</span> consulta{todayEvents.length !== 1 ? 's' : ''}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lista de Pacientes do Dia */}
|
|
<div className="space-y-2 sm:space-y-3 max-h-[calc(100vh-450px)] overflow-y-auto overflow-x-hidden pr-2">
|
|
{todayEvents.length === 0 ? (
|
|
<div className="text-center py-6 sm:py-8 text-gray-600 dark:text-muted-foreground">
|
|
<CalendarIcon className="h-8 sm:h-10 md:h-12 w-8 sm:w-10 md:w-12 mx-auto mb-2 sm:mb-3 text-gray-400 dark:text-muted-foreground/50" />
|
|
<p className="text-sm sm:text-base font-medium mb-1">Nenhuma consulta agendada para este dia</p>
|
|
<p className="text-xs sm:text-sm">Agenda livre para este dia</p>
|
|
</div>
|
|
) : (
|
|
todayEvents.map((appointment) => {
|
|
const paciente = pacientes.find(p => p.nome === appointment.title);
|
|
return (
|
|
<div
|
|
key={appointment.id}
|
|
className="border-l-4 border-t border-r border-b p-2.5 sm:p-3 md:p-4 rounded-lg shadow-sm bg-card border-border hover:shadow-md transition-shadow"
|
|
style={{ borderLeftColor: getStatusColor(appointment.type) }}
|
|
>
|
|
<div className="flex flex-col sm:grid sm:grid-cols-2 md:grid-cols-4 gap-2 sm:gap-3 md:gap-4 items-start sm:items-center">
|
|
<div className="flex items-start sm:items-center gap-2 min-w-0">
|
|
<div
|
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0 mt-1 sm:mt-0"
|
|
style={{ backgroundColor: getStatusColor(appointment.type) }}
|
|
></div>
|
|
<div className="min-w-0">
|
|
<div className="font-medium text-xs sm:text-sm md:text-base flex items-center gap-1.5 flex-wrap">
|
|
<User className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-gray-500 dark:text-muted-foreground flex-shrink-0" />
|
|
<span className="truncate">{appointment.title}</span>
|
|
</div>
|
|
{paciente && (
|
|
<div className="text-xs text-gray-600 dark:text-muted-foreground truncate mt-0.5">
|
|
CPF: {getPatientCpf(paciente)} • {getPatientAge(paciente)} anos
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm md:text-base">
|
|
<Clock className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-gray-500 dark:text-muted-foreground flex-shrink-0" />
|
|
<span className="font-medium">{appointment.time}</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<div
|
|
className="px-2 sm:px-2.5 md:px-3 py-1 rounded-full text-xs font-medium text-white whitespace-nowrap"
|
|
style={{ backgroundColor: getStatusColor(appointment.type) }}
|
|
>
|
|
{appointment.type}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end">
|
|
{/* Tornar o trigger focusable/touchable para mobile: tabIndex + classes responsivas */}
|
|
<div className="relative group" tabIndex={0} role="button" aria-label={`Informações da consulta ${appointment.title}`}>
|
|
{/* Tooltip: em telas pequenas com scrollbar horizontal permanente (overflow-x-scroll); desktop sem scroll (hover) */}
|
|
<div className="absolute sm:bottom-full bottom-auto sm:left-1/2 left-0 sm:transform sm:-translate-x-1/2 right-0 sm:mb-2 top-full sm:top-auto sm:mt-0 mt-2 px-3 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs sm:text-xs rounded-md opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity duration-200 pointer-events-auto sm:pointer-events-none whitespace-normal sm:whitespace-nowrap z-50 max-w-[92vw] sm:max-w-xs overflow-y-hidden overflow-x-scroll sm:overflow-x-hidden">
|
|
<div className="font-medium">{appointment.title} • {appointment.type}</div>
|
|
<div className="text-[11px] text-muted-foreground mt-1">Agendamento para {appointment.title}. Status: {appointment.type === 'Rotina' ? 'requested' : appointment.type}.</div>
|
|
<div className="text-[11px] text-muted-foreground mt-1">{appointment.time} • {appointment.date}</div>
|
|
<div className="absolute sm:top-full top-auto sm:left-1/2 left-1/2 transform sm:-translate-x-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent sm:border-t-gray-900 dark:sm:border-t-gray-100 sm:mt-0 mt-0"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderLaudosSection = () => (
|
|
<div className="space-y-6">
|
|
<LaudoManager
|
|
isEditingForPatient={isEditingLaudoForPatient}
|
|
selectedPatientForLaudo={patientForLaudo}
|
|
onClosePatientEditor={() => {
|
|
setIsEditingLaudoForPatient(false);
|
|
setPatientForLaudo(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
// --- NOVO SISTEMA DE LAUDOS COMPLETO ---
|
|
function LaudoManager({ isEditingForPatient, selectedPatientForLaudo, onClosePatientEditor }: { isEditingForPatient?: boolean; selectedPatientForLaudo?: any; onClosePatientEditor?: () => void }) {
|
|
const [pacientesDisponiveis] = useState([
|
|
{ id: "95170038", nome: "Ana Souza", cpf: "123.456.789-00", idade: 42, sexo: "Feminino" },
|
|
{ id: "93203056", nome: "Bruno Lima", cpf: "987.654.321-00", idade: 33, sexo: "Masculino" },
|
|
{ id: "92953542", nome: "Carla Menezes", cpf: "111.222.333-44", idade: 67, sexo: "Feminino" },
|
|
]);
|
|
|
|
const { reports, loadReports, loadReportById, loading: reportsLoading, createNewReport, updateExistingReport } = useReports();
|
|
const [laudos, setLaudos] = useState<any[]>([]);
|
|
const [selectedRange, setSelectedRange] = useState<'todos'|'semana'|'mes'|'custom'>('mes');
|
|
const [startDate, setStartDate] = useState<string | null>(null);
|
|
const [endDate, setEndDate] = useState<string | null>(null);
|
|
|
|
// helper to check if a date string is in range
|
|
const isInRange = (dateStr: string | undefined, range: 'todos'|'semana'|'mes'|'custom') => {
|
|
if (range === 'todos') return true;
|
|
if (!dateStr) return false;
|
|
const d = new Date(dateStr);
|
|
if (isNaN(d.getTime())) return false;
|
|
const now = new Date();
|
|
|
|
if (range === 'semana') {
|
|
const start = new Date(now);
|
|
start.setDate(now.getDate() - now.getDay()); // sunday start
|
|
const end = new Date(start);
|
|
end.setDate(start.getDate() + 6);
|
|
return d >= start && d <= end;
|
|
}
|
|
// mes
|
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
|
|
};
|
|
|
|
// helper: ensure report has paciente object populated (fetch by id if necessary)
|
|
const ensurePaciente = async (report: any) => {
|
|
if (!report) return report;
|
|
try {
|
|
if (!report.paciente) {
|
|
const pid = report.patient_id ?? report.patient ?? report.paciente ?? null;
|
|
if (pid) {
|
|
try {
|
|
const p = await buscarPacientePorId(String(pid));
|
|
if (p) report.paciente = p;
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
return report;
|
|
};
|
|
|
|
// When selectedRange changes (and isn't custom), compute start/end dates
|
|
useEffect(() => {
|
|
const now = new Date();
|
|
if (selectedRange === 'todos') {
|
|
setStartDate(null);
|
|
setEndDate(null);
|
|
return;
|
|
}
|
|
|
|
if (selectedRange === 'semana') {
|
|
const start = new Date(now);
|
|
start.setDate(now.getDate() - now.getDay()); // sunday
|
|
const end = new Date(start);
|
|
end.setDate(start.getDate() + 6);
|
|
setStartDate(start.toISOString().slice(0,10));
|
|
setEndDate(end.toISOString().slice(0,10));
|
|
return;
|
|
}
|
|
if (selectedRange === 'mes') {
|
|
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
setStartDate(start.toISOString().slice(0,10));
|
|
setEndDate(end.toISOString().slice(0,10));
|
|
return;
|
|
}
|
|
// custom: leave startDate/endDate as-is
|
|
}, [selectedRange]);
|
|
|
|
const filteredLaudos = (laudos || []).filter(l => {
|
|
// If a specific start/end date is set, use that range
|
|
if (startDate && endDate) {
|
|
const ds = getReportDate(l);
|
|
if (!ds) return false;
|
|
const d = new Date(ds);
|
|
if (isNaN(d.getTime())) return false;
|
|
const start = new Date(startDate + 'T00:00:00');
|
|
const end = new Date(endDate + 'T23:59:59');
|
|
return d >= start && d <= end;
|
|
}
|
|
// Fallback to selectedRange heuristics
|
|
if (!selectedRange) return true;
|
|
const ds = getReportDate(l);
|
|
return isInRange(ds, selectedRange);
|
|
});
|
|
|
|
function DateRangeButtons() {
|
|
return (
|
|
<>
|
|
<Button
|
|
variant={selectedRange === 'todos' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setSelectedRange('todos')}
|
|
className="hover-primary-blue"
|
|
>
|
|
Todos
|
|
</Button>
|
|
<Button
|
|
variant={selectedRange === 'semana' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setSelectedRange('semana')}
|
|
className="hover-primary-blue"
|
|
>
|
|
Semana
|
|
</Button>
|
|
<Button
|
|
variant={selectedRange === 'mes' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setSelectedRange('mes')}
|
|
className="hover-primary-blue"
|
|
>
|
|
Mês
|
|
</Button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// SearchBox inserido aqui para acessar reports, setLaudos e loadReports
|
|
function SearchBox() {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [searching, setSearching] = useState(false);
|
|
const { token } = useAuth();
|
|
|
|
const isMaybeId = (s: string) => {
|
|
const t = s.trim();
|
|
if (!t) return false;
|
|
if (t.includes('-') && t.length > 10) return true;
|
|
if (t.toUpperCase().startsWith('REL-')) return true;
|
|
const digits = t.replace(/\D/g, '');
|
|
if (digits.length >= 8) return true;
|
|
return false;
|
|
};
|
|
|
|
const doSearch = async () => {
|
|
const term = searchTerm.trim();
|
|
if (!term) return;
|
|
setSearching(true);
|
|
try {
|
|
if (isMaybeId(term)) {
|
|
try {
|
|
let r: any = null;
|
|
// Try direct API lookup first
|
|
try {
|
|
r = await buscarRelatorioPorId(term);
|
|
} catch (e) {
|
|
console.warn('[SearchBox] buscarRelatorioPorId failed, will try loadReportById fallback', e);
|
|
}
|
|
|
|
// Fallback: use hook loader if direct API didn't return
|
|
if (!r) {
|
|
try {
|
|
r = await loadReportById(term);
|
|
} catch (e) {
|
|
console.warn('[SearchBox] loadReportById fallback failed', e);
|
|
}
|
|
}
|
|
|
|
if (r) {
|
|
// If token exists, attempt batch enrichment like useReports
|
|
const enriched: any = { ...r };
|
|
|
|
// Collect possible patient/doctor ids from payload
|
|
const pidCandidates: string[] = [];
|
|
const didCandidates: string[] = [];
|
|
const pid = (r as any).patient_id ?? (r as any).patient ?? (r as any).paciente ?? null;
|
|
if (pid) pidCandidates.push(String(pid));
|
|
const possiblePatientName = (r as any).patient_name ?? (r as any).patient_full_name ?? (r as any).paciente?.full_name ?? (r as any).paciente?.nome ?? null;
|
|
if (possiblePatientName) {
|
|
enriched.paciente = enriched.paciente ?? {};
|
|
enriched.paciente.full_name = possiblePatientName;
|
|
}
|
|
|
|
const did = (r as any).requested_by ?? (r as any).created_by ?? (r as any).executante ?? null;
|
|
if (did) didCandidates.push(String(did));
|
|
|
|
// If token available, perform batch fetch to get full patient/doctor objects
|
|
if (token) {
|
|
try {
|
|
if (pidCandidates.length) {
|
|
const patients = await buscarPacientesPorIds(pidCandidates);
|
|
if (patients && patients.length) {
|
|
const p = patients[0];
|
|
enriched.paciente = enriched.paciente ?? {};
|
|
enriched.paciente.full_name = enriched.paciente.full_name || p.full_name || (p as any).nome;
|
|
enriched.paciente.id = enriched.paciente.id || p.id;
|
|
enriched.paciente.cpf = enriched.paciente.cpf || p.cpf;
|
|
}
|
|
}
|
|
if (didCandidates.length) {
|
|
const doctors = await buscarMedicosPorIds(didCandidates);
|
|
if (doctors && doctors.length) {
|
|
const d = doctors[0];
|
|
enriched.executante = enriched.executante || d.full_name || (d as any).nome;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// fallback: continue with payload-only enrichment
|
|
console.warn('[SearchBox] batch enrichment failed, falling back to payload-only enrichment', e);
|
|
}
|
|
}
|
|
|
|
// Final payload-only fallbacks (ensure id/cpf/order_number are populated)
|
|
const possiblePatientId = (r as any).paciente?.id ?? (r as any).patient?.id ?? (r as any).patient_id ?? (r as any).patientId ?? (r as any).id ?? undefined;
|
|
if (possiblePatientId && !enriched.paciente?.id) {
|
|
enriched.paciente = enriched.paciente ?? {};
|
|
enriched.paciente.id = possiblePatientId;
|
|
}
|
|
const possibleCpf = (r as any).patient_cpf ?? (r as any).paciente?.cpf ?? (r as any).patient?.cpf ?? null;
|
|
if (possibleCpf) {
|
|
enriched.paciente = enriched.paciente ?? {};
|
|
enriched.paciente.cpf = possibleCpf;
|
|
}
|
|
const execName = (r as any).requested_by_name ?? (r as any).requester_name ?? (r as any).requestedByName ?? (r as any).executante_name ?? (r as any).created_by_name ?? (r as any).createdByName ?? (r as any).executante ?? (r as any).requested_by ?? (r as any).created_by ?? '';
|
|
if (execName) enriched.executante = enriched.executante || execName;
|
|
if ((r as any).order_number) enriched.order_number = (r as any).order_number;
|
|
|
|
setLaudos([enriched]);
|
|
return;
|
|
}
|
|
} catch (err: any) {
|
|
console.warn('Relatório não encontrado por ID:', err);
|
|
}
|
|
}
|
|
|
|
const lower = term.toLowerCase();
|
|
const filtered = (reports || []).filter((x: any) => {
|
|
const name = (x.paciente?.full_name || x.patient_name || x.patient_full_name || x.order_number || x.exame || x.exam || '').toString().toLowerCase();
|
|
return name.includes(lower);
|
|
});
|
|
if (filtered.length) setLaudos(filtered);
|
|
else setLaudos([]);
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
};
|
|
|
|
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') doSearch();
|
|
};
|
|
|
|
const handleClear = async () => {
|
|
setSearchTerm('');
|
|
try {
|
|
// Reuse the same logic as initial load so Clear restores the doctor's assigned laudos
|
|
await loadAssignedLaudos();
|
|
} catch (err) {
|
|
console.warn('[SearchBox] erro ao restaurar laudos do médico ao limpar busca:', err);
|
|
// Safe fallback to whatever reports are available
|
|
setLaudos(reports || []);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="relative">
|
|
<Input
|
|
placeholder="Buscar paciente / pedido / ID"
|
|
className="pl-10 h-10"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
onKeyDown={handleKey}
|
|
/>
|
|
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<Button size="sm" onClick={doSearch} disabled={searching}>
|
|
Buscar
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={handleClear} className="hover-primary-blue">
|
|
Limpar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// helper to load laudos for the patients assigned to the logged-in user
|
|
const loadAssignedLaudos = async () => {
|
|
try {
|
|
// Primeiro, tenta carregar laudos criados pelo próprio médico
|
|
console.log('[LaudoManager] Tentando carregar laudos criados pelo médico:', user?.id);
|
|
try {
|
|
const reportsMod = await import('@/lib/reports');
|
|
const allMyReports = await loadReports();
|
|
|
|
if (Array.isArray(allMyReports) && allMyReports.length > 0) {
|
|
// Filtrar apenas os criados por mim
|
|
const createdByMe = allMyReports.filter((r: any) => {
|
|
const creator = ((r.created_by ?? r.executante ?? r.createdBy) || '').toString();
|
|
return user?.id && creator && creator === user.id;
|
|
});
|
|
|
|
if (createdByMe.length > 0) {
|
|
console.log('[LaudoManager] Encontrados', createdByMe.length, 'laudos criados pelo médico');
|
|
const enriched = await (async (reportsArr: any[]) => {
|
|
if (!reportsArr || !reportsArr.length) return reportsArr;
|
|
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
|
|
if (!pids.length) return reportsArr;
|
|
try {
|
|
const patients = await buscarPacientesPorIds(pids);
|
|
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
|
|
return reportsArr.map((r: any) => {
|
|
const pid = String(getReportPatientId(r));
|
|
return { ...r, paciente: r.paciente ?? map.get(pid) ?? r.paciente } as any;
|
|
});
|
|
} catch (e) {
|
|
console.warn('[LaudoManager] Erro ao enriquecer pacientes:', e);
|
|
return reportsArr;
|
|
}
|
|
})(createdByMe);
|
|
setLaudos(enriched || []);
|
|
return;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[LaudoManager] erro ao carregar laudos criados pelo médico:', e);
|
|
}
|
|
|
|
// Fallback: carregar laudos de pacientes atribuídos
|
|
const assignments = await import('@/lib/assignment').then(m => m.listAssignmentsForUser(user?.id || ''));
|
|
const patientIds = Array.isArray(assignments) ? assignments.map(a => String(a.patient_id)).filter(Boolean) : [];
|
|
|
|
if (patientIds.length === 0) {
|
|
console.log('[LaudoManager] Nenhum paciente atribuído, laudos vazios');
|
|
setLaudos([]);
|
|
return;
|
|
}
|
|
|
|
console.log('[LaudoManager] Carregando laudos de', patientIds.length, 'pacientes atribuídos');
|
|
try {
|
|
const reportsMod = await import('@/lib/reports');
|
|
if (typeof reportsMod.listarRelatoriosPorPacientes === 'function') {
|
|
const batch = await reportsMod.listarRelatoriosPorPacientes(patientIds);
|
|
const mineOnly = (batch || []).filter((r: any) => {
|
|
const requester = ((r.requested_by ?? r.created_by ?? r.executante ?? r.requestedBy ?? r.createdBy) || '').toString();
|
|
return user?.id && requester && requester === user.id;
|
|
});
|
|
|
|
const enriched = await (async (reportsArr: any[]) => {
|
|
if (!reportsArr || !reportsArr.length) return reportsArr;
|
|
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
|
|
if (!pids.length) return reportsArr;
|
|
try {
|
|
const patients = await buscarPacientesPorIds(pids);
|
|
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
|
|
return reportsArr.map((r: any) => {
|
|
const pid = String(getReportPatientId(r));
|
|
return { ...r, paciente: r.paciente ?? map.get(pid) ?? r.paciente } as any;
|
|
});
|
|
} catch (e) {
|
|
return reportsArr;
|
|
}
|
|
})(mineOnly);
|
|
setLaudos(enriched || []);
|
|
return;
|
|
} else {
|
|
const allReports: any[] = [];
|
|
for (const pid of patientIds) {
|
|
try {
|
|
const rels = await import('@/lib/reports').then(m => m.listarRelatoriosPorPaciente(pid));
|
|
if (Array.isArray(rels) && rels.length) {
|
|
const mine = rels.filter((r: any) => {
|
|
const requester = ((r.requested_by ?? r.created_by ?? r.executante ?? r.requestedBy ?? r.createdBy) || '').toString();
|
|
return user?.id && requester && requester === user.id;
|
|
});
|
|
if (mine.length) allReports.push(...mine);
|
|
}
|
|
} catch (err) {
|
|
console.warn('[LaudoManager] falha ao carregar relatórios para paciente', pid, err);
|
|
}
|
|
}
|
|
|
|
const enrichedAll = await (async (reportsArr: any[]) => {
|
|
if (!reportsArr || !reportsArr.length) return reportsArr;
|
|
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
|
|
if (!pids.length) return reportsArr;
|
|
try {
|
|
const patients = await buscarPacientesPorIds(pids);
|
|
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
|
|
return reportsArr.map((r: any) => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente } as any));
|
|
} catch (e) {
|
|
return reportsArr;
|
|
}
|
|
})(allReports);
|
|
setLaudos(enrichedAll);
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[LaudoManager] erro ao carregar relatórios em batch, tentando por paciente individual', err);
|
|
const allReports: any[] = [];
|
|
for (const pid of patientIds) {
|
|
try {
|
|
const rels = await import('@/lib/reports').then(m => m.listarRelatoriosPorPaciente(pid));
|
|
if (Array.isArray(rels) && rels.length) {
|
|
const mine = rels.filter((r: any) => {
|
|
const requester = ((r.requested_by ?? r.created_by ?? r.executante ?? r.requestedBy ?? r.createdBy) || '').toString();
|
|
return user?.id && requester && requester === user.id;
|
|
});
|
|
if (mine.length) allReports.push(...mine);
|
|
}
|
|
} catch (e) {
|
|
console.warn('[LaudoManager] falha ao carregar relatórios para paciente', pid, e);
|
|
}
|
|
}
|
|
const enrichedAll = await (async (reportsArr: any[]) => {
|
|
if (!reportsArr || !reportsArr.length) return reportsArr;
|
|
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
|
|
if (!pids.length) return reportsArr;
|
|
try {
|
|
const patients = await buscarPacientesPorIds(pids);
|
|
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
|
|
return reportsArr.map((r: any) => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente } as any));
|
|
} catch (e) {
|
|
return reportsArr;
|
|
}
|
|
})(allReports);
|
|
setLaudos(enrichedAll);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.warn('[LaudoManager] erro ao carregar laudos:', e);
|
|
setLaudos(reports || []);
|
|
}
|
|
};
|
|
|
|
// carregar laudos ao montar - somente dos pacientes atribuídos ao médico logado
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
(async () => {
|
|
// call the helper and bail if the component unmounted during async work
|
|
await loadAssignedLaudos();
|
|
})();
|
|
return () => { mounted = false; };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// sincroniza quando reports mudarem no hook (fallback)
|
|
useEffect(() => {
|
|
if (!laudos || laudos.length === 0) setLaudos(reports || []);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Sort reports newest-first (more recent dates at the top)
|
|
const sortedLaudos = React.useMemo(() => {
|
|
const arr = (filteredLaudos || []).slice();
|
|
arr.sort((a: any, b: any) => {
|
|
try {
|
|
const da = new Date(getReportDate(a) || 0).getTime() || 0;
|
|
const db = new Date(getReportDate(b) || 0).getTime() || 0;
|
|
return db - da;
|
|
} catch (e) {
|
|
return 0;
|
|
}
|
|
});
|
|
return arr;
|
|
}, [filteredLaudos]);
|
|
|
|
const [activeTab, setActiveTab] = useState("descobrir");
|
|
const [laudoSelecionado, setLaudoSelecionado] = useState<any>(null);
|
|
const [isViewing, setIsViewing] = useState(false);
|
|
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
|
|
|
|
|
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="bg-card rounded-lg p-6 shadow-md">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground mb-2">Gerenciamento de Laudo</h1>
|
|
<p className="text-muted-foreground">Nesta seção você pode gerenciar todos os laudos gerados.</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => router.push('/laudos-editor')}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Novo Laudo
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-card rounded-lg shadow-md">
|
|
<div className="flex border-b border-border">
|
|
<button
|
|
onClick={() => setActiveTab("descobrir")}
|
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === "descobrir"
|
|
? "border-blue-500 text-blue-600"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
A descobrir
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filtros */}
|
|
<div className="p-4 border-b border-border">
|
|
<div className="flex flex-wrap items-start gap-4">
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
{/* Search input integrado com busca por ID */}
|
|
<SearchBox />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 mt-0">
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<CalendarIcon className="w-4 h-4" />
|
|
<Input type="date" value={startDate ?? ''} onChange={(e) => { setStartDate(e.target.value); setSelectedRange('custom'); }} className="p-1 text-sm h-10" />
|
|
<span className="inline-flex items-center px-1 text-sm">-</span>
|
|
<Input type="date" value={endDate ?? ''} onChange={(e) => { setEndDate(e.target.value); setSelectedRange('custom'); }} className="p-1 text-sm h-10" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 items-center mt-0">
|
|
{/* date range buttons: Semana / Mês */}
|
|
<DateRangeButtons />
|
|
</div>
|
|
|
|
{/* Filtros e pesquisa removidos por solicitação */}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabela para desktop e cards empilháveis para mobile */}
|
|
<div>
|
|
{/* Desktop / tablet (md+) - tabela com scroll horizontal */}
|
|
<div className="hidden md:block overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Pedido</TableHead>
|
|
<TableHead>Data</TableHead>
|
|
<TableHead>Prazo</TableHead>
|
|
<TableHead>Paciente</TableHead>
|
|
<TableHead>Executante/Solicitante</TableHead>
|
|
<TableHead>Exame/Classificação</TableHead>
|
|
<TableHead>Ação</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sortedLaudos.map((laudo, idx) => (
|
|
<TableRow key={`${(laudo?.id ?? laudo?.order_number ?? getReportPatientId(laudo) ?? 'laudo')}-${idx}`}>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
{laudo.urgente && (
|
|
<div className="w-2 h-2 rounded-full bg-red-500"></div>
|
|
)}
|
|
<span className="font-mono text-sm">
|
|
{getReportPatientName(laudo) || laudo.order_number || getShortId(laudo.id)}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="text-sm">
|
|
<div>{formatReportDate(getReportDate(laudo))}</div>
|
|
<div className="text-xs text-muted-foreground">{laudo?.hora || new Date(laudo?.data || laudo?.created_at || laudo?.due_at || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="text-sm">
|
|
<div>{(laudo?.prazo ?? laudo?.due_at) ? formatReportDate(laudo?.due_at ?? laudo?.prazo) : '-'}</div>
|
|
<div className="text-xs text-muted-foreground">{
|
|
(() => {
|
|
// prefer explicit fields
|
|
const explicit = laudo?.prazo_hora ?? laudo?.due_time ?? laudo?.hora ?? null;
|
|
if (explicit) return explicit;
|
|
// fallback: try to parse due_at / prazo datetime and extract time
|
|
const due = laudo?.due_at ?? laudo?.prazo ?? laudo?.dueDate ?? laudo?.data ?? null;
|
|
if (!due) return '-';
|
|
try {
|
|
const d = new Date(due);
|
|
if (isNaN(d.getTime())) return '-';
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
} catch (e) {
|
|
return '-';
|
|
}
|
|
})()
|
|
}</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="text-sm">
|
|
<div className="flex items-center gap-1">
|
|
<User className="w-3 h-3" />
|
|
</div>
|
|
<div className="font-medium">{getReportPatientName(laudo) || '—'}</div>
|
|
<div className="text-xs text-muted-foreground">{getReportPatientCpf(laudo) ? `CPF: ${getReportPatientCpf(laudo)}` : ''}</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm">{
|
|
(() => {
|
|
const possibleName = laudo.requested_by_name ?? laudo.requester_name ?? laudo.requestedByName ?? laudo.executante_name ?? laudo.executante ?? laudo.executante_name ?? laudo.executante;
|
|
if (possibleName && typeof possibleName === 'string' && possibleName.trim().length) return possibleName;
|
|
const possibleId = (laudo.requested_by ?? laudo.created_by ?? laudo.executante ?? laudo.requestedBy ?? laudo.createdBy) || '';
|
|
if (possibleId && user?.id && possibleId === user.id) return (profileData as any)?.nome || user?.name || possibleId;
|
|
return possibleName || possibleId || '-';
|
|
})()
|
|
}</TableCell>
|
|
<TableCell className="text-sm">{getReportExam(laudo) || "-"}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
router.push(`/laudos/${laudo.id}`);
|
|
}}
|
|
className="flex items-center gap-1 hover-primary-blue"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
Ver Laudo
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => {
|
|
router.push(`/laudos/${laudo.id}/editar`);
|
|
}}
|
|
className="flex items-center gap-1 bg-green-600 hover:bg-green-700 text-white"
|
|
title="Editar laudo para este paciente"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
Editar Laudo
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Mobile - cards empilháveis */}
|
|
<div className="md:hidden space-y-3">
|
|
{sortedLaudos.map((laudo, idx) => (
|
|
<div key={`${(laudo?.id ?? laudo?.order_number ?? getReportPatientId(laudo) ?? 'laudo-mobile')}-${idx}`} className="bg-card p-4 rounded-lg border border-border shadow-sm">
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">{getReportExam(laudo) || '-'}</div>
|
|
<div className="text-xs text-muted-foreground">{formatReportDate(getReportDate(laudo))} {laudo?.hora ? `• ${laudo.hora}` : ''}</div>
|
|
</div>
|
|
<div className="ml-3 text-xs font-mono text-muted-foreground">{getReportPatientName(laudo) ? getShortId(laudo.id) : ''}</div>
|
|
</div>
|
|
<div className="mt-2">
|
|
<div className="font-semibold">{getReportPatientName(laudo) || '—'}</div>
|
|
<div className="text-xs text-muted-foreground">{getReportPatientCpf(laudo) ? `CPF: ${getReportPatientCpf(laudo)}` : ''}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end ml-4">
|
|
<div className="text-sm">{
|
|
(() => {
|
|
const possibleName = laudo.requested_by_name ?? laudo.requester_name ?? laudo.requestedByName ?? laudo.executante_name ?? laudo.executante ?? laudo.executante_name ?? laudo.executante;
|
|
if (possibleName && typeof possibleName === 'string' && possibleName.trim().length) return possibleName;
|
|
const possibleId = (laudo.requested_by ?? laudo.created_by ?? laudo.executante ?? laudo.requestedBy ?? laudo.createdBy) || '';
|
|
if (possibleId && user?.id && possibleId === user.id) return (profileData as any)?.nome || user?.name || possibleId;
|
|
return possibleName || possibleId || '-';
|
|
})()
|
|
}</div>
|
|
<div className="flex gap-2 mt-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
router.push(`/laudos/${laudo.id}`);
|
|
}}
|
|
className="flex items-center gap-1"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => {
|
|
router.push(`/laudos/${laudo.id}/editar`);
|
|
}}
|
|
className="flex items-center gap-1 bg-green-600 hover:bg-green-700 text-white"
|
|
title="Editar laudo"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Visualizador de Laudo */}
|
|
{isViewing && laudoSelecionado && (
|
|
<LaudoViewer laudo={laudoSelecionado} onClose={() => setIsViewing(false)} />
|
|
)}
|
|
|
|
{/* Editor para Novo Laudo */}
|
|
{isCreatingNew && (
|
|
<LaudoEditor
|
|
pacientes={pacientesDisponiveis}
|
|
onClose={() => setIsCreatingNew(false)}
|
|
isNewLaudo={true}
|
|
createNewReport={createNewReport}
|
|
updateExistingReport={updateExistingReport}
|
|
reloadReports={loadReports}
|
|
onSaved={async (r:any) => {
|
|
try {
|
|
// If report has an id, fetch full report and open viewer
|
|
if (r && (r.id || r.order_number)) {
|
|
const id = r.id ?? r.order_number;
|
|
const full = await loadReportById(String(id));
|
|
await ensurePaciente(full);
|
|
// prepend to laudos list so it appears immediately
|
|
setLaudos(prev => [full, ...(prev || [])]);
|
|
setLaudoSelecionado(full);
|
|
setIsViewing(true);
|
|
} else {
|
|
setLaudoSelecionado(r);
|
|
setIsViewing(true);
|
|
}
|
|
// refresh global reports list too
|
|
try { await loadReports(); } catch {}
|
|
} catch (e) {
|
|
// fallback: open what we have
|
|
setLaudoSelecionado(r);
|
|
setIsViewing(true);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Editor para Paciente Específico */}
|
|
{isEditingForPatient && selectedPatientForLaudo && (
|
|
<LaudoEditor
|
|
pacientes={[selectedPatientForLaudo.paciente || selectedPatientForLaudo]}
|
|
laudo={selectedPatientForLaudo}
|
|
onClose={onClosePatientEditor || (() => {})}
|
|
isNewLaudo={!selectedPatientForLaudo?.id}
|
|
preSelectedPatient={selectedPatientForLaudo.paciente || selectedPatientForLaudo}
|
|
createNewReport={createNewReport}
|
|
updateExistingReport={updateExistingReport}
|
|
reloadReports={loadReports}
|
|
onSaved={async (r:any) => {
|
|
try {
|
|
if (r && (r.id || r.order_number)) {
|
|
const id = r.id ?? r.order_number;
|
|
const full = await loadReportById(String(id));
|
|
await ensurePaciente(full);
|
|
setLaudos(prev => [full, ...(prev || [])]);
|
|
setLaudoSelecionado(full);
|
|
setIsViewing(true);
|
|
} else {
|
|
setLaudoSelecionado(r);
|
|
setIsViewing(true);
|
|
}
|
|
try { await loadReports(); } catch {}
|
|
} catch (e) {
|
|
setLaudoSelecionado(r);
|
|
setIsViewing(true);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Visualizador de Laudo (somente leitura)
|
|
function LaudoViewer({ laudo, onClose }: { laudo: any; onClose: () => void }) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
|
<div className="bg-background rounded-lg shadow-xl w-full h-full md:rounded-lg md:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-foreground">Visualizar Laudo</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Paciente: {getPatientName(laudo?.paciente) || getPatientName(laudo) || '—'} | CPF: {getReportPatientCpf(laudo) ?? laudo?.patient_cpf ?? '-'} | {laudo?.especialidade ?? laudo?.exame ?? '-'}
|
|
</p>
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<div className="max-w-2xl mx-auto bg-background border border-border rounded-lg p-6 shadow-sm">
|
|
{/* Header do Laudo */}
|
|
<div className="text-center mb-6">
|
|
<h2 className="text-lg font-bold">LAUDO MÉDICO - {(laudo.especialidade ?? laudo.exame ?? '').toString().toUpperCase()}</h2>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Data: {formatReportDate(getReportDate(laudo))}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Dados do Paciente */}
|
|
<div className="mb-6 p-4 bg-muted rounded">
|
|
<h3 className="font-semibold mb-2">Dados do Paciente:</h3>
|
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
<p><strong>Nome:</strong> {getPatientName(laudo?.paciente) || getPatientName(laudo) || '-'}</p>
|
|
<p><strong>CPF:</strong> {getPatientCpf(laudo?.paciente) ?? laudo?.patient_cpf ?? '-'}</p>
|
|
<p><strong>Idade:</strong> {getPatientAge(laudo?.paciente) ? `${getPatientAge(laudo?.paciente)} anos` : (getPatientAge(laudo) ? `${getPatientAge(laudo)} anos` : '-')}</p>
|
|
<p><strong>Sexo:</strong> {getPatientSex(laudo?.paciente) ?? getPatientSex(laudo) ?? '-'}</p>
|
|
<p><strong>CID:</strong> {laudo?.cid ?? laudo?.cid_code ?? '-'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Conteúdo do Laudo */}
|
|
<div className="mb-6">
|
|
<div
|
|
className="prose prose-sm max-w-none"
|
|
dangerouslySetInnerHTML={{
|
|
__html: ((laudo.conteudo ?? laudo.content_html ?? laudo.contentHtml ?? laudo.content) || '').toString().replace(/\n/g, '<br>')
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Exame */}
|
|
{((laudo.exame ?? laudo.exam ?? laudo.especialidade ?? laudo.report_type) || '').toString().length > 0 && (
|
|
<div className="mb-4">
|
|
<h4 className="font-semibold mb-1">Exame / Especialidade:</h4>
|
|
<p className="text-sm">{laudo.exame ?? laudo.exam ?? laudo.especialidade ?? laudo.report_type}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Diagnóstico */}
|
|
{((laudo.diagnostico ?? laudo.diagnosis) || '').toString().length > 0 && (
|
|
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/20 rounded">
|
|
<h4 className="font-semibold mb-1">Diagnóstico:</h4>
|
|
<p className="text-sm font-bold">{laudo.diagnostico ?? laudo.diagnosis}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conclusão */}
|
|
{((laudo.conclusao ?? laudo.conclusion) || '').toString().length > 0 && (
|
|
<div className="mb-6 p-3 bg-green-50 dark:bg-green-950/20 rounded">
|
|
<h4 className="font-semibold mb-1">Conclusão:</h4>
|
|
<p className="text-sm font-bold">{laudo.conclusao ?? laudo.conclusion}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Diagnóstico e Conclusão */}
|
|
{laudo.diagnostico && (
|
|
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/20 rounded">
|
|
<h4 className="font-semibold mb-1" style={{ color: '#EF4444' }}>Diagnóstico:</h4>
|
|
<p className="text-sm font-bold" style={{ color: '#EF4444' }}>{laudo.diagnostico}</p>
|
|
</div>
|
|
)}
|
|
|
|
{laudo.conclusao && (
|
|
<div className="mb-6 p-3 bg-green-50 dark:bg-green-950/20 rounded">
|
|
<h4 className="font-semibold mb-1" style={{ color: '#EF4444' }}>Conclusão:</h4>
|
|
<p className="text-sm font-bold" style={{ color: '#EF4444' }}>{laudo.conclusao}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Assinatura */}
|
|
<div className="mt-8 text-center border-t pt-4">
|
|
<div className="h-16 mb-2"></div>
|
|
{(() => {
|
|
const signatureName = laudo?.created_by_name ?? laudo?.createdByName ?? ((laudo?.created_by && user?.id && laudo.created_by === user.id) ? profileData.nome : (laudo?.created_by_name || profileData.nome));
|
|
return (
|
|
<>
|
|
<p className="text-sm font-semibold">{signatureName}</p>
|
|
<p className="text-xs text-muted-foreground">{profileData.crm ? `CRM: ${String(profileData.crm).replace(/^(?:CRM\s*)+/i, '').trim()}` : 'CRM não informado'}{laudo.especialidade ? ` - ${laudo.especialidade}` : ''}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">Data: {formatReportDate(getReportDate(laudo))}</p>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-border bg-muted/20">
|
|
<div className="flex items-center justify-end">
|
|
<Button onClick={onClose}>
|
|
Fechar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Editor de Laudo Avançado (para novos laudos)
|
|
function LaudoEditor({ pacientes, laudo, onClose, isNewLaudo, preSelectedPatient, createNewReport, updateExistingReport, reloadReports, onSaved }: { pacientes?: any[]; laudo?: any; onClose: () => void; isNewLaudo?: boolean; preSelectedPatient?: any; createNewReport?: (data: any) => Promise<any>; updateExistingReport?: (id: string, data: any) => Promise<any>; reloadReports?: () => Promise<void>; onSaved?: (r:any) => void }) {
|
|
const { toast } = useToast();
|
|
const [activeTab, setActiveTab] = useState("editor");
|
|
// Initialize content checking all possible field names
|
|
const initialContent = laudo?.conteudo ?? laudo?.content_html ?? laudo?.contentHtml ?? laudo?.content ?? "";
|
|
console.log('[LaudoEditor] Initializing content - laudo:', laudo, 'initialContent length:', initialContent?.length, 'fields:', {
|
|
conteudo: laudo?.conteudo,
|
|
content_html: laudo?.content_html,
|
|
contentHtml: laudo?.contentHtml,
|
|
content: laudo?.content
|
|
});
|
|
const [content, setContent] = useState(initialContent);
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
const [pacienteSelecionado, setPacienteSelecionado] = useState<any>(preSelectedPatient || null);
|
|
const [listaPacientes, setListaPacientes] = useState<any[]>([]);
|
|
// Novo: campos para solicitante e prazo
|
|
// solicitanteId será enviado ao backend (sempre o id do usuário logado)
|
|
const [solicitanteId, setSolicitanteId] = useState<string>(user?.id || "");
|
|
// displaySolicitante é apenas para exibição (nome do usuário) e NÃO é enviado ao backend
|
|
// Prefer profileData.nome (nome do médico carregado) — cai back para user.name ou email
|
|
const displaySolicitante = ((profileData as any) && ((profileData as any).nome || (profileData as any).nome_social)) || user?.name || (user?.profile as any)?.full_name || user?.email || '';
|
|
const [prazoDate, setPrazoDate] = useState<string>("");
|
|
const [prazoTime, setPrazoTime] = useState<string>("");
|
|
|
|
// Pega token do usuário logado (passado explicitamente para listarPacientes)
|
|
const { token } = useAuth();
|
|
|
|
// Carregar pacientes reais do Supabase ao abrir o modal ou quando o token mudar
|
|
useEffect(() => {
|
|
async function fetchPacientes() {
|
|
try {
|
|
if (!token) {
|
|
setListaPacientes([]);
|
|
return;
|
|
}
|
|
const pacientes = await listarPacientes();
|
|
setListaPacientes(pacientes || []);
|
|
} catch (err) {
|
|
console.warn('Erro ao carregar pacientes:', err);
|
|
setListaPacientes([]);
|
|
}
|
|
}
|
|
fetchPacientes();
|
|
}, [token]);
|
|
const [campos, setCampos] = useState({
|
|
cid: laudo?.cid || "",
|
|
diagnostico: laudo?.diagnostico || "",
|
|
conclusao: laudo?.conclusao || "",
|
|
exame: laudo?.exame || "",
|
|
especialidade: laudo?.especialidade || "",
|
|
mostrarData: true,
|
|
mostrarAssinatura: true
|
|
});
|
|
const [imagens, setImagens] = useState<any[]>([]);
|
|
const [templates] = useState([
|
|
"Exame normal, sem alterações significativas",
|
|
"Paciente em acompanhamento ambulatorial",
|
|
"Recomenda-se retorno em 30 dias",
|
|
"Alterações compatíveis com processo inflamatório",
|
|
"Resultado dentro dos parâmetros de normalidade",
|
|
"Recomendo seguimento com especialista"
|
|
]);
|
|
|
|
|
|
const sigCanvasRef = useRef<any>(null);
|
|
|
|
// Estado para imagem da assinatura
|
|
const [assinaturaImg, setAssinaturaImg] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!sigCanvasRef.current) return;
|
|
const handleEnd = () => {
|
|
const url = sigCanvasRef.current.getTrimmedCanvas().toDataURL('image/png');
|
|
setAssinaturaImg(url);
|
|
};
|
|
const canvas = sigCanvasRef.current;
|
|
if (canvas && canvas.canvas) {
|
|
canvas.canvas.addEventListener('mouseup', handleEnd);
|
|
canvas.canvas.addEventListener('touchend', handleEnd);
|
|
}
|
|
return () => {
|
|
if (canvas && canvas.canvas) {
|
|
canvas.canvas.removeEventListener('mouseup', handleEnd);
|
|
canvas.canvas.removeEventListener('touchend', handleEnd);
|
|
}
|
|
};
|
|
}, [sigCanvasRef]);
|
|
|
|
const handleClearSignature = () => {
|
|
if (sigCanvasRef.current) {
|
|
sigCanvasRef.current.clear();
|
|
}
|
|
setAssinaturaImg(null);
|
|
};
|
|
|
|
// Carregar dados do laudo existente quando disponível (mais robusto: suporta vários nomes de campo)
|
|
useEffect(() => {
|
|
if (laudo && !isNewLaudo) {
|
|
console.log('[LaudoEditor useEffect] Loading existing laudo data:', laudo);
|
|
// Conteúdo: aceita 'conteudo', 'content_html', 'contentHtml', 'content'
|
|
const contentValue = laudo.conteudo ?? laudo.content_html ?? laudo.contentHtml ?? laudo.content ?? "";
|
|
console.log('[LaudoEditor useEffect] Content value length:', contentValue?.length, 'Setting content...');
|
|
setContent(contentValue);
|
|
|
|
// Campos: use vários fallbacks
|
|
const cidValue = laudo.cid ?? laudo.cid_code ?? '';
|
|
const diagnosticoValue = laudo.diagnostico ?? laudo.diagnosis ?? '';
|
|
const conclusaoValue = laudo.conclusao ?? laudo.conclusion ?? '';
|
|
const exameValue = laudo.exame ?? laudo.exam ?? laudo.especialidade ?? '';
|
|
const especialidadeValue = laudo.especialidade ?? laudo.exame ?? laudo.exam ?? '';
|
|
const mostrarDataValue = typeof laudo.hide_date === 'boolean' ? !laudo.hide_date : true;
|
|
const mostrarAssinaturaValue = typeof laudo.hide_signature === 'boolean' ? !laudo.hide_signature : true;
|
|
|
|
setCampos({
|
|
cid: cidValue,
|
|
diagnostico: diagnosticoValue,
|
|
conclusao: conclusaoValue,
|
|
exame: exameValue,
|
|
especialidade: especialidadeValue,
|
|
mostrarData: mostrarDataValue,
|
|
mostrarAssinatura: mostrarAssinaturaValue
|
|
});
|
|
|
|
// Paciente: não sobrescrever se já existe preSelectedPatient ou pacienteSelecionado
|
|
if (!pacienteSelecionado) {
|
|
const pacienteFromLaudo = laudo.paciente ?? laudo.patient ?? null;
|
|
if (pacienteFromLaudo) {
|
|
setPacienteSelecionado(pacienteFromLaudo);
|
|
} else if (laudo.patient_id && listaPacientes && listaPacientes.length) {
|
|
const found = listaPacientes.find(p => String(p.id) === String(laudo.patient_id));
|
|
if (found) setPacienteSelecionado(found);
|
|
}
|
|
}
|
|
|
|
// preencher solicitanteId/prazo quando existe laudo (edição)
|
|
// preferimos manter o solicitanteId como o user id; se o laudo tiver requested_by que pareça um id, usamos ele
|
|
const possibleRequestedById = laudo.requested_by ?? laudo.created_by ?? null;
|
|
if (possibleRequestedById && typeof possibleRequestedById === 'string' && possibleRequestedById.length > 5) {
|
|
setSolicitanteId(possibleRequestedById);
|
|
} else {
|
|
setSolicitanteId(user?.id || "");
|
|
}
|
|
|
|
const dueRaw = laudo.due_at ?? laudo.prazo ?? laudo.dueDate ?? laudo.data ?? null;
|
|
if (dueRaw) {
|
|
try {
|
|
const d = new Date(dueRaw);
|
|
if (!isNaN(d.getTime())) {
|
|
setPrazoDate(d.toISOString().slice(0,10));
|
|
setPrazoTime(d.toTimeString().slice(0,5));
|
|
}
|
|
} catch (e) {
|
|
// ignore invalid date
|
|
}
|
|
}
|
|
|
|
// assinatura: aceitar vários campos possíveis
|
|
const sig = laudo.assinaturaImg ?? laudo.signature_image ?? laudo.signature ?? laudo.sign_image ?? null;
|
|
if (sig) setAssinaturaImg(sig);
|
|
}
|
|
}, [laudo, isNewLaudo, pacienteSelecionado, listaPacientes]);
|
|
|
|
// Histórico para desfazer/refazer
|
|
const [history, setHistory] = useState<string[]>([]);
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
|
|
// Atualiza histórico ao digitar
|
|
useEffect(() => {
|
|
if (history[historyIndex] !== content) {
|
|
const newHistory = history.slice(0, historyIndex + 1);
|
|
setHistory([...newHistory, content]);
|
|
setHistoryIndex(newHistory.length);
|
|
}
|
|
// eslint-disable-next-line
|
|
}, [content]);
|
|
|
|
const handleUndo = () => {
|
|
if (historyIndex > 0) {
|
|
setContent(history[historyIndex - 1]);
|
|
setHistoryIndex(historyIndex - 1);
|
|
}
|
|
};
|
|
const handleRedo = () => {
|
|
if (historyIndex < history.length - 1) {
|
|
setContent(history[historyIndex + 1]);
|
|
setHistoryIndex(historyIndex + 1);
|
|
}
|
|
};
|
|
|
|
// Formatação avançada
|
|
const formatText = (type: string, value?: any) => {
|
|
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
|
if (!textarea) return;
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const selectedText = textarea.value.substring(start, end);
|
|
let formattedText = "";
|
|
switch(type) {
|
|
case "bold":
|
|
formattedText = selectedText ? `**${selectedText}**` : "**texto em negrito**";
|
|
break;
|
|
case "italic":
|
|
formattedText = selectedText ? `*${selectedText}*` : "*texto em itálico*";
|
|
break;
|
|
case "underline":
|
|
formattedText = selectedText ? `__${selectedText}__` : "__texto sublinhado__";
|
|
break;
|
|
case "list-ul":
|
|
formattedText = selectedText ? selectedText.split('\n').map(l => `• ${l}`).join('\n') : "• item da lista";
|
|
break;
|
|
case "list-ol":
|
|
formattedText = selectedText ? selectedText.split('\n').map((l,i) => `${i+1}. ${l}`).join('\n') : "1. item da lista";
|
|
break;
|
|
case "indent":
|
|
formattedText = selectedText ? selectedText.split('\n').map(l => ` ${l}`).join('\n') : " ";
|
|
break;
|
|
case "outdent":
|
|
formattedText = selectedText ? selectedText.split('\n').map(l => l.replace(/^\s{1,4}/, "")).join('\n') : "";
|
|
break;
|
|
case "align-left":
|
|
formattedText = selectedText ? `[left]${selectedText}[/left]` : "[left]Texto à esquerda[/left]";
|
|
break;
|
|
case "align-center":
|
|
formattedText = selectedText ? `[center]${selectedText}[/center]` : "[center]Texto centralizado[/center]";
|
|
break;
|
|
case "align-right":
|
|
formattedText = selectedText ? `[right]${selectedText}[/right]` : "[right]Texto à direita[/right]";
|
|
break;
|
|
case "align-justify":
|
|
formattedText = selectedText ? `[justify]${selectedText}[/justify]` : "[justify]Texto justificado[/justify]";
|
|
break;
|
|
case "font-size":
|
|
formattedText = selectedText ? `[size=${value}]${selectedText}[/size]` : `[size=${value}]Texto tamanho ${value}[/size]`;
|
|
break;
|
|
case "font-family":
|
|
formattedText = selectedText ? `[font=${value}]${selectedText}[/font]` : `[font=${value}]${value}[/font]`;
|
|
break;
|
|
case "font-color":
|
|
formattedText = selectedText ? `[color=${value}]${selectedText}[/color]` : `[color=${value}]${value}[/color]`;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
const newText = textarea.value.substring(0, start) + formattedText + textarea.value.substring(end);
|
|
setContent(newText);
|
|
};
|
|
|
|
const insertTemplate = (template: string) => {
|
|
setContent((prev: string) => prev ? `${prev}\n\n${template}` : template);
|
|
};
|
|
|
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
files.forEach(file => {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
setImagens(prev => [...prev, {
|
|
id: Date.now() + Math.random(),
|
|
name: file.name,
|
|
url: e.target?.result,
|
|
type: file.type
|
|
}]);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
const processContent = (content: string) => {
|
|
return content
|
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
.replace(/__(.*?)__/g, '<u>$1</u>')
|
|
.replace(/\[left\]([\s\S]*?)\[\/left\]/g, '<div style="text-align:left">$1</div>')
|
|
.replace(/\[center\]([\s\S]*?)\[\/center\]/g, '<div style="text-align:center">$1</div>')
|
|
.replace(/\[right\]([\s\S]*?)\[\/right\]/g, '<div style="text-align:right">$1</div>')
|
|
.replace(/\[justify\]([\s\S]*?)\[\/justify\]/g, '<div style="text-align:justify">$1</div>')
|
|
.replace(/\[size=(\d+)\]([\s\S]*?)\[\/size\]/g, '<span style="font-size:$1px">$2</span>')
|
|
.replace(/\[font=([^\]]+)\]([\s\S]*?)\[\/font\]/g, '<span style="font-family:$1">$2</span>')
|
|
.replace(/\[color=([^\]]+)\]([\s\S]*?)\[\/color\]/g, '<span style="color:$1">$2</span>')
|
|
.replace(/{{sexo_paciente}}/g, pacienteSelecionado?.sexo || laudo?.paciente?.sexo || '[SEXO]')
|
|
.replace(/{{diagnostico}}/g, campos.diagnostico || '[DIAGNÓSTICO]')
|
|
.replace(/{{conclusao}}/g, campos.conclusao || '[CONCLUSÃO]')
|
|
.replace(/\n/g, '<br>');
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-2 sm:p-4">
|
|
<div className="bg-background rounded-none md:rounded-lg shadow-xl w-full h-full md:h-auto md:max-w-6xl max-h-[95vh] overflow-hidden flex flex-col">
|
|
{/* Header */}
|
|
<div className="border-b border-border">
|
|
<div className="flex items-center justify-between p-2 sm:p-4">
|
|
<div>
|
|
<h2 className="text-base sm:text-xl font-bold text-foreground">
|
|
{isNewLaudo ? "Novo Laudo Médico" : "Editar Laudo Existente"}
|
|
</h2>
|
|
{isNewLaudo ? (
|
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
|
Crie um novo laudo selecionando um paciente
|
|
</p>
|
|
) : (
|
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
|
Paciente: {getPatientName(pacienteSelecionado) || getPatientName(laudo?.paciente) || getPatientName(laudo) || '-'} | CPF: {getReportPatientCpf(laudo) ?? laudo?.patient_cpf ?? '-'} | {laudo?.especialidade}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0 flex-shrink-0">
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Seleção de Paciente (apenas para novos laudos) */}
|
|
{isNewLaudo && (
|
|
<div className="px-2 sm:px-4 pb-2 sm:pb-4">
|
|
{!pacienteSelecionado ? (
|
|
<div className="bg-muted border border-border rounded-lg p-2 sm:p-4">
|
|
<Label htmlFor="select-paciente" className="text-xs sm:text-sm font-medium mb-2 block">
|
|
Selecionar Paciente *
|
|
</Label>
|
|
<Select onValueChange={(value) => {
|
|
const paciente = listaPacientes.find(p => p.id === value);
|
|
if (paciente) setPacienteSelecionado(paciente);
|
|
}}>
|
|
<SelectTrigger className="w-full text-xs sm:text-sm">
|
|
<SelectValue placeholder="Escolha um paciente para criar o laudo" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{listaPacientes.map((paciente) => (
|
|
<SelectItem key={paciente.id} value={paciente.id}>
|
|
{paciente.full_name} {paciente.cpf ? `- CPF: ${paciente.cpf}` : ''}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
) : (
|
|
<div className="bg-primary/10 border border-primary/20 rounded-lg p-2 sm:p-3 flex items-center justify-between gap-2">
|
|
<div>
|
|
<div className="font-semibold text-primary text-sm sm:text-base">{getPatientName(pacienteSelecionado)}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{getPatientCpf(pacienteSelecionado) ? `CPF: ${getPatientCpf(pacienteSelecionado)} | ` : ''}
|
|
{pacienteSelecionado?.birth_date ? `Nascimento: ${pacienteSelecionado.birth_date}` : (getPatientAge(pacienteSelecionado) ? `Idade: ${getPatientAge(pacienteSelecionado)} anos` : '')}
|
|
{getPatientSex(pacienteSelecionado) ? ` | Sexo: ${getPatientSex(pacienteSelecionado)}` : ''}
|
|
</div>
|
|
</div>
|
|
{!preSelectedPatient && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPacienteSelecionado(null)}
|
|
className="text-xs flex-shrink-0"
|
|
>
|
|
Trocar
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Novos campos: Solicitante e Prazo */}
|
|
<div className="mt-2 sm:mt-3 grid grid-cols-1 md:grid-cols-2 gap-2 sm:gap-3">
|
|
<div>
|
|
<Label htmlFor="solicitante" className="text-xs sm:text-sm">Solicitante</Label>
|
|
{/* Mostrar o nome do usuário logado de forma estática (não editável) */}
|
|
<Input id="solicitante" value={displaySolicitante} readOnly disabled className="text-xs sm:text-sm h-8 sm:h-10" />
|
|
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="prazoDate" className="text-xs sm:text-sm">Prazo do Laudo</Label>
|
|
<div className="flex gap-1 sm:gap-2">
|
|
<Input id="prazoDate" type="date" value={prazoDate} onChange={(e) => setPrazoDate(e.target.value)} className="text-xs sm:text-sm h-8 sm:h-10" />
|
|
<Input id="prazoTime" type="time" value={prazoTime} onChange={(e) => setPrazoTime(e.target.value)} className="text-xs sm:text-sm h-8 sm:h-10" />
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">Defina a data e hora do prazo (opcional).</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-border overflow-x-auto">
|
|
{/* Informações tab removed - only Editor/Imagens/Campos/Pré-visualização remain */}
|
|
<button
|
|
onClick={() => setActiveTab("editor")}
|
|
className={`px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
|
activeTab === "editor"
|
|
? "border-blue-500 text-blue-600"
|
|
: "border-transparent text-gray-600 dark:text-muted-foreground dark:hover:text-foreground dark:hover:bg-blue-900"
|
|
}`}
|
|
style={{
|
|
backgroundColor: activeTab === "editor" ? undefined : "transparent"
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (activeTab !== "editor") {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
e.currentTarget.style.color = "#4B5563";
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (activeTab !== "editor") {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
e.currentTarget.style.color = "#4B5563";
|
|
}
|
|
}}
|
|
>
|
|
<FileText className="w-4 h-4 inline mr-1" />
|
|
Editor
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("campos")}
|
|
className={`px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
|
activeTab === "campos"
|
|
? "border-blue-500 text-blue-600"
|
|
: "border-transparent text-gray-600 dark:text-muted-foreground dark:hover:text-foreground dark:hover:bg-blue-900"
|
|
}`}
|
|
style={{
|
|
backgroundColor: activeTab === "campos" ? undefined : "transparent"
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (activeTab !== "campos") {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
e.currentTarget.style.color = "#4B5563";
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (activeTab !== "campos") {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
e.currentTarget.style.color = "#4B5563";
|
|
}
|
|
}}
|
|
>
|
|
<Settings className="w-4 h-4 inline mr-1" />
|
|
Campos
|
|
</button>
|
|
<button
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
className={`px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
|
showPreview
|
|
? "border-green-500 text-green-600"
|
|
: "border-transparent text-gray-600 dark:text-muted-foreground dark:hover:text-foreground dark:hover:bg-blue-900"
|
|
}`}
|
|
style={{
|
|
backgroundColor: !showPreview ? "transparent" : undefined
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!showPreview) {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
e.currentTarget.style.color = "#4B5563";
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!showPreview) {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
e.currentTarget.style.color = "#4B5563";
|
|
}
|
|
}}
|
|
>
|
|
<Eye className="w-4 h-4 inline mr-1" />
|
|
{showPreview ? "Ocultar" : "Pré-visualização"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-hidden flex flex-row">
|
|
{/* Left Panel - Editor */}
|
|
<div className={`flex flex-col overflow-hidden transition-all ${showPreview ? 'w-1/2 sm:w-3/5' : 'w-full'}`}>
|
|
{/* 'Informações' section removed to keep editor-only experience */}
|
|
|
|
{activeTab === "editor" && (
|
|
<div className="flex-1 flex flex-col">
|
|
{/* Toolbar */}
|
|
<div className="p-1.5 sm:p-3 border-b border-border overflow-x-auto">
|
|
<div className="flex flex-wrap gap-1 sm:gap-2 items-center">
|
|
{/* Tamanho da fonte */}
|
|
<label className="text-xs mr-0.5 sm:mr-1 whitespace-nowrap">Tamanho</label>
|
|
<input
|
|
type="number"
|
|
min={8}
|
|
max={32}
|
|
defaultValue={14}
|
|
onBlur={e => formatText('font-size', e.target.value)}
|
|
className="w-12 border rounded px-1 py-0.5 text-xs mr-1 sm:mr-2"
|
|
title="Tamanho da fonte"
|
|
/>
|
|
{/* Família da fonte */}
|
|
<label className="text-xs mr-0.5 sm:mr-1 whitespace-nowrap">Fonte</label>
|
|
<select
|
|
defaultValue={'Arial'}
|
|
onBlur={e => formatText('font-family', e.target.value)}
|
|
className="border rounded px-1 py-0.5 text-xs mr-1 sm:mr-2 bg-white text-gray-900 dark:bg-gray-800 dark:text-white"
|
|
style={{ minWidth: 100, fontWeight: 500 }}
|
|
title="Família da fonte"
|
|
>
|
|
<option value="Arial" style={{ color: '#222', background: '#fff', fontWeight: 600 }}>Arial</option>
|
|
<option value="Helvetica" style={{ color: '#222', background: '#fff', fontWeight: 600 }}>Helvetica</option>
|
|
<option value="Times New Roman" style={{ color: '#222', background: '#fff', fontWeight: 600 }}>Times New Roman</option>
|
|
<option value="Courier New" style={{ color: '#222', background: '#fff', fontWeight: 600 }}>Courier New</option>
|
|
<option value="Verdana" style={{ color: '#222', background: '#fff', fontWeight: 600 }}>Verdana</option>
|
|
<option value="Georgia" style={{ color: '#222', background: '#fff', fontWeight: 600 }}>Georgia</option>
|
|
</select>
|
|
{/* Cor da fonte */}
|
|
<label className="text-xs mr-1">Cor</label>
|
|
<input
|
|
type="color"
|
|
defaultValue="#222222"
|
|
onBlur={e => formatText('font-color', e.target.value)}
|
|
className="w-6 h-6 border rounded mr-2"
|
|
title="Cor da fonte"
|
|
/>
|
|
{/* Alinhamento */}
|
|
<Button variant="outline" size="sm" onClick={() => formatText('align-left')} title="Alinhar à esquerda" className="px-1 hover-primary-blue-soft"><svg width="16" height="16" fill="none"><rect x="2" y="4" width="12" height="2" rx="1" fill="currentColor"/><rect x="2" y="7" width="8" height="2" rx="1" fill="currentColor"/><rect x="2" y="10" width="10" height="2" rx="1" fill="currentColor"/></svg></Button>
|
|
<Button variant="outline" size="sm" onClick={() => formatText('align-center')} title="Centralizar" className="px-1 hover-primary-blue-soft"><svg width="16" height="16" fill="none"><rect x="4" y="4" width="8" height="2" rx="1" fill="currentColor"/><rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor"/><rect x="3" y="10" width="10" height="2" rx="1" fill="currentColor"/></svg></Button>
|
|
<Button variant="outline" size="sm" onClick={() => formatText('align-right')} title="Alinhar à direita" className="px-1 hover-primary-blue-soft"><svg width="16" height="16" fill="none"><rect x="6" y="4" width="8" height="2" rx="1" fill="currentColor"/><rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor"/><rect x="4" y="10" width="10" height="2" rx="1" fill="currentColor"/></svg></Button>
|
|
<Button variant="outline" size="sm" onClick={() => formatText('align-justify')} title="Justificar" className="px-1 hover-primary-blue-soft"><svg width="16" height="16" fill="none"><rect x="2" y="4" width="12" height="2" rx="1" fill="currentColor"/><rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor"/><rect x="2" y="10" width="12" height="2" rx="1" fill="currentColor"/></svg></Button>
|
|
{/* Listas */}
|
|
<Button variant="outline" size="sm" onClick={() => formatText('list-ol')} title="Lista numerada" className="px-1 hover-primary-blue-soft">1.</Button>
|
|
<Button variant="outline" size="sm" onClick={() => formatText('list-ul')} title="Lista com marcadores" className="px-1 hover-primary-blue-soft">•</Button>
|
|
{/* Recuo */}
|
|
<Button variant="outline" size="sm" onClick={() => formatText('indent')} title="Aumentar recuo" className="px-1 hover-primary-blue-soft">→</Button>
|
|
<Button variant="outline" size="sm" onClick={() => formatText('outdent')} title="Diminuir recuo" className="px-1 hover-primary-blue-soft">←</Button>
|
|
{/* Desfazer/Refazer */}
|
|
<Button variant="outline" size="sm" onClick={handleUndo} title="Desfazer" className="px-1 hover:bg-primary/10 hover:text-primary">↺</Button>
|
|
<div className="flex flex-wrap gap-1">
|
|
{templates.map((template, idx) => (
|
|
<Button
|
|
key={idx}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs h-auto p-1 px-2 hover:bg-primary/10 hover:text-primary"
|
|
onClick={() => insertTemplate(template)}
|
|
>
|
|
{template.substring(0, 30)}...
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Editor */}
|
|
<div className="flex-1 p-2 sm:p-4 overflow-y-auto scrollbar-thin scrollbar-thumb-blue-500 scrollbar-track-blue-100 dark:scrollbar-thumb-blue-400 dark:scrollbar-track-blue-950 max-h-[calc(100vh-500px)] sm:max-h-[calc(100vh-400px)]">
|
|
<Textarea
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
placeholder="Digite o conteúdo do laudo aqui. Use ** para negrito, * para itálico, <u></u> para sublinhado."
|
|
className="w-full min-h-[200px] sm:min-h-[300px] resize-none text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === "campos" && (
|
|
<div className="flex-1 p-2 sm:p-4 space-y-2 sm:space-y-4 overflow-y-auto">
|
|
<div>
|
|
<Label htmlFor="cid" className="text-xs sm:text-sm">CID</Label>
|
|
<Input
|
|
id="cid"
|
|
value={campos.cid}
|
|
onChange={(e) => setCampos(prev => ({ ...prev, cid: e.target.value }))}
|
|
placeholder="Ex: M25.5, I10, etc."
|
|
className="text-xs sm:text-sm h-8 sm:h-10"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="exame" className="text-xs sm:text-sm">Exame</Label>
|
|
<Input
|
|
id="exame"
|
|
value={campos.exame}
|
|
onChange={(e) => setCampos(prev => ({ ...prev, exame: e.target.value }))}
|
|
placeholder="Exame realizado"
|
|
className="text-xs sm:text-sm h-8 sm:h-10"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="diagnostico" className="text-xs sm:text-sm">Diagnóstico</Label>
|
|
<Textarea
|
|
id="diagnostico"
|
|
value={campos.diagnostico}
|
|
onChange={(e) => setCampos(prev => ({ ...prev, diagnostico: e.target.value }))}
|
|
placeholder="Diagnóstico principal"
|
|
rows={2}
|
|
className="text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="conclusao" className="text-xs sm:text-sm">Conclusão</Label>
|
|
<Textarea
|
|
id="conclusao"
|
|
value={campos.conclusao}
|
|
onChange={(e) => setCampos(prev => ({ ...prev, conclusao: e.target.value }))}
|
|
placeholder="Conclusão do laudo"
|
|
rows={2}
|
|
className="text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 sm:space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id="mostrar-data"
|
|
checked={campos.mostrarData}
|
|
onChange={(e) => setCampos(prev => ({ ...prev, mostrarData: e.target.checked }))}
|
|
className="w-4 h-4"
|
|
/>
|
|
<Label htmlFor="mostrar-data" className="text-xs sm:text-sm">Mostrar data no laudo</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id="mostrar-assinatura"
|
|
checked={campos.mostrarAssinatura}
|
|
onChange={(e) => setCampos(prev => ({ ...prev, mostrarAssinatura: e.target.checked }))}
|
|
className="w-4 h-4"
|
|
/>
|
|
<Label htmlFor="mostrar-assinatura" className="text-xs sm:text-sm">Mostrar assinatura no laudo</Label>
|
|
</div>
|
|
</div>
|
|
{/* Assinatura Digital removida dos campos */}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Preview Panel */}
|
|
{showPreview && (
|
|
<div className="w-1/2 sm:w-2/5 border-l border-border bg-muted/20 flex flex-col overflow-hidden">
|
|
<div className="p-1 sm:p-1.5 border-b border-border flex-shrink-0">
|
|
<h3 className="font-semibold text-xs sm:text-sm text-foreground line-clamp-1">Pré-visualização</h3>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-1 sm:p-1.5">
|
|
<div className="bg-background border border-border rounded p-1 sm:p-1.5 text-xs space-y-1">
|
|
{/* Header do Laudo */}
|
|
<div className="text-center mb-1 pb-1 border-b border-border/40">
|
|
<h2 className="text-xs sm:text-sm font-bold leading-tight line-clamp-2">
|
|
LAUDO {campos.especialidade ? `- ${campos.especialidade.toUpperCase().substring(0, 15)}` : ''}
|
|
</h2>
|
|
{campos.exame && (
|
|
<p className="text-xs font-semibold mt-0.5 line-clamp-1">{campos.exame.substring(0, 30)}</p>
|
|
)}
|
|
{campos.mostrarData && (
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{new Date().toLocaleDateString('pt-BR')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Dados do Paciente - Ultra Compacto */}
|
|
{(isNewLaudo ? pacienteSelecionado : laudo?.paciente) && (
|
|
<div className="mb-1 pb-1 border-b border-border/40 space-y-0">
|
|
<div className="text-xs"><span className="font-semibold">Pac:</span> {(isNewLaudo ? getPatientName(pacienteSelecionado) : getPatientName(laudo?.paciente)).substring(0, 15)}</div>
|
|
<div className="text-xs"><span className="font-semibold">CPF:</span> {(isNewLaudo ? getPatientCpf(pacienteSelecionado) : getPatientCpf(laudo?.paciente)).substring(0, 10)}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Informações Clínicas */}
|
|
<div className="mb-1 pb-1 border-b border-border/40 space-y-0">
|
|
{campos.cid && (
|
|
<div className="text-xs"><span className="font-semibold">CID:</span> <span className="text-blue-600 dark:text-blue-400 font-semibold">{campos.cid}</span></div>
|
|
)}
|
|
{campos.diagnostico && (
|
|
<div className="text-xs line-clamp-2"><span className="font-semibold">Diag:</span> {campos.diagnostico.substring(0, 40)}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Conteúdo Principal */}
|
|
{content && (
|
|
<div className="mb-1 pb-1 border-b border-border/40">
|
|
<div
|
|
className="text-xs leading-tight [&_p]:my-0 [&_br]:mb-0 whitespace-pre-wrap line-clamp-4"
|
|
dangerouslySetInnerHTML={{
|
|
__html: processContent(content).substring(0, 150)
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conclusão */}
|
|
{campos.conclusao && (
|
|
<div className="mb-1 pb-1 border-b border-border/40">
|
|
<div className="text-xs line-clamp-2"><span className="font-semibold">Conc:</span> {campos.conclusao.substring(0, 40)}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Assinatura Digital - Compacta */}
|
|
{campos.mostrarAssinatura && (
|
|
<div className="pt-1 mt-1 border-t border-border/40 text-center">
|
|
{assinaturaImg && assinaturaImg.length > 30 ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={assinaturaImg} alt="Assinatura Digital" className="mx-auto h-6 object-contain mb-0.5" />
|
|
) : (
|
|
<div className="text-xs text-muted-foreground italic">Sem assinatura</div>
|
|
)}
|
|
<div className="border-t border-border/40 my-0.5"></div>
|
|
<p className="text-xs font-semibold leading-tight">{((profileData as any)?.nome || (profileData as any)?.nome_social || user?.name || 'Squad-20').substring(0, 20)}</p>
|
|
{(((profileData as any)?.crm) || ((user?.profile as any)?.crm)) ? (
|
|
<p className="text-xs text-muted-foreground">CRM {(((profileData as any)?.crm) || (user?.profile as any)?.crm).toString().replace(/^(?:CRM\s*)+/i, '').trim().substring(0, 10)}</p>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-2 sm:p-4 border-t border-border bg-muted/20">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
|
<div className="text-xs text-muted-foreground hidden sm:block">
|
|
Este editor permite escrever relatórios de forma livre, com formatação de texto rica.
|
|
</div>
|
|
<div className="flex gap-2 w-full sm:w-auto">
|
|
<Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm">
|
|
Cancelar
|
|
</Button>
|
|
{/* botão 'Salvar Rascunho' removido por não ser utilizado */}
|
|
<Button
|
|
variant="default"
|
|
onClick={async () => {
|
|
try {
|
|
const userId = user?.id || '00000000-0000-0000-0000-000000000001';
|
|
// compor due_at a partir dos campos de data/hora, se fornecidos
|
|
let composedDueAt = undefined;
|
|
if (prazoDate) {
|
|
// if time not provided, default to 23:59
|
|
const t = prazoTime || '23:59';
|
|
composedDueAt = new Date(`${prazoDate}T${t}:00`).toISOString();
|
|
}
|
|
|
|
const payload = {
|
|
patient_id: pacienteSelecionado?.id,
|
|
order_number: '',
|
|
exam: campos.exame || '',
|
|
diagnosis: campos.diagnostico || '',
|
|
conclusion: campos.conclusao || '',
|
|
cid_code: campos.cid || '',
|
|
content_html: content,
|
|
content_json: {},
|
|
// status intentionally omitted — não enviar 'draft'
|
|
requested_by: solicitanteId || userId,
|
|
due_at: composedDueAt ?? new Date().toISOString(),
|
|
hide_date: !campos.mostrarData,
|
|
hide_signature: !campos.mostrarAssinatura,
|
|
};
|
|
|
|
if (isNewLaudo) {
|
|
if (createNewReport) {
|
|
const created = await createNewReport(payload as any);
|
|
console.log('[LaudoEditor] Report criado:', { created, patient_id: payload.patient_id });
|
|
// ✅ Webhook agora é enviado automaticamente dentro de createNewReport() / criarRelatorio()
|
|
|
|
if (onSaved) onSaved(created);
|
|
}
|
|
} else {
|
|
// Atualizar laudo existente: confirmar e enviar apenas diff
|
|
const targetId = laudo?.id ?? laudo?.order_number ?? null;
|
|
if (!targetId) throw new Error('ID do laudo ausente, não é possível atualizar');
|
|
|
|
// Montar objeto contendo somente campos alterados
|
|
const original = laudo || {};
|
|
const candidate: any = {
|
|
patient_id: payload.patient_id,
|
|
order_number: payload.order_number,
|
|
exam: payload.exam,
|
|
diagnosis: payload.diagnosis,
|
|
conclusion: payload.conclusion,
|
|
cid_code: payload.cid_code,
|
|
content_html: payload.content_html,
|
|
// content_json intentionally left as full replacement if changed
|
|
// status omitted on purpose
|
|
requested_by: payload.requested_by,
|
|
due_at: payload.due_at,
|
|
hide_date: payload.hide_date,
|
|
hide_signature: payload.hide_signature,
|
|
};
|
|
|
|
const diff: any = {};
|
|
for (const k of Object.keys(candidate)) {
|
|
const val = candidate[k];
|
|
const origVal = original[k];
|
|
// Considerar string/undefined equivalence
|
|
if (typeof val === 'string') {
|
|
if ((origVal ?? '') !== (val ?? '')) diff[k] = val;
|
|
} else if (typeof val === 'boolean') {
|
|
if (origVal !== val) diff[k] = val;
|
|
} else if (val !== undefined && val !== null) {
|
|
if (JSON.stringify(origVal) !== JSON.stringify(val)) {
|
|
diff[k] = val;
|
|
} else {
|
|
// no change
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(diff).length === 0) {
|
|
toast({ title: 'Nada a atualizar', description: 'Nenhuma alteração detectada.', variant: 'default' });
|
|
} else {
|
|
const ok = window.confirm('Deseja realmente atualizar este laudo? As alterações serão enviadas ao servidor.');
|
|
if (!ok) return;
|
|
if (updateExistingReport) {
|
|
const updated = await updateExistingReport(String(targetId), diff as any);
|
|
if (onSaved) onSaved(updated);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (reloadReports) {
|
|
await reloadReports();
|
|
}
|
|
|
|
toast({
|
|
title: isNewLaudo ? 'Laudo criado com sucesso!' : 'Laudo atualizado com sucesso!',
|
|
description: isNewLaudo ? 'O laudo foi liberado e salvo.' : 'As alterações foram salvas.',
|
|
variant: 'default',
|
|
});
|
|
onClose();
|
|
} catch (err) {
|
|
toast({
|
|
title: isNewLaudo ? 'Erro ao criar laudo' : 'Erro ao atualizar laudo',
|
|
description: (err && typeof err === 'object' && 'message' in err) ? (err as any).message : String(err) || 'Tente novamente.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
}}
|
|
className="flex-1 sm:flex-none text-xs sm:text-sm"
|
|
>
|
|
{isNewLaudo ? "Liberar Laudo" : "Atualizar Laudo"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
const renderComunicacaoSection = () => (
|
|
<div className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6 w-full">
|
|
<h2 className="text-xl sm:text-2xl font-bold mb-4">Comunicação com o Paciente</h2>
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="patientSelect" className="text-xs sm:text-sm">Paciente *</Label>
|
|
<Select
|
|
value={commPatientId ?? ''}
|
|
onValueChange={(val: string) => {
|
|
const v = val === "__none" ? null : (val || null);
|
|
setCommPatientId(v);
|
|
setCommResponses([]);
|
|
setCommResponsesError(null);
|
|
if (!v) {
|
|
setCommPhoneNumber('');
|
|
return;
|
|
}
|
|
try {
|
|
const found = (pacientes || []).find((p: any) => String(p.id ?? p.uuid ?? p.email ?? '') === String(v));
|
|
if (found) {
|
|
setCommPhoneNumber(
|
|
found.phone_mobile ?? found.celular ?? found.telefone ?? found.phone ?? found.mobile ?? found.phone_number ?? ''
|
|
);
|
|
} else {
|
|
setCommPhoneNumber('');
|
|
}
|
|
} catch (e) {
|
|
console.warn('[ProfissionalPage] erro ao preencher telefone do paciente selecionado', e);
|
|
setCommPhoneNumber('');
|
|
}
|
|
void loadCommResponses(String(v));
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full text-xs sm:text-sm">
|
|
<SelectValue placeholder="-- nenhum --" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none">-- nenhum --</SelectItem>
|
|
{pacientes && pacientes.map((p:any) => (
|
|
<SelectItem key={String(p.id || p.uuid || p.cpf || p.email)} value={String(p.id ?? p.uuid ?? p.email ?? '')}>
|
|
{p.full_name ?? p.nome ?? p.name ?? p.email ?? String(p.id ?? p.cpf ?? '')}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phoneNumber" className="text-xs sm:text-sm">Número (phone_number)</Label>
|
|
<Input id="phoneNumber" placeholder="+5511999999999" value={commPhoneNumber} readOnly disabled className="bg-muted/50 text-xs sm:text-sm" />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="message" className="text-xs sm:text-sm">Mensagem (message)</Label>
|
|
<textarea id="message" className="w-full p-2 sm:p-3 border rounded text-xs sm:text-sm" rows={5} value={commMessage} onChange={(e) => setCommMessage(e.target.value)} />
|
|
</div>
|
|
|
|
<div className="flex justify-end mt-6">
|
|
<Button onClick={handleSave} disabled={smsSending} size="sm" className="text-xs sm:text-sm">
|
|
{smsSending ? 'Enviando...' : 'Enviar SMS'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const renderDisponibilidadesSection = () => {
|
|
// Filtrar apenas a primeira disponibilidade de cada dia da semana
|
|
const availabilityByDay = new Map<string, DoctorAvailability>();
|
|
(availabilities || []).forEach((a) => {
|
|
const day = String(a.weekday ?? '').toLowerCase();
|
|
if (!availabilityByDay.has(day)) {
|
|
availabilityByDay.set(day, a);
|
|
}
|
|
});
|
|
let filteredAvailabilities = Array.from(availabilityByDay.values());
|
|
|
|
// Ordenar por dia da semana (Segunda a Domingo)
|
|
filteredAvailabilities = filteredAvailabilities.sort((a, b) => {
|
|
const weekdayOrder: Record<string, number> = {
|
|
'segunda': 1, 'segunda-feira': 1, 'mon': 1, 'monday': 1, '1': 1,
|
|
'terca': 2, 'terça': 2, 'terça-feira': 2, 'tue': 2, 'tuesday': 2, '2': 2,
|
|
'quarta': 3, 'quarta-feira': 3, 'wed': 3, 'wednesday': 3, '3': 3,
|
|
'quinta': 4, 'quinta-feira': 4, 'thu': 4, 'thursday': 4, '4': 4,
|
|
'sexta': 5, 'sexta-feira': 5, 'fri': 5, 'friday': 5, '5': 5,
|
|
'sabado': 6, 'sábado': 6, 'sat': 6, 'saturday': 6, '6': 6,
|
|
'domingo': 7, 'dom': 7, 'sun': 7, 'sunday': 7, '0': 7, '7': 7
|
|
};
|
|
|
|
const getWeekdayOrder = (weekday: any) => {
|
|
if (typeof weekday === 'number') return weekday === 0 ? 7 : weekday;
|
|
const normalized = String(weekday).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
|
return weekdayOrder[normalized] || 999;
|
|
};
|
|
|
|
return getWeekdayOrder(a.weekday) - getWeekdayOrder(b.weekday);
|
|
});
|
|
|
|
// Filtrar apenas a primeira exceção de cada data
|
|
const exceptionByDate = new Map<string, DoctorException>();
|
|
(exceptions || []).forEach((ex) => {
|
|
// Alguns backends/versões usam nomes diferentes para a data da exceção.
|
|
// Fazemos cast para any ao verificar campos legados para satisfazer o tipo DoctorException.
|
|
const date = String(((ex as any).exception_date) ?? ((ex as any).exceptionDate) ?? ex.date ?? '');
|
|
if (!exceptionByDate.has(date)) {
|
|
exceptionByDate.set(date, ex);
|
|
}
|
|
});
|
|
const filteredExceptions = Array.from(exceptionByDate.values());
|
|
|
|
return (
|
|
<section className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6 w-full">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
|
<h2 className="text-xl sm:text-2xl font-bold">Minhas Disponibilidades</h2>
|
|
<div className="flex gap-2 w-full sm:w-auto">
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 sm:flex-initial bg-blue-600 hover:bg-blue-700 text-xs sm:text-sm"
|
|
onClick={async () => {
|
|
try {
|
|
const list = await listarDisponibilidades({ doctorId: doctorId!, active: true });
|
|
setAvailabilitiesForCreate(list || []);
|
|
setEditingAvailability(null);
|
|
setShowAvailabilityForm(true);
|
|
} catch (e) {
|
|
console.warn('Erro ao carregar disponibilidades:', e);
|
|
setAvailabilitiesForCreate([]);
|
|
setEditingAvailability(null);
|
|
setShowAvailabilityForm(true);
|
|
}
|
|
}}
|
|
>
|
|
+ Disponibilidade
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Disponibilidades */}
|
|
{availLoading ? (
|
|
<div className="text-sm text-muted-foreground p-4">Carregando disponibilidades…</div>
|
|
) : filteredAvailabilities && filteredAvailabilities.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{filteredAvailabilities.map((a) => (
|
|
<div key={String(a.id)} className="p-2 border rounded flex justify-between items-start">
|
|
<div>
|
|
<div className="font-medium">{translateWeekday(a.weekday)} • {a.start_time} — {a.end_time}</div>
|
|
<div className="text-xs text-muted-foreground">Duração: {a.slot_minutes} min • Tipo: {a.appointment_type || '—'} • {a.active ? 'Ativa' : 'Inativa'}</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setEditingAvailability(a);
|
|
setShowAvailabilityForm(true);
|
|
}}
|
|
className="hover:bg-muted hover:text-foreground"
|
|
>
|
|
Editar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={async () => {
|
|
if (!confirm('Excluir esta disponibilidade?')) return;
|
|
try {
|
|
await deletarDisponibilidade(String(a.id));
|
|
reloadAvailabilities();
|
|
toast({ title: 'Disponibilidade excluída', variant: 'default' });
|
|
} catch (e) {
|
|
console.warn('Erro ao deletar disponibilidade:', e);
|
|
alert((e as any)?.message || 'Erro ao deletar disponibilidade');
|
|
}
|
|
}}
|
|
>
|
|
Excluir
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground p-4 border rounded-lg bg-muted/50">
|
|
Nenhuma disponibilidade cadastrada.
|
|
</div>
|
|
)}
|
|
|
|
{/* Exceções */}
|
|
<div className="mt-8">
|
|
<h3 className="text-lg sm:text-xl font-bold mb-4">Exceções (Bloqueios/Liberações)</h3>
|
|
{exceptLoading ? (
|
|
<div className="text-sm text-muted-foreground p-4">Carregando exceções…</div>
|
|
) : filteredExceptions && filteredExceptions.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{filteredExceptions.map((ex) => (
|
|
<div key={String(ex.id)} className="p-3 border rounded flex justify-between items-start">
|
|
<div className="flex-1">
|
|
<div className="font-medium text-sm sm:text-base">
|
|
{(() => {
|
|
try {
|
|
// Normaliza possíveis nomes de campo (exception_date, exceptionDate, date) e formata com fallback
|
|
const dateRaw = (ex as any).exception_date ?? (ex as any).exceptionDate ?? ex.date ?? '';
|
|
const parts = String(dateRaw).split('-');
|
|
if (parts.length >= 3) {
|
|
const [y, m, d] = parts;
|
|
return `${d}/${m}/${y}`;
|
|
}
|
|
// fallback: tentar parse ISO/locale
|
|
const dt = new Date(String(dateRaw));
|
|
if (!isNaN(dt.getTime())) {
|
|
return `${String(dt.getDate()).padStart(2, '0')}/${String(dt.getMonth() + 1).padStart(2, '0')}/${dt.getFullYear()}`;
|
|
}
|
|
return String(dateRaw);
|
|
} catch (e) {
|
|
return ((ex as any).exception_date ?? (ex as any).exceptionDate ?? ex.date) as any;
|
|
}
|
|
})()}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Tipo: {(ex as any).kind || 'bloqueio'} • Motivo: {(ex as any).reason || '—'}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 ml-2">
|
|
{/* Sem ações para exceções */}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground p-4 border rounded-lg bg-muted/50">
|
|
Nenhuma exceção cadastrada.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
const renderPerfilSection = () => (
|
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 sm:gap-6 px-0 py-4 sm:py-8 md:px-4">
|
|
{/* Header com Título e Botão */}
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-2xl sm:text-3xl font-bold">Meu Perfil</h2>
|
|
<p className="text-xs sm:text-sm text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
|
|
</div>
|
|
{!isEditingProfile ? (
|
|
<Button
|
|
className="bg-blue-600 hover:bg-blue-700 text-xs sm:text-sm w-full sm:w-auto"
|
|
onClick={() => setIsEditingProfile(true)}
|
|
>
|
|
Editar Perfil
|
|
</Button>
|
|
) : (
|
|
<div className="flex gap-2 w-full sm:w-auto">
|
|
<Button
|
|
className="bg-green-600 hover:bg-green-700 flex-1 sm:flex-initial text-xs sm:text-sm"
|
|
onClick={handleSaveProfile}
|
|
>
|
|
✓ Salvar
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleCancelEdit}
|
|
className="flex-1 sm:flex-initial text-xs sm:text-sm"
|
|
>
|
|
✕ Cancelar
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Grid de 3 colunas (2 + 1) - Responsivo */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
|
{/* Coluna Esquerda - Informações Pessoais */}
|
|
<div className="lg:col-span-2 space-y-4 sm:space-y-6">
|
|
{/* Informações Pessoais */}
|
|
<div className="border border-border rounded-lg p-4 sm:p-6">
|
|
<h3 className="text-base sm:text-lg font-semibold mb-4">Informações Pessoais</h3>
|
|
|
|
<div className="space-y-4">
|
|
{/* Nome Completo */}
|
|
<div>
|
|
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
|
Nome Completo
|
|
</Label>
|
|
<div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground font-medium">
|
|
{profileData.nome || "Não preenchido"}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Este campo não pode ser alterado
|
|
</p>
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div>
|
|
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
|
Email
|
|
</Label>
|
|
{isEditingProfile ? (
|
|
<Input
|
|
value={profileData.email || ""}
|
|
onChange={(e) => handleProfileChange('email', e.target.value)}
|
|
className="mt-2 text-xs sm:text-sm"
|
|
type="email"
|
|
/>
|
|
) : (
|
|
<div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
|
|
{profileData.email || "Não preenchido"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Telefone */}
|
|
<div>
|
|
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
|
Telefone
|
|
</Label>
|
|
{isEditingProfile ? (
|
|
<Input
|
|
value={profileData.telefone || ""}
|
|
onChange={(e) => handleProfileChange('telefone', e.target.value)}
|
|
className="mt-2 text-xs sm:text-sm"
|
|
placeholder="(00) 00000-0000"
|
|
/>
|
|
) : (
|
|
<div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
|
|
{profileData.telefone || "Não preenchido"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* CRM */}
|
|
<div>
|
|
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
|
CRM
|
|
</Label>
|
|
<div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground font-medium">
|
|
{profileData.crm || "Não preenchido"}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Este campo não pode ser alterado
|
|
</p>
|
|
</div>
|
|
|
|
{/* Especialidade */}
|
|
<div>
|
|
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
|
Especialidade
|
|
</Label>
|
|
{isEditingProfile ? (
|
|
<Input
|
|
value={profileData.especialidade || ""}
|
|
onChange={(e) => handleProfileChange('especialidade', e.target.value)}
|
|
className="mt-2 text-xs sm:text-sm"
|
|
placeholder="Ex: Cardiologia"
|
|
/>
|
|
) : (
|
|
<div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
|
|
{profileData.especialidade || "Não preenchido"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Endereço e Contato */}
|
|
<div className="border border-border rounded-lg p-4 sm:p-6">
|
|
<h3 className="text-base sm:text-lg font-semibold mb-4">Endereço e Contato</h3>
|
|
|
|
<div className="space-y-4">
|
|
{/* Logradouro */}
|
|
<div>
|
|
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
|
Logradouro
|
|
</Label>
|
|
{isEditingProfile ? (
|
|
<Input
|
|
value={profileData.endereco || ""}
|
|
onChange={(e) => handleProfileChange('endereco', e.target.value)}
|
|
className="mt-2 text-xs sm:text-sm"
|
|
placeholder="Rua, avenida, etc."
|
|
/>
|
|
) : (
|
|
<div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
|
|
{profileData.endereco || "Não preenchido"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cidade */}
|
|
<div>
|
|
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
|
Cidade
|
|
</Label>
|
|
{isEditingProfile ? (
|
|
<Input
|
|
value={profileData.cidade || ""}
|
|
onChange={(e) => handleProfileChange('cidade', e.target.value)}
|
|
className="mt-2 text-xs sm:text-sm"
|
|
placeholder="São Paulo"
|
|
/>
|
|
) : (
|
|
<div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
|
|
{profileData.cidade || "Não preenchido"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* CEP */}
|
|
<div>
|
|
<Label className="text-xs sm:text-sm font-medium text-muted-foreground">
|
|
CEP
|
|
</Label>
|
|
{isEditingProfile ? (
|
|
<Input
|
|
value={profileData.cep || ""}
|
|
onChange={(e) => handleProfileChange('cep', e.target.value)}
|
|
className="mt-2 text-xs sm:text-sm"
|
|
placeholder="00000-000"
|
|
/>
|
|
) : (
|
|
<div className="mt-2 p-3 bg-muted rounded text-xs sm:text-sm text-foreground">
|
|
{profileData.cep || "Não preenchido"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Coluna Direita - Foto do Perfil */}
|
|
<div>
|
|
<div className="border border-border rounded-lg p-4 sm:p-6">
|
|
<h3 className="text-base sm:text-lg font-semibold mb-4">Foto do Perfil</h3>
|
|
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Avatar className="h-20 w-20 sm:h-24 sm:w-24">
|
|
{(profileData as any).fotoUrl ? (
|
|
<AvatarImage src={(profileData as any).fotoUrl} alt={(profileData as any).nome} />
|
|
) : (
|
|
<AvatarFallback className="bg-primary text-primary-foreground text-lg sm:text-2xl font-bold">
|
|
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'}
|
|
</AvatarFallback>
|
|
)}
|
|
</Avatar>
|
|
|
|
<div className="text-center space-y-2">
|
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
|
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'MD'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
|
|
const renderActiveSection = () => {
|
|
switch (activeSection) {
|
|
case 'calendario':
|
|
return renderCalendarioSection();
|
|
case 'pacientes':
|
|
return (
|
|
<section className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6 w-full">
|
|
<h2 className="text-xl sm:text-2xl font-bold mb-4">Pacientes</h2>
|
|
<div className="overflow-x-auto w-full">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="text-xs sm:text-sm">Nome</TableHead>
|
|
<TableHead className="text-xs sm:text-sm">CPF</TableHead>
|
|
<TableHead className="text-xs sm:text-sm">Idade</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pacientes.map((paciente) => (
|
|
<TableRow key={paciente.id ?? paciente.cpf}>
|
|
<TableCell className="text-xs sm:text-sm">{paciente.nome}</TableCell>
|
|
<TableCell className="text-xs sm:text-sm">{paciente.cpf}</TableCell>
|
|
<TableCell className="text-xs sm:text-sm">{getPatientAge(paciente) ? `${getPatientAge(paciente)} anos` : '-'}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</section>
|
|
);
|
|
case 'laudos':
|
|
return renderLaudosSection();
|
|
case 'disponibilidades':
|
|
return renderDisponibilidadesSection();
|
|
case 'comunicacao':
|
|
return renderComunicacaoSection();
|
|
case 'perfil':
|
|
return renderPerfilSection();
|
|
default:
|
|
return renderCalendarioSection();
|
|
}
|
|
};
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
|
|
return (
|
|
<ProtectedRoute requiredUserType={["profissional"]}>
|
|
<div className="flex flex-col min-h-screen">
|
|
{/* Header - Responsivo */}
|
|
<header className="bg-card shadow-md border-b border-border sticky top-0 z-40">
|
|
<div className="px-4 py-3 md:px-6">
|
|
<div className="flex items-center justify-between gap-4 flex-wrap md:flex-nowrap">
|
|
{/* Logo/Avatar Section */}
|
|
<div className="flex items-center gap-3 min-w-0 flex-1 md:flex-none">
|
|
{/* Logo MEDIConnect */}
|
|
<div className="flex items-center gap-2 mr-2 md:mr-4">
|
|
<div className="w-8 h-8 md:w-10 md:h-10 bg-primary rounded-lg flex items-center justify-center shrink-0">
|
|
<Stethoscope className="w-4 h-4 md:w-5 md:h-5 text-primary-foreground" />
|
|
</div>
|
|
<span className="text-base md:text-lg font-semibold text-foreground hidden sm:inline">
|
|
MEDIConnect
|
|
</span>
|
|
</div>
|
|
|
|
<div className="h-8 w-px bg-border hidden sm:block"></div>
|
|
|
|
<Avatar className="h-10 w-10 md:h-12 md:w-12 flex-shrink-0">
|
|
<AvatarImage src={(profileData as any).fotoUrl || undefined} alt={profileData.nome} />
|
|
<AvatarFallback className="bg-muted text-xs md:text-sm">
|
|
<User className="h-4 w-4 md:h-5 md:w-5" />
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="text-xs md:text-sm text-muted-foreground truncate">Profissional de Saúde</p>
|
|
<h2 className="text-sm md:text-base font-semibold leading-none truncate">{profileData.nome}</h2>
|
|
<p className="text-xs text-muted-foreground truncate line-clamp-1">{(profileData.crm ? `CRM: ${profileData.crm}` : '') + (profileData.especialidade ? ` • ${profileData.especialidade}` : '')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions - Mobile hidden on small screens */}
|
|
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
|
|
<SimpleThemeToggle />
|
|
|
|
{/* Desktop Buttons - Hidden on mobile */}
|
|
<div className="hidden sm:flex items-center gap-1 md:gap-2">
|
|
<Button asChild variant="default" size="sm" className="bg-primary hover:bg-primary/90 text-primary-foreground rounded shadow-sm shadow-blue-500/10 border border-primary text-xs md:text-sm px-2 md:px-4 h-8 md:h-9">
|
|
<Link href="/" aria-label="Início">Início</Link>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={logout}
|
|
size="sm"
|
|
className="text-red-600 border-red-600 hover:bg-red-50 cursor-pointer dark:hover:bg-red-600 dark:hover:text-white text-xs md:text-sm px-2 md:px-4 h-8 md:h-9"
|
|
>
|
|
Sair
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Mobile Menu Button */}
|
|
<button
|
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
className="md:hidden p-2 hover:bg-muted rounded transition-colors flex-shrink-0"
|
|
aria-label="Menu"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sidebarOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile menu items - visible when sidebarOpen */}
|
|
{sidebarOpen && (
|
|
<div className="md:hidden flex flex-col gap-2 mt-3 pt-3 border-t border-border">
|
|
<Button asChild variant="default" size="sm" className="w-full bg-primary hover:bg-primary/90 text-primary-foreground">
|
|
<Link href="/">Início</Link>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={logout}
|
|
size="sm"
|
|
className="w-full text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-600 dark:hover:text-white"
|
|
>
|
|
Sair
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 flex flex-col md:flex-row gap-0 md:gap-6 px-3 sm:px-4 md:px-8 py-4 md:py-8">
|
|
{/* Sidebar - Mobile Drawer or Desktop */}
|
|
<aside className={`${
|
|
sidebarOpen ? 'block' : 'hidden md:block'
|
|
} md:sticky md:top-24 md:h-fit w-full md:w-[220px] mb-4 md:mb-0`}>
|
|
<nav className="bg-card shadow-md rounded-lg border border-border p-2 md:p-3 space-y-1">
|
|
<Button
|
|
variant={activeSection === 'calendario' ? 'default' : 'ghost'}
|
|
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
|
onClick={() => {
|
|
setActiveSection('calendario');
|
|
setSidebarOpen(false);
|
|
}}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
<span className="hidden sm:inline">Calendário</span>
|
|
<span className="sm:hidden">Calendário</span>
|
|
</Button>
|
|
<Button
|
|
variant={activeSection === 'pacientes' ? 'default' : 'ghost'}
|
|
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
|
onClick={() => {
|
|
setActiveSection('pacientes');
|
|
setSidebarOpen(false);
|
|
}}
|
|
>
|
|
<Users className="mr-2 h-4 w-4" />
|
|
Pacientes
|
|
</Button>
|
|
<Button
|
|
variant={activeSection === 'laudos' ? 'default' : 'ghost'}
|
|
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
|
onClick={() => {
|
|
setActiveSection('laudos');
|
|
setSidebarOpen(false);
|
|
}}
|
|
>
|
|
<FileText className="mr-2 h-4 w-4" />
|
|
Laudos
|
|
</Button>
|
|
<Button
|
|
variant={activeSection === 'disponibilidades' ? 'default' : 'ghost'}
|
|
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
|
onClick={() => {
|
|
setActiveSection('disponibilidades');
|
|
setSidebarOpen(false);
|
|
}}
|
|
>
|
|
<Clock className="mr-2 h-4 w-4" />
|
|
Disponibilidades
|
|
</Button>
|
|
<Button
|
|
variant={activeSection === 'comunicacao' ? 'default' : 'ghost'}
|
|
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
|
onClick={() => {
|
|
setActiveSection('comunicacao');
|
|
setSidebarOpen(false);
|
|
}}
|
|
>
|
|
<MessageSquare className="mr-2 h-4 w-4" />
|
|
SMS
|
|
</Button>
|
|
<Button
|
|
variant={activeSection === 'perfil' ? 'default' : 'ghost'}
|
|
className="w-full justify-start text-sm md:text-base transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
|
onClick={() => {
|
|
setActiveSection('perfil');
|
|
setSidebarOpen(false);
|
|
}}
|
|
>
|
|
<Settings className="mr-2 h-4 w-4" />
|
|
Meu Perfil
|
|
</Button>
|
|
</nav>
|
|
</aside>
|
|
|
|
{/* Main Content Area */}
|
|
<main className="flex-1 min-w-0 w-full">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h1 className="text-2xl md:text-3xl font-bold">Área do Profissional</h1>
|
|
</div>
|
|
<p className="mb-6 md:mb-8 text-sm md:text-base">Bem-vindo à sua área exclusiva.</p>
|
|
|
|
{renderActiveSection()}
|
|
</main>
|
|
</div>
|
|
|
|
{/* AvailabilityForm para criar/editar disponibilidades */}
|
|
{showAvailabilityForm && (
|
|
<AvailabilityForm
|
|
open={showAvailabilityForm}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setShowAvailabilityForm(false);
|
|
setEditingAvailability(null);
|
|
setAvailabilitiesForCreate([]);
|
|
}
|
|
}}
|
|
doctorId={editingAvailability?.doctor_id ?? doctorId}
|
|
availability={editingAvailability}
|
|
existingAvailabilities={availabilitiesForCreate}
|
|
mode={editingAvailability ? "edit" : "create"}
|
|
onSaved={(saved) => {
|
|
console.log('Disponibilidade salva', saved);
|
|
setEditingAvailability(null);
|
|
setShowAvailabilityForm(false);
|
|
setAvailabilitiesForCreate([]);
|
|
reloadAvailabilities();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Popup antigo (manter para compatibilidade) */}
|
|
{showPopup && (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex justify-center items-center z-50">
|
|
|
|
<div className="bg-card border border-border p-6 rounded-lg w-96">
|
|
|
|
{step === 1 && (
|
|
<>
|
|
<h3 className="text-lg font-semibold mb-2">Selecionar Paciente</h3>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Data: {selectedDate ? new Date(selectedDate + 'T00:00:00').toLocaleDateString('pt-BR') : 'Não selecionada'}
|
|
</p>
|
|
<Select
|
|
value={newEvent.title}
|
|
onValueChange={(value) => setNewEvent({ ...newEvent, title: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Selecione o paciente" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{pacientes && pacientes.map((paciente) => (
|
|
<SelectItem key={paciente.cpf} value={paciente.nome}>
|
|
{paciente.nome} - {paciente.cpf}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="flex gap-2 mt-4">
|
|
<Button
|
|
onClick={() => setShowPopup(false)}
|
|
variant="outline"
|
|
className="flex-1 hover:bg-primary! hover:text-white! transition-colors"
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
onClick={handleNextStep}
|
|
disabled={!newEvent.title}
|
|
className="flex-1"
|
|
>
|
|
Próximo
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<>
|
|
<h3 className="text-lg font-semibold mb-4">Tipo da Consulta</h3>
|
|
<Select
|
|
value={newEvent.type}
|
|
onValueChange={(value) => setNewEvent({ ...newEvent, type: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Selecione o tipo" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.keys(colorsByType).map((type) => (
|
|
<SelectItem key={type} value={type}>
|
|
{type}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="flex gap-2 mt-4">
|
|
<Button
|
|
onClick={() => setStep(1)}
|
|
variant="outline"
|
|
className="flex-1"
|
|
>
|
|
Voltar
|
|
</Button>
|
|
<Button
|
|
onClick={handleNextStep}
|
|
disabled={!newEvent.type}
|
|
className="flex-1"
|
|
>
|
|
Próximo
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<>
|
|
<h3 className="text-lg font-semibold mb-4">Horário da Consulta</h3>
|
|
<Input
|
|
type="time"
|
|
value={newEvent.time}
|
|
onChange={(e) => setNewEvent({ ...newEvent, time: e.target.value })}
|
|
className="mb-4"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={() => setStep(2)}
|
|
variant="outline"
|
|
className="flex-1"
|
|
>
|
|
Voltar
|
|
</Button>
|
|
<Button
|
|
onClick={handleNextStep}
|
|
disabled={!newEvent.time}
|
|
className="flex-1"
|
|
>
|
|
{editingEvent ? "Salvar" : "Agendar"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de ação para editar evento */}
|
|
{showActionModal && selectedEvent && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50 p-4">
|
|
<div className="bg-card border border-border p-4 sm:p-6 rounded-lg w-full max-w-md">
|
|
<h3 className="text-base sm:text-lg font-semibold mb-2">
|
|
Consulta de {selectedEvent.title}
|
|
</h3>
|
|
<p className="text-xs sm:text-sm text-gray-600 mb-4">
|
|
{selectedEvent.extendedProps.type} às {selectedEvent.extendedProps.time}
|
|
</p>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={handleStartEdit}
|
|
className="flex-1 flex items-center justify-center gap-2 text-xs sm:text-sm"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
Editar
|
|
</Button>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={() => setShowActionModal(false)}
|
|
variant="outline"
|
|
className="w-full mt-2 hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm"
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal para visualizar pacientes de um dia específico */}
|
|
{showDayModal && selectedDayDate && (
|
|
<div className="fixed inset-0 bg-black/50 flex justify-center items-center z-50 p-4">
|
|
<div className="bg-card border border-border rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
{/* Header com navegação */}
|
|
<div className="flex items-center justify-between p-3 sm:p-4 border-b border-border gap-2">
|
|
<button
|
|
onClick={() => {
|
|
const prev = new Date(selectedDayDate);
|
|
prev.setDate(prev.getDate() - 1);
|
|
setSelectedDayDate(prev);
|
|
}}
|
|
className="p-2 hover:bg-muted rounded transition-colors flex-shrink-0"
|
|
aria-label="Dia anterior"
|
|
>
|
|
<ChevronLeft className="h-4 w-4 sm:h-5 sm:w-5" />
|
|
</button>
|
|
|
|
<h2 className="text-base sm:text-lg font-semibold flex-1 text-center line-clamp-2">
|
|
{selectedDayDate.toLocaleDateString('pt-BR', {
|
|
weekday: 'short',
|
|
day: 'numeric',
|
|
month: 'short'
|
|
})}
|
|
</h2>
|
|
|
|
<button
|
|
onClick={() => {
|
|
const next = new Date(selectedDayDate);
|
|
next.setDate(next.getDate() + 1);
|
|
setSelectedDayDate(next);
|
|
}}
|
|
className="p-2 hover:bg-muted rounded transition-colors flex-shrink-0"
|
|
aria-label="Próximo dia"
|
|
>
|
|
<ChevronRight className="h-4 w-4 sm:h-5 sm:w-5" />
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setShowDayModal(false)}
|
|
className="p-2 hover:bg-muted rounded transition-colors flex-shrink-0"
|
|
aria-label="Fechar"
|
|
>
|
|
<X className="h-4 w-4 sm:h-5 sm:w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{(() => {
|
|
const dayStr = selectedDayDate.toISOString().split('T')[0];
|
|
const dayEvents = events.filter(e => e.date === dayStr).sort((a, b) => a.time.localeCompare(b.time));
|
|
|
|
if (dayEvents.length === 0) {
|
|
return (
|
|
<div className="text-center py-6 sm:py-8 text-muted-foreground">
|
|
<CalendarIcon className="h-10 sm:h-12 w-10 sm:w-12 mx-auto mb-3 sm:mb-4 text-muted-foreground/50" />
|
|
<p className="text-base sm:text-lg">Nenhuma consulta agendada para este dia</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-xs sm:text-sm text-muted-foreground mb-4">
|
|
{dayEvents.length} consulta{dayEvents.length !== 1 ? 's' : ''} agendada{dayEvents.length !== 1 ? 's' : ''}
|
|
</p>
|
|
{dayEvents.map((appointment) => {
|
|
const paciente = pacientes.find(p => p.nome === appointment.title);
|
|
return (
|
|
<div
|
|
key={appointment.id}
|
|
className="border-l-4 p-3 sm:p-4 rounded-lg bg-muted/20"
|
|
style={{ borderLeftColor: getStatusColor(appointment.type) }}
|
|
>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-semibold text-xs sm:text-sm flex items-center gap-2">
|
|
<User className="h-4 w-4" />
|
|
{appointment.title}
|
|
</h3>
|
|
<span className="px-2 sm:px-3 py-1 rounded-full text-xs font-medium text-white" style={{ backgroundColor: getStatusColor(appointment.type) }}>
|
|
{appointment.type}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm text-muted-foreground flex-wrap">
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-4 w-4" />
|
|
{appointment.time}
|
|
</span>
|
|
{paciente && (
|
|
<span>
|
|
CPF: {getPatientCpf(paciente)} • {getPatientAge(paciente)} anos
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Formulário para cadastro de paciente */}
|
|
<PatientRegistrationForm
|
|
open={showPatientForm}
|
|
onOpenChange={setShowPatientForm}
|
|
mode="create"
|
|
onSaved={(newPaciente) => {
|
|
// Adicionar o novo paciente à lista e recarregar
|
|
setPacientes((prev) => [...prev, newPaciente]);
|
|
}}
|
|
/>
|
|
</div>
|
|
</ProtectedRoute>
|
|
);
|
|
};
|
|
|
|
const getShortId = (id?: string) => {
|
|
if (!id) return '-';
|
|
try {
|
|
return id.length > 10 ? `${id.slice(0, 8)}...` : id;
|
|
} catch (e) {
|
|
return id;
|
|
}
|
|
};
|
|
|
|
export default ProfissionalPage;
|
|
|