Merge branch 'backup/develop' into fix/appoiments
This commit is contained in:
commit
4344ccedca
@ -520,24 +520,25 @@ export default function RelatoriosPage() {
|
|||||||
|
|
||||||
{/* Performance por médico */}
|
{/* Performance por médico */}
|
||||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-0 mb-4">
|
||||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><TrendingUp className="w-5 h-5" /> Performance por Médico</h2>
|
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><TrendingUp className="w-5 h-5" /> Performance por Médico</h2>
|
||||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Performance por Médico", "Resumo da performance por médico.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors w-full md:w-auto" onClick={() => exportPDF("Performance por Médico", "Resumo da performance por médico.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm mt-4">
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-muted-foreground">
|
<tr className="text-muted-foreground border-b border-border">
|
||||||
<th className="text-left font-medium">Médico</th>
|
<th className="text-left font-medium py-3 px-2 md:px-0">Médico</th>
|
||||||
<th className="text-left font-medium">Consultas</th>
|
<th className="text-center font-medium py-3 px-2 md:px-0">Consultas</th>
|
||||||
<th className="text-left font-medium">Absenteísmo (%)</th>
|
<th className="text-center font-medium py-3 px-2 md:px-0">Absenteísmo (%)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(loading ? performancePorMedico : medicosPerformance).map((m) => (
|
{(loading ? performancePorMedico : medicosPerformance).map((m) => (
|
||||||
<tr key={m.nome}>
|
<tr key={m.nome} className="border-b border-border/50 hover:bg-muted/30 transition-colors">
|
||||||
<td className="py-1">{m.nome}</td>
|
<td className="py-3 px-2 md:px-0">{m.nome}</td>
|
||||||
<td className="py-1">{m.consultas}</td>
|
<td className="py-3 px-2 md:px-0 text-center font-medium">{m.consultas}</td>
|
||||||
<td className="py-1">{m.absenteismo}</td>
|
<td className="py-3 px-2 md:px-0 text-center text-blue-500 font-medium">{m.absenteismo}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -545,6 +546,7 @@ export default function RelatoriosPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -244,8 +244,12 @@ export default function ResultadosClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onlyAvail = (res?.slots || []).filter((s: any) => s.available)
|
const onlyAvail = (res?.slots || []).filter((s: any) => s.available)
|
||||||
|
const nowMs = Date.now()
|
||||||
for (const s of onlyAvail) {
|
for (const s of onlyAvail) {
|
||||||
const dt = new Date(s.datetime)
|
const dt = new Date(s.datetime)
|
||||||
|
const dtMs = dt.getTime()
|
||||||
|
// Filtrar: só mostrar horários que são posteriores ao horário atual
|
||||||
|
if (dtMs < nowMs) continue
|
||||||
const key = dt.toISOString().split('T')[0]
|
const key = dt.toISOString().split('T')[0]
|
||||||
const bucket = days.find(d => d.dateKey === key)
|
const bucket = days.find(d => d.dateKey === key)
|
||||||
if (!bucket) continue
|
if (!bucket) continue
|
||||||
@ -260,7 +264,6 @@ export default function ResultadosClient() {
|
|||||||
|
|
||||||
// compute nearest slot (earliest available in the returned window, but after now)
|
// compute nearest slot (earliest available in the returned window, but after now)
|
||||||
let nearest: { iso: string; label: string } | null = null
|
let nearest: { iso: string; label: string } | null = null
|
||||||
const nowMs = Date.now()
|
|
||||||
const allSlots = days.flatMap(d => d.horarios || [])
|
const allSlots = days.flatMap(d => d.horarios || [])
|
||||||
const futureSorted = allSlots
|
const futureSorted = allSlots
|
||||||
.map(s => ({ ...s, ms: new Date(s.iso).getTime() }))
|
.map(s => ({ ...s, ms: new Date(s.iso).getTime() }))
|
||||||
@ -582,17 +585,24 @@ export default function ResultadosClient() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const merged = Array.from(mergedMap.values()).sort((a:any,b:any) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
|
const merged = Array.from(mergedMap.values()).sort((a:any,b:any) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
|
||||||
const formatted = (merged || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }))
|
const nowMs = Date.now()
|
||||||
|
// Filtrar: só mostrar horários que são posteriores ao horário atual
|
||||||
|
const futureOnly = merged.filter((s: any) => new Date(s.datetime).getTime() >= nowMs)
|
||||||
|
const formatted = (futureOnly || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }))
|
||||||
setMoreTimesSlots(formatted)
|
setMoreTimesSlots(formatted)
|
||||||
return formatted
|
return formatted
|
||||||
} else {
|
} else {
|
||||||
const slots = (av.slots || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }))
|
const nowMs = Date.now()
|
||||||
|
// Filtrar: só mostrar horários que são posteriores ao horário atual
|
||||||
|
const slots = (av.slots || []).filter((s:any) => new Date(s.datetime).getTime() >= nowMs).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }))
|
||||||
setMoreTimesSlots(slots)
|
setMoreTimesSlots(slots)
|
||||||
return slots
|
return slots
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[ResultadosClient] erro ao filtrar por disponibilidades', e)
|
console.warn('[ResultadosClient] erro ao filtrar por disponibilidades', e)
|
||||||
const slots = (av.slots || []).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }))
|
const nowMs = Date.now()
|
||||||
|
// Filtrar: só mostrar horários que são posteriores ao horário atual
|
||||||
|
const slots = (av.slots || []).filter((s:any) => new Date(s.datetime).getTime() >= nowMs).map((s:any) => ({ iso: s.datetime, label: new Date(s.datetime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }))
|
||||||
setMoreTimesSlots(slots)
|
setMoreTimesSlots(slots)
|
||||||
return slots
|
return slots
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import ProtectedRoute from "@/components/shared/ProtectedRoute";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api";
|
import { buscarPacientes, listarPacientes, buscarPacientePorId, buscarPacientesPorIds, buscarMedicoPorId, buscarMedicosPorIds, buscarMedicos, listarAgendamentos, type Paciente, buscarRelatorioPorId, atualizarMedico } from "@/lib/api";
|
||||||
|
import { ENV_CONFIG } from '@/lib/env-config';
|
||||||
import { useReports } from "@/hooks/useReports";
|
import { useReports } from "@/hooks/useReports";
|
||||||
import { CreateReportData } from "@/types/report-types";
|
import { CreateReportData } from "@/types/report-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -36,7 +37,6 @@ import {
|
|||||||
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { ENV_CONFIG } from '@/lib/env-config';
|
|
||||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
@ -182,7 +182,7 @@ const ProfissionalPage = () => {
|
|||||||
const q = `doctor_id=eq.${encodeURIComponent(String(resolvedDoctorId))}&select=patient_id&limit=200`;
|
const q = `doctor_id=eq.${encodeURIComponent(String(resolvedDoctorId))}&select=patient_id&limit=200`;
|
||||||
const appts = await listarAgendamentos(q).catch(() => []);
|
const appts = await listarAgendamentos(q).catch(() => []);
|
||||||
for (const a of (appts || [])) {
|
for (const a of (appts || [])) {
|
||||||
const pid = a.patient_id ?? a.patient ?? a.patient_id_raw ?? null;
|
const pid = (a as any).patient_id ?? null;
|
||||||
if (pid) patientIdSet.add(String(pid));
|
if (pid) patientIdSet.add(String(pid));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -211,6 +211,7 @@ const ProfissionalPage = () => {
|
|||||||
})();
|
})();
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
// Re-run when user id becomes available so patients assigned to the logged-in doctor are loaded
|
// 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]);
|
}, [user?.id]);
|
||||||
|
|
||||||
// Carregar perfil do médico correspondente ao usuário logado
|
// Carregar perfil do médico correspondente ao usuário logado
|
||||||
@ -429,6 +430,9 @@ const ProfissionalPage = () => {
|
|||||||
const [commPhoneNumber, setCommPhoneNumber] = useState('');
|
const [commPhoneNumber, setCommPhoneNumber] = useState('');
|
||||||
const [commMessage, setCommMessage] = useState('');
|
const [commMessage, setCommMessage] = useState('');
|
||||||
const [commPatientId, setCommPatientId] = useState<string | null>(null);
|
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 [smsSending, setSmsSending] = useState(false);
|
||||||
|
|
||||||
const handleSave = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleSave = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
@ -520,6 +524,68 @@ const ProfissionalPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const handleEditarLaudo = (paciente: any) => {
|
||||||
@ -2638,6 +2704,9 @@ const ProfissionalPage = () => {
|
|||||||
// Use a sentinel value "__none" for the "-- nenhum --" choice and map it to null here.
|
// Use a sentinel value "__none" for the "-- nenhum --" choice and map it to null here.
|
||||||
const v = val === "__none" ? null : (val || null);
|
const v = val === "__none" ? null : (val || null);
|
||||||
setCommPatientId(v);
|
setCommPatientId(v);
|
||||||
|
// clear previous responses when changing selection
|
||||||
|
setCommResponses([]);
|
||||||
|
setCommResponsesError(null);
|
||||||
if (!v) {
|
if (!v) {
|
||||||
setCommPhoneNumber('');
|
setCommPhoneNumber('');
|
||||||
return;
|
return;
|
||||||
@ -2655,6 +2724,8 @@ const ProfissionalPage = () => {
|
|||||||
console.warn('[ProfissionalPage] erro ao preencher telefone do paciente selecionado', e);
|
console.warn('[ProfissionalPage] erro ao preencher telefone do paciente selecionado', e);
|
||||||
setCommPhoneNumber('');
|
setCommPhoneNumber('');
|
||||||
}
|
}
|
||||||
|
// carregar respostas do paciente selecionado
|
||||||
|
void loadCommResponses(String(v));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
@ -2686,6 +2757,35 @@ const ProfissionalPage = () => {
|
|||||||
{smsSending ? 'Enviando...' : 'Enviar SMS'}
|
{smsSending ? 'Enviando...' : 'Enviar SMS'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Respostas do paciente */}
|
||||||
|
<div className="mt-6 border-t border-border pt-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-lg font-semibold">Últimas respostas do paciente</h3>
|
||||||
|
<div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void loadCommResponses()} disabled={!commPatientId || commResponsesLoading}>
|
||||||
|
{commResponsesLoading ? 'Atualizando...' : 'Atualizar respostas'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{commResponsesLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Carregando respostas...</div>
|
||||||
|
) : commResponsesError ? (
|
||||||
|
<div className="text-sm text-red-500">{commResponsesError}</div>
|
||||||
|
) : (commResponses && commResponses.length) ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{commResponses.map((m:any) => (
|
||||||
|
<div key={m.id} className="p-3 rounded border border-border bg-muted/10">
|
||||||
|
<div className="text-xs text-muted-foreground">{m.created_at ? new Date(m.created_at).toLocaleString() : ''}</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap">{m.body ?? m.content ?? m.message ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">Nenhuma resposta encontrada para o paciente selecionado.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2941,8 +3041,8 @@ const ProfissionalPage = () => {
|
|||||||
);
|
);
|
||||||
case 'laudos':
|
case 'laudos':
|
||||||
return renderLaudosSection();
|
return renderLaudosSection();
|
||||||
// case 'comunicacao':
|
case 'comunicacao':
|
||||||
// return renderComunicacaoSection();
|
return renderComunicacaoSection();
|
||||||
case 'perfil':
|
case 'perfil':
|
||||||
return renderPerfilSection();
|
return renderPerfilSection();
|
||||||
default:
|
default:
|
||||||
@ -3013,15 +3113,14 @@ const ProfissionalPage = () => {
|
|||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
Laudos
|
Laudos
|
||||||
</Button>
|
</Button>
|
||||||
{/* Comunicação removida - campos embaixo do calendário */}
|
<Button
|
||||||
{/* <Button
|
|
||||||
variant={activeSection === 'comunicacao' ? 'default' : 'ghost'}
|
variant={activeSection === 'comunicacao' ? 'default' : 'ghost'}
|
||||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||||
onClick={() => setActiveSection('comunicacao')}
|
onClick={() => setActiveSection('comunicacao')}
|
||||||
>
|
>
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
Comunicação
|
SMS
|
||||||
</Button> */}
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={activeSection === 'perfil' ? 'default' : 'ghost'}
|
variant={activeSection === 'perfil' ? 'default' : 'ghost'}
|
||||||
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
className="w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user