develop #83
@ -3,7 +3,6 @@ import { useState } from 'react'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { ENV_CONFIG } from '@/lib/env-config'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@ -55,83 +54,7 @@ export default function LoginPacientePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- Auto-cadastro (client-side) ---
|
// Auto-cadastro foi removido (UI + client-side endpoint call)
|
||||||
const [showRegister, setShowRegister] = useState(false)
|
|
||||||
const [reg, setReg] = useState({ email: '', full_name: '', phone_mobile: '', cpf: '', birth_date: '' })
|
|
||||||
const [regLoading, setRegLoading] = useState(false)
|
|
||||||
const [regError, setRegError] = useState('')
|
|
||||||
const [regSuccess, setRegSuccess] = useState('')
|
|
||||||
|
|
||||||
function cleanCpf(cpf: string) {
|
|
||||||
return String(cpf || '').replace(/\D/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCPF(cpfRaw: string) {
|
|
||||||
const cpf = cleanCpf(cpfRaw)
|
|
||||||
if (!/^\d{11}$/.test(cpf)) return false
|
|
||||||
if (/^([0-9])\1+$/.test(cpf)) return false
|
|
||||||
const digits = cpf.split('').map((d) => Number(d))
|
|
||||||
const calc = (len: number) => {
|
|
||||||
let sum = 0
|
|
||||||
for (let i = 0; i < len; i++) sum += digits[i] * (len + 1 - i)
|
|
||||||
const v = (sum * 10) % 11
|
|
||||||
return v === 10 ? 0 : v
|
|
||||||
}
|
|
||||||
return calc(9) === digits[9] && calc(10) === digits[10]
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRegister = async (e?: React.FormEvent) => {
|
|
||||||
if (e) e.preventDefault()
|
|
||||||
setRegError('')
|
|
||||||
setRegSuccess('')
|
|
||||||
|
|
||||||
// client-side validation
|
|
||||||
if (!reg.email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(reg.email)) return setRegError('Email inválido')
|
|
||||||
if (!reg.full_name || reg.full_name.trim().length < 3) return setRegError('Nome deve ter ao menos 3 caracteres')
|
|
||||||
if (!reg.phone_mobile || !/^\d{10,11}$/.test(reg.phone_mobile)) return setRegError('Telefone inválido (10-11 dígitos)')
|
|
||||||
if (!reg.cpf || !/^\d{11}$/.test(cleanCpf(reg.cpf))) return setRegError('CPF deve conter 11 dígitos')
|
|
||||||
if (!validateCPF(reg.cpf)) return setRegError('CPF inválido')
|
|
||||||
|
|
||||||
setRegLoading(true)
|
|
||||||
try {
|
|
||||||
const url = `${ENV_CONFIG.SUPABASE_URL}/functions/v1/register-patient`
|
|
||||||
const body = {
|
|
||||||
email: reg.email,
|
|
||||||
full_name: reg.full_name,
|
|
||||||
phone_mobile: reg.phone_mobile,
|
|
||||||
cpf: cleanCpf(reg.cpf),
|
|
||||||
// always include redirect to patient landing as requested
|
|
||||||
redirect_url: 'https://mediconecta-app-liart.vercel.app/'
|
|
||||||
} as any
|
|
||||||
if (reg.birth_date) body.birth_date = reg.birth_date
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', apikey: ENV_CONFIG.SUPABASE_ANON_KEY, Accept: 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
|
|
||||||
const json = await res.json().catch(() => null)
|
|
||||||
if (res.ok) {
|
|
||||||
setRegSuccess(json?.message ?? 'Cadastro realizado com sucesso! Verifique seu email para acessar a plataforma.')
|
|
||||||
// clear form but keep email for convenience
|
|
||||||
setReg({ ...reg, full_name: '', phone_mobile: '', cpf: '', birth_date: '' })
|
|
||||||
} else if (res.status === 400) {
|
|
||||||
setRegError(json?.error ?? json?.message ?? 'Dados inválidos')
|
|
||||||
} else if (res.status === 409) {
|
|
||||||
setRegError(json?.error ?? 'CPF ou email já cadastrado')
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
setRegError(json?.error ?? 'Rate limit excedido. Tente novamente mais tarde.')
|
|
||||||
} else {
|
|
||||||
setRegError(json?.error ?? json?.message ?? `Erro (${res.status})`)
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[REGISTER PACIENTE] erro', err)
|
|
||||||
setRegError(err?.message ?? String(err))
|
|
||||||
} finally {
|
|
||||||
setRegLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
|
||||||
@ -206,53 +129,7 @@ export default function LoginPacientePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
{/* Auto-cadastro UI removed */}
|
||||||
<div className="text-sm text-muted-foreground mb-2">Ainda não tem conta? <button className="text-primary underline ml-2" onClick={() => setShowRegister(!showRegister)}>{showRegister ? 'Fechar' : 'Criar conta'}</button></div>
|
|
||||||
|
|
||||||
{showRegister && (
|
|
||||||
<Card className="mt-2">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-center">Auto-cadastro de Paciente</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleRegister} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">Nome completo</label>
|
|
||||||
<Input value={reg.full_name} onChange={(e) => setReg({...reg, full_name: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">Email</label>
|
|
||||||
<Input type="email" value={reg.email} onChange={(e) => setReg({...reg, email: e.target.value})} required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">Telefone (apenas números)</label>
|
|
||||||
<Input value={reg.phone_mobile} onChange={(e) => setReg({...reg, phone_mobile: e.target.value.replace(/\D/g,'')})} placeholder="11999998888" required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">CPF (11 dígitos)</label>
|
|
||||||
<Input value={reg.cpf} onChange={(e) => setReg({...reg, cpf: e.target.value.replace(/\D/g,'')})} placeholder="12345678901" required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">Data de nascimento (opcional)</label>
|
|
||||||
<Input type="date" value={reg.birth_date} onChange={(e) => setReg({...reg, birth_date: e.target.value})} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{regError && (
|
|
||||||
<Alert variant="destructive"><AlertDescription>{regError}</AlertDescription></Alert>
|
|
||||||
)}
|
|
||||||
{regSuccess && (
|
|
||||||
<Alert><AlertDescription>{regSuccess}</AlertDescription></Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button type="submit" className="flex-1" disabled={regLoading}>{regLoading ? 'Criando...' : 'Criar Conta'}</Button>
|
|
||||||
<Button variant="ghost" onClick={() => setShowRegister(false)} disabled={regLoading}>Cancelar</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -155,13 +155,13 @@ export default function AgendamentoPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row bg-background">
|
<div className="bg-background">
|
||||||
<div className="flex w-full flex-col">
|
<div className="w-full">
|
||||||
<div className="flex w-full flex-col gap-10 p-6">
|
<div className="w-full max-w-7xl mx-auto flex flex-col gap-6 sm:gap-10 p-4 sm:p-6">
|
||||||
<div className="flex flex-row justify-between items-center">
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
|
||||||
{/* Cabeçalho simplificado (sem 3D) */}
|
{/* Cabeçalho simplificado (sem 3D) */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Calendário</h1>
|
<h1 className="text-xl sm:text-2xl font-bold text-foreground">Calendário</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Navegue através do atalho: Calendário (C).
|
Navegue através do atalho: Calendário (C).
|
||||||
</p>
|
</p>
|
||||||
@ -170,8 +170,8 @@ export default function AgendamentoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legenda de status (aplica-se ao EventManager) */}
|
{/* Legenda de status (aplica-se ao EventManager) */}
|
||||||
<div className="rounded-md border bg-card/60 p-2 sm:p-3 -mt-4">
|
<div className="rounded-md border bg-card/60 p-2 sm:p-3 -mt-2 sm:-mt-4 overflow-x-auto">
|
||||||
<div className="flex flex-wrap items-center gap-6 text-sm">
|
<div className="flex flex-nowrap items-center gap-4 sm:gap-6 text-xs sm:text-sm whitespace-nowrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span aria-hidden className="h-3 w-3 rounded-full bg-blue-500 ring-2 ring-blue-500/30" />
|
<span aria-hidden className="h-3 w-3 rounded-full bg-blue-500 ring-2 ring-blue-500/30" />
|
||||||
<span className="text-foreground">Solicitado</span>
|
<span className="text-foreground">Solicitado</span>
|
||||||
@ -192,11 +192,11 @@ export default function AgendamentoPage() {
|
|||||||
<div className="flex w-full">
|
<div className="flex w-full">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{managerLoading ? (
|
{managerLoading ? (
|
||||||
<div className="flex items-center justify-center w-full min-h-[70vh]">
|
<div className="flex items-center justify-center w-full min-h-[60vh] sm:min-h-[70vh]">
|
||||||
<div className="text-sm text-muted-foreground">Conectando ao calendário — carregando agendamentos...</div>
|
<div className="text-sm text-muted-foreground">Conectando ao calendário — carregando agendamentos...</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full min-h-[70vh]">
|
<div className="w-full min-h-[60vh] sm:min-h-[70vh]">
|
||||||
<EventManager
|
<EventManager
|
||||||
events={managerEvents}
|
events={managerEvents}
|
||||||
className="compact-event-manager"
|
className="compact-event-manager"
|
||||||
|
|||||||
@ -55,7 +55,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
import { mockProfessionals } from "@/lib/mocks/appointment-mocks";
|
import { mockProfessionals } from "@/lib/mocks/appointment-mocks";
|
||||||
import { listarAgendamentos, buscarPacientesPorIds, buscarMedicosPorIds, atualizarAgendamento, buscarAgendamentoPorId, deletarAgendamento } from "@/lib/api";
|
import { listarAgendamentos, buscarPacientesPorIds, buscarMedicosPorIds, atualizarAgendamento, buscarAgendamentoPorId, deletarAgendamento, addDeletedAppointmentId } from "@/lib/api";
|
||||||
import { CalendarRegistrationForm } from "@/components/features/forms/calendar-registration-form";
|
import { CalendarRegistrationForm } from "@/components/features/forms/calendar-registration-form";
|
||||||
|
|
||||||
const formatDate = (date: string | Date) => {
|
const formatDate = (date: string | Date) => {
|
||||||
@ -140,6 +140,8 @@ export default function ConsultasPage() {
|
|||||||
try {
|
try {
|
||||||
// call server DELETE
|
// call server DELETE
|
||||||
await deletarAgendamento(appointmentId);
|
await deletarAgendamento(appointmentId);
|
||||||
|
// Mark as deleted in cache so it won't appear again
|
||||||
|
addDeletedAppointmentId(appointmentId);
|
||||||
// remove from UI
|
// remove from UI
|
||||||
setAppointments((prev) => prev.filter((a) => a.id !== appointmentId));
|
setAppointments((prev) => prev.filter((a) => a.id !== appointmentId));
|
||||||
// also update originalAppointments cache
|
// also update originalAppointments cache
|
||||||
|
|||||||
@ -1,332 +1,180 @@
|
|||||||
|
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FileDown, BarChart2, Users, DollarSign, TrendingUp, UserCheck, CalendarCheck, ThumbsUp, User, Briefcase } from "lucide-react";
|
import { FileDown, BarChart2, Users, CalendarCheck } from "lucide-react";
|
||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||||
import {
|
import {
|
||||||
countTotalPatients,
|
|
||||||
countTotalDoctors,
|
|
||||||
countAppointmentsToday,
|
countAppointmentsToday,
|
||||||
getAppointmentsByDateRange,
|
getAppointmentsByDateRange,
|
||||||
listarAgendamentos,
|
listarAgendamentos,
|
||||||
getUpcomingAppointments,
|
|
||||||
getNewUsersLastDays,
|
|
||||||
getPendingReports,
|
|
||||||
buscarMedicosPorIds,
|
buscarMedicosPorIds,
|
||||||
buscarPacientesPorIds,
|
buscarPacientesPorIds,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
// Dados fictícios para demonstração
|
// ============================================================================
|
||||||
const metricas = [
|
// Constants
|
||||||
{ label: "Atendimentos", value: 1240, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
// ============================================================================
|
||||||
{ label: "Absenteísmo", value: "7,2%", icon: <UserCheck className="w-6 h-6 text-red-500" /> },
|
|
||||||
{ label: "Satisfação", value: "Dados não foram disponibilizados", icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
|
|
||||||
{ label: "Faturamento (Mês)", value: "R$ 45.000", icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
|
||||||
{ label: "No-show", value: "5,1%", icon: <User className="w-6 h-6 text-yellow-500" /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const consultasPorPeriodo = [
|
const FALLBACK_MEDICOS = [
|
||||||
{ periodo: "Jan", consultas: 210 },
|
|
||||||
{ periodo: "Fev", consultas: 180 },
|
|
||||||
{ periodo: "Mar", consultas: 250 },
|
|
||||||
{ periodo: "Abr", consultas: 230 },
|
|
||||||
{ periodo: "Mai", consultas: 270 },
|
|
||||||
{ periodo: "Jun", consultas: 220 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const faturamentoMensal = [
|
|
||||||
{ mes: "Jan", valor: 35000 },
|
|
||||||
{ mes: "Fev", valor: 29000 },
|
|
||||||
{ mes: "Mar", valor: 42000 },
|
|
||||||
{ mes: "Abr", valor: 38000 },
|
|
||||||
{ mes: "Mai", valor: 45000 },
|
|
||||||
{ mes: "Jun", valor: 41000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const taxaNoShow = [
|
|
||||||
{ mes: "Jan", noShow: 6.2 },
|
|
||||||
{ mes: "Fev", noShow: 5.8 },
|
|
||||||
{ mes: "Mar", noShow: 4.9 },
|
|
||||||
{ mes: "Abr", noShow: 5.5 },
|
|
||||||
{ mes: "Mai", noShow: 5.1 },
|
|
||||||
{ mes: "Jun", noShow: 4.7 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// pacientesMaisAtendidos static list removed — data will be fetched from the API
|
|
||||||
|
|
||||||
const medicosMaisProdutivos = [
|
|
||||||
{ nome: "Dr. Carlos Andrade", consultas: 62 },
|
{ nome: "Dr. Carlos Andrade", consultas: 62 },
|
||||||
{ nome: "Dra. Paula Silva", consultas: 58 },
|
{ nome: "Dra. Paula Silva", consultas: 58 },
|
||||||
{ nome: "Dr. João Pedro", consultas: 54 },
|
{ nome: "Dr. João Pedro", consultas: 54 },
|
||||||
{ nome: "Dra. Marina Costa", consultas: 51 },
|
{ nome: "Dra. Marina Costa", consultas: 51 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const convenios = [
|
// ============================================================================
|
||||||
{ nome: "Unimed", valor: 18000 },
|
// Helper Functions
|
||||||
{ nome: "Bradesco", valor: 12000 },
|
// ============================================================================
|
||||||
{ nome: "SulAmérica", valor: 9000 },
|
|
||||||
{ nome: "Particular", valor: 15000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const performancePorMedico = [
|
|
||||||
{ nome: "Dr. Carlos Andrade", consultas: 62, absenteismo: 4.8 },
|
|
||||||
{ nome: "Dra. Paula Silva", consultas: 58, absenteismo: 6.1 },
|
|
||||||
{ nome: "Dr. João Pedro", consultas: 54, absenteismo: 7.5 },
|
|
||||||
{ nome: "Dra. Marina Costa", consultas: 51, absenteismo: 5.2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const COLORS = ["#10b981", "#6366f1", "#f59e42", "#ef4444"];
|
|
||||||
|
|
||||||
function exportPDF(title: string, content: string) {
|
function exportPDF(title: string, content: string) {
|
||||||
const doc = new jsPDF();
|
const doc = new jsPDF();
|
||||||
doc.text(title, 10, 10);
|
doc.text(title, 10, 10);
|
||||||
doc.text(content, 10, 20);
|
doc.text(content, 10, 20);
|
||||||
doc.save(`${title.toLowerCase().replace(/ /g, '-')}.pdf`);
|
doc.save(`${title.toLowerCase().replace(/ /g, "-")}.pdf`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export default function RelatoriosPage() {
|
export default function RelatoriosPage() {
|
||||||
// Local state that will be replaced by API data when available
|
// State
|
||||||
// Start with empty data to avoid showing fictitious frontend data while loading
|
|
||||||
const [metricsState, setMetricsState] = useState<Array<{ label: string; value: any; icon: any }>>([]);
|
const [metricsState, setMetricsState] = useState<Array<{ label: string; value: any; icon: any }>>([]);
|
||||||
const [consultasData, setConsultasData] = useState<Array<{ periodo: string; consultas: number }>>([]);
|
const [consultasData, setConsultasData] = useState<Array<{ periodo: string; consultas: number }>>([]);
|
||||||
const [faturamentoData, setFaturamentoData] = useState<Array<{ mes: string; valor: number }>>([]);
|
|
||||||
const [taxaNoShowState, setTaxaNoShowState] = useState<Array<{ mes: string; noShow: number }>>([]);
|
|
||||||
const [pacientesTop, setPacientesTop] = useState<Array<{ nome: string; consultas: number }>>([]);
|
const [pacientesTop, setPacientesTop] = useState<Array<{ nome: string; consultas: number }>>([]);
|
||||||
const [medicosTop, setMedicosTop] = useState(medicosMaisProdutivos);
|
const [medicosTop, setMedicosTop] = useState(FALLBACK_MEDICOS);
|
||||||
const [medicosPerformance, setMedicosPerformance] = useState<Array<{ nome: string; consultas: number; absenteismo: number }>>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [conveniosData, setConveniosData] = useState<Array<{ nome: string; valor: number }>>(convenios);
|
|
||||||
|
|
||||||
|
// Data Loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch counts in parallel, then try to fetch a larger appointments list via listarAgendamentos.
|
// Fetch appointments
|
||||||
// If listarAgendamentos fails (for example: unauthenticated), fall back to getAppointmentsByDateRange(30).
|
|
||||||
const [patientsCount, doctorsCount, appointmentsToday] = await Promise.all([
|
|
||||||
countTotalPatients().catch(() => 0),
|
|
||||||
countTotalDoctors().catch(() => 0),
|
|
||||||
countAppointmentsToday().catch(() => 0),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let appointments: any[] = [];
|
let appointments: any[] = [];
|
||||||
try {
|
try {
|
||||||
// Try to get a larger set of appointments (up to 1000) to compute top patients
|
appointments = await listarAgendamentos(
|
||||||
// select=patient_id,doctor_id,scheduled_at,status to reduce payload
|
"select=patient_id,doctor_id,scheduled_at,status&order=scheduled_at.desc&limit=1000"
|
||||||
// include insurance_provider so we can aggregate convênios client-side
|
);
|
||||||
appointments = await listarAgendamentos('select=patient_id,doctor_id,scheduled_at,status,insurance_provider&order=scheduled_at.desc&limit=1000');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback to the smaller helper if listarAgendamentos cannot be used (e.g., no auth token)
|
console.warn("[relatorios] listarAgendamentos failed, using fallback", e);
|
||||||
console.warn('[relatorios] listarAgendamentos falhou, usando getAppointmentsByDateRange fallback', e);
|
|
||||||
appointments = await getAppointmentsByDateRange(30).catch(() => []);
|
appointments = await getAppointmentsByDateRange(30).catch(() => []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch today's appointments count
|
||||||
|
let appointmentsToday = 0;
|
||||||
|
try {
|
||||||
|
appointmentsToday = await countAppointmentsToday().catch(() => 0);
|
||||||
|
} catch (e) {
|
||||||
|
appointmentsToday = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Update top metrics card
|
// ===== Build Consultas Chart (last 30 days) =====
|
||||||
setMetricsState([
|
|
||||||
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
|
||||||
{ label: "Absenteísmo", value: "—", icon: <UserCheck className="w-6 h-6 text-red-500" /> },
|
|
||||||
{ label: "Satisfação", value: "Dados não foram disponibilizados", icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
|
|
||||||
{ label: "Faturamento (Mês)", value: "—", icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
|
||||||
{ label: "No-show", value: "—", icon: <User className="w-6 h-6 text-yellow-500" /> },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Build last 30 days series for consultas
|
|
||||||
const daysCount = 30;
|
const daysCount = 30;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
const startTs = start.getTime() - (daysCount - 1) * 86400000; // include today
|
const startTs = start.getTime() - (daysCount - 1) * 86400000;
|
||||||
const dayBuckets: Record<string, { periodo: string; consultas: number }> = {};
|
const dayBuckets: Record<string, { periodo: string; consultas: number }> = {};
|
||||||
|
|
||||||
for (let i = 0; i < daysCount; i++) {
|
for (let i = 0; i < daysCount; i++) {
|
||||||
const d = new Date(startTs + i * 86400000);
|
const d = new Date(startTs + i * 86400000);
|
||||||
const iso = d.toISOString().split("T")[0];
|
const iso = d.toISOString().split("T")[0];
|
||||||
const periodo = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
|
const periodo = `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
dayBuckets[iso] = { periodo, consultas: 0 };
|
dayBuckets[iso] = { periodo, consultas: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count appointments per day
|
|
||||||
const appts = Array.isArray(appointments) ? appointments : [];
|
const appts = Array.isArray(appointments) ? appointments : [];
|
||||||
for (const a of appts) {
|
for (const a of appts) {
|
||||||
try {
|
try {
|
||||||
const iso = (a.scheduled_at || '').toString().split('T')[0];
|
const iso = (a.scheduled_at || "").toString().split("T")[0];
|
||||||
if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
|
if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore malformed
|
// ignore malformed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const consultasArr = Object.values(dayBuckets);
|
setConsultasData(Object.values(dayBuckets));
|
||||||
setConsultasData(consultasArr);
|
|
||||||
|
|
||||||
// Estimate monthly faturamento for last 6 months using doctor.valor_consulta when available
|
// ===== Aggregate Counts =====
|
||||||
const monthsBack = 6;
|
|
||||||
const monthMap: Record<string, { mes: string; valor: number; totalAppointments: number; noShowCount: number }> = {};
|
|
||||||
const nowMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
const monthKeys: string[] = [];
|
|
||||||
for (let i = monthsBack - 1; i >= 0; i--) {
|
|
||||||
const m = new Date(nowMonth.getFullYear(), nowMonth.getMonth() - i, 1);
|
|
||||||
const key = `${m.getFullYear()}-${String(m.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
monthKeys.push(key);
|
|
||||||
monthMap[key] = { mes: m.toLocaleString('pt-BR', { month: 'short' }), valor: 0, totalAppointments: 0, noShowCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter appointments within monthsBack and group
|
|
||||||
const apptsForMonths = appts.filter((a) => {
|
|
||||||
try {
|
|
||||||
const d = new Date(a.scheduled_at);
|
|
||||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
return key in monthMap;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Collect unique doctor ids to fetch valor_consulta in bulk
|
|
||||||
const doctorIds = Array.from(new Set(apptsForMonths.map((a: any) => String(a.doctor_id).trim()).filter(Boolean)));
|
|
||||||
const doctors = doctorIds.length ? await buscarMedicosPorIds(doctorIds) : [];
|
|
||||||
const doctorMap = new Map<string, any>();
|
|
||||||
for (const d of doctors) doctorMap.set(String(d.id), d);
|
|
||||||
|
|
||||||
for (const a of apptsForMonths) {
|
|
||||||
try {
|
|
||||||
const d = new Date(a.scheduled_at);
|
|
||||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
const doc = doctorMap.get(String(a.doctor_id));
|
|
||||||
const price = doc && doc.valor_consulta ? Number(doc.valor_consulta) : 0;
|
|
||||||
monthMap[key].valor += price;
|
|
||||||
monthMap[key].totalAppointments += 1;
|
|
||||||
if (String(a.status || '').toLowerCase() === 'no_show' || String(a.status || '').toLowerCase() === 'no-show') {
|
|
||||||
monthMap[key].noShowCount += 1;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const faturamentoArr = monthKeys.map((k) => ({ mes: monthMap[k].mes, valor: Math.round(monthMap[k].valor) }));
|
|
||||||
setFaturamentoData(faturamentoArr);
|
|
||||||
|
|
||||||
// Taxa no-show per month
|
|
||||||
const taxaArr = monthKeys.map((k) => {
|
|
||||||
const total = monthMap[k].totalAppointments || 0;
|
|
||||||
const noShow = monthMap[k].noShowCount || 0;
|
|
||||||
const pct = total ? Number(((noShow / total) * 100).toFixed(1)) : 0;
|
|
||||||
return { mes: monthMap[k].mes, noShow: pct };
|
|
||||||
});
|
|
||||||
setTaxaNoShowState(taxaArr);
|
|
||||||
|
|
||||||
// Top patients and doctors (by number of appointments in the period)
|
|
||||||
const patientCounts: Record<string, number> = {};
|
const patientCounts: Record<string, number> = {};
|
||||||
const doctorCounts: Record<string, number> = {};
|
const doctorCounts: Record<string, number> = {};
|
||||||
const doctorNoShowCounts: Record<string, number> = {};
|
const doctorNoShowCounts: Record<string, number> = {};
|
||||||
for (const a of apptsForMonths) {
|
|
||||||
if (a.patient_id) patientCounts[String(a.patient_id)] = (patientCounts[String(a.patient_id)] || 0) + 1;
|
for (const a of appts) {
|
||||||
|
if (a.patient_id) {
|
||||||
|
patientCounts[String(a.patient_id)] = (patientCounts[String(a.patient_id)] || 0) + 1;
|
||||||
|
}
|
||||||
if (a.doctor_id) {
|
if (a.doctor_id) {
|
||||||
const did = String(a.doctor_id);
|
const did = String(a.doctor_id);
|
||||||
doctorCounts[did] = (doctorCounts[did] || 0) + 1;
|
doctorCounts[did] = (doctorCounts[did] || 0) + 1;
|
||||||
const status = String(a.status || '').toLowerCase();
|
if (String(a.status || "").toLowerCase() === "no_show" || String(a.status || "").toLowerCase() === "no-show") {
|
||||||
if (status === 'no_show' || status === 'no-show') doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
|
doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const topPatientIds = Object.entries(patientCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map((x) => x[0]);
|
// ===== Top 5 Patients & Doctors =====
|
||||||
const topDoctorIds = Object.entries(doctorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map((x) => x[0]);
|
const topPatientIds = Object.entries(patientCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((x) => x[0]);
|
||||||
|
const topDoctorIds = Object.entries(doctorCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((x) => x[0]);
|
||||||
|
|
||||||
const [patientsFetched, doctorsFetched] = await Promise.all([
|
const [patientsFetched, doctorsFetched] = await Promise.all([
|
||||||
topPatientIds.length ? buscarPacientesPorIds(topPatientIds) : Promise.resolve([]),
|
topPatientIds.length ? buscarPacientesPorIds(topPatientIds) : Promise.resolve([]),
|
||||||
topDoctorIds.length ? buscarMedicosPorIds(topDoctorIds) : Promise.resolve([]),
|
topDoctorIds.length ? buscarMedicosPorIds(topDoctorIds) : Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ===== Build Patient List =====
|
||||||
const pacientesList = topPatientIds.map((id) => {
|
const pacientesList = topPatientIds.map((id) => {
|
||||||
const p = (patientsFetched || []).find((x: any) => String(x.id) === String(id));
|
const p = (patientsFetched || []).find((x: any) => String(x.id) === String(id));
|
||||||
return { nome: p ? p.full_name : id, consultas: patientCounts[id] || 0 };
|
return { nome: p ? p.full_name : id, consultas: patientCounts[id] || 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== Build Doctor List =====
|
||||||
const medicosList = topDoctorIds.map((id) => {
|
const medicosList = topDoctorIds.map((id) => {
|
||||||
const m = (doctorsFetched || []).find((x: any) => String(x.id) === String(id));
|
const m = (doctorsFetched || []).find((x: any) => String(x.id) === String(id));
|
||||||
return { nome: m ? m.full_name : id, consultas: doctorCounts[id] || 0 };
|
return { nome: m ? m.full_name : id, consultas: doctorCounts[id] || 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build performance list (consultas + absenteísmo)
|
// ===== Update State =====
|
||||||
const perfIds = Object.keys(doctorCounts).sort((a, b) => (doctorCounts[b] || 0) - (doctorCounts[a] || 0)).slice(0, 5);
|
|
||||||
const perfDoctors = (doctorsFetched && doctorsFetched.length) ? doctorsFetched : doctors;
|
|
||||||
const perfList = perfIds.map((id) => {
|
|
||||||
const d = (perfDoctors || []).find((x: any) => String(x.id) === String(id));
|
|
||||||
const consultas = doctorCounts[id] || 0;
|
|
||||||
const noShow = doctorNoShowCounts[id] || 0;
|
|
||||||
const absenteismo = consultas ? Number(((noShow / consultas) * 100).toFixed(1)) : 0;
|
|
||||||
return { nome: d ? d.full_name : id, consultas, absenteismo };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use fetched list (may be empty) — do not fall back to static data for patients, but keep fallback for medicosTop
|
|
||||||
setPacientesTop(pacientesList);
|
setPacientesTop(pacientesList);
|
||||||
setMedicosTop(medicosList.length ? medicosList : medicosMaisProdutivos);
|
setMedicosTop(medicosList.length ? medicosList : FALLBACK_MEDICOS);
|
||||||
setMedicosPerformance(perfList.length ? perfList.slice(0,5) : performancePorMedico.map((p) => ({ nome: p.nome, consultas: p.consultas, absenteismo: p.absenteismo })).slice(0,5));
|
|
||||||
|
|
||||||
// Aggregate convênios (insurance providers) from appointments in the period
|
|
||||||
try {
|
|
||||||
const providerCounts: Record<string, number> = {};
|
|
||||||
for (const a of apptsForMonths) {
|
|
||||||
let prov: any = a?.insurance_provider ?? a?.insuranceProvider ?? a?.insurance ?? '';
|
|
||||||
// If provider is an object, try to extract a human-friendly name
|
|
||||||
if (prov && typeof prov === 'object') prov = prov.name || prov.full_name || prov.title || '';
|
|
||||||
prov = String(prov || '').trim();
|
|
||||||
const key = prov || 'Não disponibilizado';
|
|
||||||
providerCounts[key] = (providerCounts[key] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let conveniosArr = Object.entries(providerCounts).map(([nome, valor]) => ({ nome, valor }));
|
|
||||||
if (!conveniosArr.length) {
|
|
||||||
// No provider info at all — present a single bucket showing the total count as 'Não disponibilizado'
|
|
||||||
conveniosArr = [{ nome: 'Não disponibilizado', valor: apptsForMonths.length }];
|
|
||||||
} else {
|
|
||||||
// Sort and keep top 5, group the rest into 'Outros'
|
|
||||||
conveniosArr.sort((a, b) => b.valor - a.valor);
|
|
||||||
if (conveniosArr.length > 5) {
|
|
||||||
const top = conveniosArr.slice(0, 5);
|
|
||||||
const others = conveniosArr.slice(5).reduce((s, c) => s + c.valor, 0);
|
|
||||||
top.push({ nome: 'Outros', valor: others });
|
|
||||||
conveniosArr = top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setConveniosData(conveniosArr);
|
|
||||||
} catch (e) {
|
|
||||||
// keep existing static conveniosData if something goes wrong
|
|
||||||
console.warn('[relatorios] erro ao agregar convênios', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update metrics cards with numbers we fetched
|
|
||||||
setMetricsState([
|
setMetricsState([
|
||||||
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
||||||
{ label: "Absenteísmo", value: '—', icon: <UserCheck className="w-6 h-6 text-red-500" /> },
|
|
||||||
{ label: "Satisfação", value: 'Dados não foram disponibilizados', icon: <ThumbsUp className="w-6 h-6 text-green-500" /> },
|
|
||||||
{ label: "Faturamento (Mês)", value: `R$ ${faturamentoArr.at(-1)?.valor ?? 0}`, icon: <DollarSign className="w-6 h-6 text-emerald-500" /> },
|
|
||||||
{ label: "No-show", value: `${taxaArr.at(-1)?.noShow ?? 0}%`, icon: <User className="w-6 h-6 text-yellow-500" /> },
|
|
||||||
] as any);
|
] as any);
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[relatorios] erro ao carregar dados', err);
|
console.error("[relatorios] error loading data:", err);
|
||||||
if (mounted) setError(err?.message ?? String(err));
|
if (mounted) setError(err?.message ?? String(err));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setLoading(false);
|
if (mounted) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
|
||||||
return () => { mounted = false; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
load();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []); return (
|
||||||
<div className="p-6 bg-background min-h-screen">
|
<div className="p-6 bg-background min-h-screen">
|
||||||
<h1 className="text-2xl font-bold mb-6 text-foreground">Dashboard Executivo de Relatórios</h1>
|
<h1 className="text-2xl font-bold mb-6 text-foreground">Dashboard Executivo de Relatórios</h1>
|
||||||
|
|
||||||
{/* Métricas principais */}
|
{/* Métricas principais */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-1 gap-6 mb-8">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
// simple skeletons while loading to avoid showing fake data
|
// simple skeletons while loading to avoid showing fake data
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
Array.from({ length: 1 }).map((_, i) => (
|
||||||
<div key={i} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
|
<div key={i} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
|
||||||
<div className="h-6 w-6 bg-muted rounded mb-2 animate-pulse" />
|
<div className="h-6 w-6 bg-muted rounded mb-2 animate-pulse" />
|
||||||
<div className="h-6 w-20 bg-muted rounded mt-2 animate-pulse" />
|
<div className="h-6 w-20 bg-muted rounded mt-2 animate-pulse" />
|
||||||
@ -344,13 +192,21 @@ export default function RelatoriosPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gráficos e Relatórios */}
|
{/* Consultas Chart */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
<div className="grid grid-cols-1 gap-8 mb-8">
|
||||||
{/* Consultas realizadas por período */}
|
|
||||||
<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-3 md:gap-0 mb-4">
|
||||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><BarChart2 className="w-5 h-5" /> Consultas por Período</h2>
|
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2">
|
||||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
<BarChart2 className="w-5 h-5" /> Consultas por Período
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="hover:bg-primary! hover:text-white! transition-colors w-full md:w-auto"
|
||||||
|
onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.")}
|
||||||
|
>
|
||||||
|
<FileDown className="w-4 h-4 mr-1" /> Exportar PDF
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
||||||
@ -366,62 +222,6 @@ export default function RelatoriosPage() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Faturamento mensal/anual */}
|
|
||||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><DollarSign className="w-5 h-5" /> Faturamento Mensal</h2>
|
|
||||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Faturamento Mensal", "Resumo do faturamento mensal.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
|
||||||
<LineChart data={faturamentoData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="mes" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Line type="monotone" dataKey="valor" stroke="#10b981" name="Faturamento" strokeWidth={3} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
|
||||||
{/* Taxa de no-show */}
|
|
||||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><UserCheck className="w-5 h-5" /> Taxa de No-show</h2>
|
|
||||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Taxa de No-show", "Resumo da taxa de no-show.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
|
||||||
<LineChart data={taxaNoShowState}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="mes" />
|
|
||||||
<YAxis unit="%" />
|
|
||||||
<Tooltip />
|
|
||||||
<Line type="monotone" dataKey="noShow" stroke="#ef4444" name="No-show (%)" strokeWidth={3} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Indicadores de satisfação */}
|
|
||||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><ThumbsUp className="w-5 h-5" /> Satisfação dos Pacientes</h2>
|
|
||||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Satisfação dos Pacientes", "Resumo dos indicadores de satisfação.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center h-[220px]">
|
|
||||||
<span className="text-2xl font-bold text-foreground">Dados não foram disponibilizados</span>
|
|
||||||
<span className="text-muted-foreground mt-2">Índice de satisfação geral</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
@ -462,7 +262,7 @@ export default function RelatoriosPage() {
|
|||||||
{/* Médicos mais produtivos */}
|
{/* Médicos mais produtivos */}
|
||||||
<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 items-center justify-between mb-2">
|
||||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Briefcase className="w-5 h-5" /> Médicos Mais Produtivos</h2>
|
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Médicos Mais Produtivos</h2>
|
||||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.")}> <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" onClick={() => exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm mt-4">
|
<table className="w-full text-sm mt-4">
|
||||||
@ -493,59 +293,6 @@ export default function RelatoriosPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
|
||||||
{/* Análise de convênios */}
|
|
||||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><DollarSign className="w-5 h-5" /> Análise de Convênios</h2>
|
|
||||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Análise de Convênios", "Resumo da análise de convênios.")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie data={conveniosData} dataKey="valor" nameKey="nome" cx="50%" cy="50%" outerRadius={80} label>
|
|
||||||
{conveniosData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Performance por médico */}
|
|
||||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
|
||||||
<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>
|
|
||||||
<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 className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-muted-foreground border-b border-border">
|
|
||||||
<th className="text-left font-medium py-3 px-2 md:px-0">Médico</th>
|
|
||||||
<th className="text-center font-medium py-3 px-2 md:px-0">Consultas</th>
|
|
||||||
<th className="text-center font-medium py-3 px-2 md:px-0">Absenteísmo (%)</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(loading ? performancePorMedico : medicosPerformance).map((m) => (
|
|
||||||
<tr key={m.nome} className="border-b border-border/50 hover:bg-muted/30 transition-colors">
|
|
||||||
<td className="py-3 px-2 md:px-0">{m.nome}</td>
|
|
||||||
<td className="py-3 px-2 md:px-0 text-center font-medium">{m.consultas}</td>
|
|
||||||
<td className="py-3 px-2 md:px-0 text-center text-blue-500 font-medium">{m.absenteismo}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -96,6 +96,136 @@ export default function LaudoPage() {
|
|||||||
window.print()
|
window.print()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDownloadPDF = async () => {
|
||||||
|
if (!report) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Para simplificar, vamos usar jsPDF com html2canvas para capturar o conteúdo
|
||||||
|
const { jsPDF } = await import('jspdf')
|
||||||
|
const html2canvas = await import('html2canvas').then((m) => m.default)
|
||||||
|
|
||||||
|
// Criar um elemento temporário com o conteúdo
|
||||||
|
const element = document.createElement('div')
|
||||||
|
element.style.position = 'absolute'
|
||||||
|
element.style.left = '-9999px'
|
||||||
|
element.style.width = '210mm' // A4 width
|
||||||
|
element.style.padding = '20mm'
|
||||||
|
element.style.backgroundColor = 'white'
|
||||||
|
element.style.fontFamily = 'Arial, sans-serif'
|
||||||
|
|
||||||
|
// Extrair informações
|
||||||
|
const reportDate = new Date(report.report_date || report.created_at || Date.now()).toLocaleDateString('pt-BR')
|
||||||
|
const cid = report.cid ?? report.cid_code ?? report.cidCode ?? report.cie ?? ''
|
||||||
|
const exam = report.exam ?? report.exame ?? report.especialidade ?? report.report_type ?? ''
|
||||||
|
const diagnosis = report.diagnosis ?? report.diagnostico ?? report.diagnosis_text ?? report.diagnostico_text ?? ''
|
||||||
|
const conclusion = report.conclusion ?? report.conclusao ?? report.conclusion_text ?? report.conclusao_text ?? ''
|
||||||
|
const notesText = report.content ?? report.body ?? report.conteudo ?? report.notes ?? report.observacoes ?? ''
|
||||||
|
|
||||||
|
// Extrair nome do médico
|
||||||
|
let doctorName = ''
|
||||||
|
if (doctor) {
|
||||||
|
doctorName = doctor.full_name || doctor.name || doctor.fullName || doctor.doctor_name || ''
|
||||||
|
}
|
||||||
|
if (!doctorName) {
|
||||||
|
const rd = report as any
|
||||||
|
const tryKeys = [
|
||||||
|
'doctor_name', 'doctor_full_name', 'doctorFullName', 'doctorName',
|
||||||
|
'requested_by_name', 'requested_by', 'requester_name', 'requester',
|
||||||
|
'created_by_name', 'created_by', 'executante', 'executante_name',
|
||||||
|
]
|
||||||
|
for (const k of tryKeys) {
|
||||||
|
const v = rd[k]
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '') {
|
||||||
|
doctorName = String(v)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Montar HTML do documento
|
||||||
|
element.innerHTML = `
|
||||||
|
<div style="border-bottom: 2px solid #3b82f6; padding-bottom: 10px; margin-bottom: 20px;">
|
||||||
|
<h1 style="text-align: center; font-size: 24px; font-weight: bold; color: #1f2937; margin: 0;">RELATÓRIO MÉDICO</h1>
|
||||||
|
<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Data: ${reportDate}</p>
|
||||||
|
${doctorName ? `<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Profissional: ${doctorName}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #f0f9ff; border: 1px solid #bfdbfe; padding: 10px; margin-bottom: 15px;">
|
||||||
|
<div style="display: flex; gap: 20px;">
|
||||||
|
${cid ? `<div><p style="font-size: 9px; font-weight: bold; color: #475569; margin: 0 0 5px 0;">CID</p><p style="font-size: 11px; font-weight: bold; color: #1f2937; margin: 0;">${cid}</p></div>` : ''}
|
||||||
|
${exam ? `<div><p style="font-size: 9px; font-weight: bold; color: #475569; margin: 0 0 5px 0;">EXAME / TIPO</p><p style="font-size: 11px; font-weight: bold; color: #1f2937; margin: 0;">${exam}</p></div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${diagnosis ? `
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">DIAGNÓSTICO</h2>
|
||||||
|
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${diagnosis}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${conclusion ? `
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">CONCLUSÃO</h2>
|
||||||
|
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${conclusion}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${notesText ? `
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">NOTAS DO PROFISSIONAL</h2>
|
||||||
|
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${notesText}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div style="margin-top: 30px; padding-top: 10px; border-top: 1px solid #e5e7eb; font-size: 8px; text-align: center; color: #9ca3af;">
|
||||||
|
Documento gerado em ${new Date().toLocaleString('pt-BR')}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
document.body.appendChild(element)
|
||||||
|
|
||||||
|
// Capturar como canvas
|
||||||
|
const canvas = await html2canvas(element, {
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
})
|
||||||
|
|
||||||
|
document.body.removeChild(element)
|
||||||
|
|
||||||
|
// Converter para PDF
|
||||||
|
const imgData = canvas.toDataURL('image/png')
|
||||||
|
const pdf = new jsPDF({
|
||||||
|
orientation: 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4',
|
||||||
|
})
|
||||||
|
|
||||||
|
const imgWidth = 210 // A4 width in mm
|
||||||
|
const pageHeight = 297 // A4 height in mm
|
||||||
|
const imgHeight = (canvas.height * imgWidth) / canvas.width
|
||||||
|
let heightLeft = imgHeight
|
||||||
|
|
||||||
|
let position = 0
|
||||||
|
|
||||||
|
while (heightLeft >= 0) {
|
||||||
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
|
||||||
|
heightLeft -= pageHeight
|
||||||
|
position -= pageHeight
|
||||||
|
if (heightLeft > 0) {
|
||||||
|
pdf.addPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download
|
||||||
|
pdf.save(`laudo-${reportDate}-${doctorName || 'profissional'}.pdf`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF:', error)
|
||||||
|
alert('Erro ao gerar PDF. Tente novamente.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
@ -158,7 +288,7 @@ export default function LaudoPage() {
|
|||||||
: 'bg-gradient-to-br from-slate-50 to-slate-100'
|
: 'bg-gradient-to-br from-slate-50 to-slate-100'
|
||||||
}`}>
|
}`}>
|
||||||
{/* Header Toolbar */}
|
{/* Header Toolbar */}
|
||||||
<div className={`sticky top-0 z-40 transition-colors duration-300 ${
|
<div className={`sticky top-0 z-40 transition-colors duration-300 print:hidden ${
|
||||||
isDark
|
isDark
|
||||||
? 'bg-slate-800 border-slate-700'
|
? 'bg-slate-800 border-slate-700'
|
||||||
: 'bg-white border-slate-200'
|
: 'bg-white border-slate-200'
|
||||||
@ -221,13 +351,13 @@ export default function LaudoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex justify-center py-12 px-4 min-h-[calc(100vh-80px)]">
|
<div className="flex justify-center py-12 px-4 print:py-0 print:px-0 min-h-[calc(100vh-80px)] print:min-h-screen">
|
||||||
{/* Document Container */}
|
{/* Document Container */}
|
||||||
<div className={`w-full max-w-4xl transition-colors duration-300 shadow-2xl rounded-xl overflow-hidden ${
|
<div className={`w-full max-w-4xl transition-colors duration-300 shadow-2xl rounded-xl overflow-hidden print:shadow-none print:rounded-none print:max-w-full ${
|
||||||
isDark ? 'bg-slate-800' : 'bg-white'
|
isDark ? 'bg-slate-800' : 'bg-white'
|
||||||
}`}>
|
}`}>
|
||||||
{/* Document Content */}
|
{/* Document Content */}
|
||||||
<div className="p-16 space-y-8 print:p-0 print:shadow-none">
|
<div className="p-16 space-y-8 print:p-12 print:space-y-6">
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className={`text-center mb-12 pb-8 border-b-2 ${
|
<div className={`text-center mb-12 pb-8 border-b-2 ${
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import Link from 'next/link'
|
|||||||
import ProtectedRoute from '@/components/shared/ProtectedRoute'
|
import ProtectedRoute from '@/components/shared/ProtectedRoute'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { buscarPacientes, buscarPacientePorUserId, getUserInfo, listarAgendamentos, buscarMedicosPorIds, buscarMedicos, atualizarPaciente, buscarPacientePorId, getDoctorById, atualizarAgendamento, deletarAgendamento } from '@/lib/api'
|
import { buscarPacientes, buscarPacientePorUserId, getUserInfo, listarAgendamentos, buscarMedicosPorIds, buscarMedicos, atualizarPaciente, buscarPacientePorId, getDoctorById, atualizarAgendamento, deletarAgendamento, addDeletedAppointmentId } from '@/lib/api'
|
||||||
import { CalendarRegistrationForm } from '@/components/features/forms/calendar-registration-form'
|
import { CalendarRegistrationForm } from '@/components/features/forms/calendar-registration-form'
|
||||||
import { buscarRelatorioPorId, listarRelatoriosPorMedico } from '@/lib/reports'
|
import { buscarRelatorioPorId, listarRelatoriosPorMedico } from '@/lib/reports'
|
||||||
import { ENV_CONFIG } from '@/lib/env-config'
|
import { ENV_CONFIG } from '@/lib/env-config'
|
||||||
@ -416,31 +416,31 @@ export default function PacientePage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:gap-4 mb-6 md:grid-cols-2">
|
||||||
<Card className="group rounded-2xl border border-border/60 bg-card/70 p-5 backdrop-blur-sm shadow-sm transition hover:shadow-md">
|
<Card className="group rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5 md:p-5 backdrop-blur-sm shadow-sm transition hover:shadow-md">
|
||||||
<div className="flex h-40 w-full flex-col items-center justify-center gap-3">
|
<div className="flex h-32 sm:h-36 md:h-40 w-full flex-col items-center justify-center gap-2 sm:gap-3">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
<div className="flex h-10 w-10 sm:h-11 sm:w-11 md:h-12 md:w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
<Calendar className="h-6 w-6" aria-hidden />
|
<Calendar className="h-5 w-5 sm:h-5 sm:w-5 md:h-6 md:w-6" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
{/* rótulo e número com mesma fonte e mesmo tamanho (harmônico) */}
|
{/* rótulo e número com mesma fonte e mesmo tamanho (harmônico) */}
|
||||||
<span className="text-lg md:text-xl font-semibold text-muted-foreground tracking-wide">
|
<span className="text-base sm:text-lg md:text-lg font-semibold text-muted-foreground tracking-wide">
|
||||||
{strings.proximaConsulta}
|
{strings.proximaConsulta}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
|
<span className="text-base sm:text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
|
||||||
{loading ? strings.carregando : (nextAppt ?? '-')}
|
{loading ? strings.carregando : (nextAppt ?? '-')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="group rounded-2xl border border-border/60 bg-card/70 p-5 backdrop-blur-sm shadow-sm transition hover:shadow-md">
|
<Card className="group rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5 md:p-5 backdrop-blur-sm shadow-sm transition hover:shadow-md">
|
||||||
<div className="flex h-40 w-full flex-col items-center justify-center gap-3">
|
<div className="flex h-32 sm:h-36 md:h-40 w-full flex-col items-center justify-center gap-2 sm:gap-3">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
<div className="flex h-10 w-10 sm:h-11 sm:w-11 md:h-12 md:w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
<FileText className="h-6 w-6" aria-hidden />
|
<FileText className="h-5 w-5 sm:h-5 sm:w-5 md:h-6 md:w-6" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg md:text-xl font-semibold text-muted-foreground tracking-wide">
|
<span className="text-base sm:text-lg md:text-lg font-semibold text-muted-foreground tracking-wide">
|
||||||
{strings.ultimosExames}
|
{strings.ultimosExames}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
|
<span className="text-base sm:text-lg md:text-xl font-semibold text-foreground" aria-live="polite">
|
||||||
{loading ? strings.carregando : (examsCount !== null ? String(examsCount) : '-')}
|
{loading ? strings.carregando : (examsCount !== null ? String(examsCount) : '-')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -610,6 +610,19 @@ export default function PacientePage() {
|
|||||||
hora: sched ? sched.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '',
|
hora: sched ? sched.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '',
|
||||||
status: a.status ? String(a.status) : 'Pendente',
|
status: a.status ? String(a.status) : 'Pendente',
|
||||||
}
|
}
|
||||||
|
}).filter((consulta: any) => {
|
||||||
|
// Filter out cancelled appointments (those with cancelled_at set OR status='cancelled')
|
||||||
|
const raw = rows.find((r: any) => String(r.id) === String(consulta.id));
|
||||||
|
if (!raw) return false;
|
||||||
|
|
||||||
|
// Check cancelled_at field
|
||||||
|
const cancelled = raw.cancelled_at;
|
||||||
|
if (cancelled && cancelled !== '' && cancelled !== 'null') return false;
|
||||||
|
|
||||||
|
// Check status field
|
||||||
|
if (raw.status && String(raw.status).toLowerCase() === 'cancelled') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
})
|
})
|
||||||
|
|
||||||
setDoctorsMap(doctorsMap)
|
setDoctorsMap(doctorsMap)
|
||||||
@ -700,16 +713,16 @@ export default function PacientePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="bg-linear-to-br from-card to-card/95 shadow-lg rounded-2xl border border-primary/10 p-8">
|
<section className="bg-linear-to-br from-card to-card/95 shadow-lg rounded-2xl border border-primary/10 p-4 sm:p-6 md:p-8">
|
||||||
<div className="max-w-3xl mx-auto space-y-8">
|
<div className="max-w-3xl mx-auto space-y-4 sm:space-y-6 md:space-y-8">
|
||||||
<header className="text-center space-y-4">
|
<header className="text-center space-y-2 sm:space-y-3 md:space-y-4">
|
||||||
<h2 className="text-4xl font-bold text-foreground">Agende sua próxima consulta</h2>
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-foreground">Agende sua próxima consulta</h2>
|
||||||
<p className="text-lg text-muted-foreground leading-relaxed">Escolha o formato ideal, selecione a especialidade e encontre o profissional perfeito para você.</p>
|
<p className="text-sm sm:text-base md:text-lg text-muted-foreground leading-relaxed">Escolha o formato ideal, selecione a especialidade e encontre o profissional perfeito para você.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="space-y-6 rounded-2xl border border-primary/15 bg-linear-to-r from-primary/5 to-primary/10 p-8 shadow-sm">
|
<div className="space-y-4 sm:space-y-6 rounded-2xl border border-primary/15 bg-linear-to-r from-primary/5 to-primary/10 p-4 sm:p-6 md:p-8 shadow-sm">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button asChild className="w-full md:w-auto px-10 py-3 bg-primary text-white hover:bg-primary/90! hover:text-white! transition-all duration-200 font-semibold text-base rounded-lg shadow-md hover:shadow-lg active:scale-95">
|
<Button asChild className="w-full sm:w-auto px-6 sm:px-8 md:px-10 py-2 sm:py-2.5 md:py-3 bg-primary text-white hover:bg-primary/90! hover:text-white! transition-all duration-200 font-semibold text-sm sm:text-base rounded-lg shadow-md hover:shadow-lg active:scale-95">
|
||||||
<Link href={buildResultadosHref()} prefetch={false}>
|
<Link href={buildResultadosHref()} prefetch={false}>
|
||||||
Pesquisar Médicos
|
Pesquisar Médicos
|
||||||
</Link>
|
</Link>
|
||||||
@ -720,15 +733,15 @@ export default function PacientePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Consultas Agendadas Section */}
|
{/* Consultas Agendadas Section */}
|
||||||
<section className="bg-card shadow-md rounded-lg border border-border p-6">
|
<section className="bg-card shadow-md rounded-lg border border-border p-4 sm:p-5 md:p-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-5 md:space-y-6">
|
||||||
<header>
|
<header>
|
||||||
<h2 className="text-3xl font-bold text-foreground mb-2">Suas Consultas Agendadas</h2>
|
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-foreground mb-1 sm:mb-2">Suas Consultas Agendadas</h2>
|
||||||
<p className="text-muted-foreground">Gerencie suas consultas confirmadas, pendentes ou canceladas.</p>
|
<p className="text-xs sm:text-sm md:text-base text-muted-foreground">Gerencie suas consultas confirmadas, pendentes ou canceladas.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Date Navigation */}
|
{/* Date Navigation */}
|
||||||
<div className="flex flex-col gap-4 rounded-2xl border border-primary/20 bg-linear-to-r from-primary/5 to-primary/10 p-6 sm:flex-row sm:items-center sm:justify-between shadow-sm">
|
<div className="flex flex-col gap-3 sm:gap-4 rounded-2xl border border-primary/20 bg-linear-to-r from-primary/5 to-primary/10 p-3 sm:p-4 md:p-6 sm:flex-row sm:items-center sm:justify-between shadow-sm">
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -738,9 +751,9 @@ export default function PacientePage() {
|
|||||||
aria-label="Dia anterior"
|
aria-label="Dia anterior"
|
||||||
className={`group shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all ${hoverPrimaryIconClass}`}
|
className={`group shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all ${hoverPrimaryIconClass}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-5 w-5 transition group-hover:text-white" />
|
<ChevronLeft className="h-4 w-4 sm:h-5 sm:w-5 transition group-hover:text-white" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-base sm:text-lg font-semibold text-foreground min-w-fit">{formatDatePt(currentDate)}</span>
|
<span className="text-sm sm:text-base md:text-lg font-semibold text-foreground min-w-fit">{formatDatePt(currentDate)}</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -749,7 +762,7 @@ export default function PacientePage() {
|
|||||||
aria-label="Próximo dia"
|
aria-label="Próximo dia"
|
||||||
className={`group shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all ${hoverPrimaryIconClass}`}
|
className={`group shadow-sm hover:bg-primary! hover:text-white! hover:border-primary! transition-all ${hoverPrimaryIconClass}`}
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-5 w-5 transition group-hover:text-white" />
|
<ChevronRight className="h-4 w-4 sm:h-5 sm:w-5 transition group-hover:text-white" />
|
||||||
</Button>
|
</Button>
|
||||||
{isSelectedDateToday && (
|
{isSelectedDateToday && (
|
||||||
<Button
|
<Button
|
||||||
@ -758,13 +771,14 @@ export default function PacientePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={goToToday}
|
onClick={goToToday}
|
||||||
disabled
|
disabled
|
||||||
className="border border-border/50 text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-[0.97] hover:bg-primary/5 hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-foreground"
|
className="border border-border/50 text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-[0.97] hover:bg-primary/5 hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-foreground text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
Hoje
|
Hoje
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-muted-foreground bg-background/50 px-4 py-2 rounded-lg">
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground bg-background/50 px-3 sm:px-4 py-1.5 sm:py-2 rounded-lg">
|
||||||
|
|
||||||
<span className="text-primary font-semibold">{_todaysAppointments.length}</span> consulta{_todaysAppointments.length !== 1 ? 's' : ''} agendada{_todaysAppointments.length !== 1 ? 's' : ''}
|
<span className="text-primary font-semibold">{_todaysAppointments.length}</span> consulta{_todaysAppointments.length !== 1 ? 's' : ''} agendada{_todaysAppointments.length !== 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -780,50 +794,50 @@ export default function PacientePage() {
|
|||||||
const todays = _todaysAppointments
|
const todays = _todaysAppointments
|
||||||
if (!todays || todays.length === 0) {
|
if (!todays || todays.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
<div className="flex flex-col items-center justify-center py-12 sm:py-16 px-3 sm:px-4">
|
||||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
<div className="rounded-full bg-primary/10 p-3 sm:p-4 mb-3 sm:mb-4">
|
||||||
<Calendar className="h-10 w-10 text-primary" />
|
<Calendar className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-bold text-foreground mb-2">Nenhuma consulta agendada para este dia</p>
|
<p className="text-lg sm:text-xl font-bold text-foreground mb-1 sm:mb-2">Nenhuma consulta agendada para este dia</p>
|
||||||
<p className="text-base text-muted-foreground text-center max-w-sm">Use a busca acima para marcar uma nova consulta ou navegue entre os dias.</p>
|
<p className="text-sm sm:text-base text-muted-foreground text-center max-w-sm">Use a busca acima para marcar uma nova consulta ou navegue entre os dias.</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return todays.map((consulta: any) => (
|
return todays.map((consulta: any) => (
|
||||||
<div
|
<div
|
||||||
key={consulta.id}
|
key={consulta.id}
|
||||||
className="rounded-2xl border border-primary/15 bg-card shadow-md hover:shadow-xl transition-all duration-300 p-6 hover:border-primary/30 hover:bg-card/95"
|
className="rounded-2xl border border-primary/15 bg-card shadow-md hover:shadow-xl transition-all duration-300 p-4 sm:p-5 md:p-6 hover:border-primary/30 hover:bg-card/95"
|
||||||
>
|
>
|
||||||
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-[1.5fr_0.8fr_1fr_1.2fr] items-start">
|
<div className="grid gap-4 sm:gap-5 md:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-[1.5fr_0.8fr_1fr_1.2fr] items-start">
|
||||||
{/* Doctor Info */}
|
{/* Doctor Info */}
|
||||||
<div className="flex items-start gap-4 min-w-0">
|
<div className="flex items-start gap-3 sm:gap-4 min-w-0">
|
||||||
<span
|
<span
|
||||||
className="mt-2 h-4 w-4 shrink-0 rounded-full shadow-sm"
|
className="mt-1 sm:mt-2 h-3 w-3 sm:h-4 sm:w-4 shrink-0 rounded-full shadow-sm"
|
||||||
style={{ backgroundColor: consulta.status === 'Confirmada' ? '#10b981' : consulta.status === 'Pendente' ? '#f59e0b' : '#ef4444' }}
|
style={{ backgroundColor: consulta.status === 'Confirmada' ? '#10b981' : consulta.status === 'Pendente' ? '#f59e0b' : '#ef4444' }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div className="space-y-3 min-w-0">
|
<div className="space-y-2 sm:space-y-3 min-w-0">
|
||||||
<div className="font-bold flex items-center gap-2.5 text-foreground text-lg leading-tight">
|
<div className="font-bold flex items-center gap-1.5 sm:gap-2.5 text-foreground text-sm sm:text-base md:text-lg leading-tight">
|
||||||
<Stethoscope className="h-5 w-5 text-primary shrink-0" />
|
<Stethoscope className="h-4 w-4 sm:h-5 sm:w-5 text-primary shrink-0" />
|
||||||
<span className="truncate">{consulta.medico}</span>
|
<span className="truncate">{consulta.medico}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground wrap-break-word leading-relaxed">
|
<p className="text-xs sm:text-sm text-muted-foreground wrap-break-word leading-relaxed">
|
||||||
<span className="font-medium text-foreground/70">{consulta.especialidade}</span>
|
<span className="font-medium text-foreground/70">{consulta.especialidade}</span>
|
||||||
<span className="mx-1.5">•</span>
|
<span className="mx-1 sm:mx-1.5">•</span>
|
||||||
<span>{consulta.local}</span>
|
<span>{consulta.local}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time */}
|
{/* Time */}
|
||||||
<div className="flex items-center justify-start gap-2.5 text-foreground">
|
<div className="flex items-center justify-start gap-2 sm:gap-2.5 text-foreground">
|
||||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
<Clock className="h-4 w-4 sm:h-5 sm:w-5 text-primary shrink-0" />
|
||||||
<span className="font-bold text-lg">{consulta.hora}</span>
|
<span className="font-bold text-sm sm:text-base md:text-lg">{consulta.hora}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
<div className="flex items-center justify-start">
|
<div className="flex items-center justify-start">
|
||||||
<span className={`px-4 py-2.5 rounded-full text-xs font-bold text-white shadow-md transition-all ${
|
<span className={`px-2 sm:px-3 md:px-4 py-1.5 sm:py-2 md:py-2.5 rounded-full text-xs font-bold text-white shadow-md transition-all ${
|
||||||
consulta.status === 'Confirmada'
|
consulta.status === 'Confirmada'
|
||||||
? 'bg-linear-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/20'
|
? 'bg-linear-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/20'
|
||||||
: consulta.status === 'Pendente'
|
: consulta.status === 'Pendente'
|
||||||
@ -839,7 +853,7 @@ export default function PacientePage() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border border-primary/30 text-primary bg-primary/5 hover:bg-primary! hover:text-white! hover:border-primary! transition-all duration-200 focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-95 text-xs font-semibold flex-1"
|
className="border border-primary/30 text-primary bg-primary/5 hover:bg-primary! hover:text-white! hover:border-primary! transition-all duration-200 focus-visible:ring-2 focus-visible:ring-primary/40 active:scale-95 text-xs sm:text-xs font-semibold flex-1"
|
||||||
onClick={() => setSelectedAppointment(consulta)}
|
onClick={() => setSelectedAppointment(consulta)}
|
||||||
>
|
>
|
||||||
Detalhes
|
Detalhes
|
||||||
@ -849,13 +863,15 @@ export default function PacientePage() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border border-destructive/30 text-destructive bg-destructive/5 hover:bg-destructive! hover:text-white! hover:border-destructive! transition-all duration-200 focus-visible:ring-2 focus-visible:ring-destructive/40 active:scale-95 text-xs font-semibold flex-1"
|
className="border border-destructive/30 text-destructive bg-destructive/5 hover:bg-destructive! hover:text-white! hover:border-destructive! transition-all duration-200 focus-visible:ring-2 focus-visible:ring-destructive/40 active:scale-95 text-xs sm:text-xs font-semibold flex-1"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const ok = typeof window !== 'undefined' ? window.confirm('Deseja realmente cancelar esta consulta?') : true
|
const ok = typeof window !== 'undefined' ? window.confirm('Deseja realmente cancelar esta consulta?') : true
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
// call API to delete
|
// call API to delete
|
||||||
await deletarAgendamento(consulta.id)
|
await deletarAgendamento(consulta.id)
|
||||||
|
// Mark as deleted in cache so it won't appear again
|
||||||
|
addDeletedAppointmentId(consulta.id)
|
||||||
// remove from local list
|
// remove from local list
|
||||||
setAppointments((prev) => {
|
setAppointments((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
@ -944,8 +960,10 @@ export default function PacientePage() {
|
|||||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||||
const [remoteMatch, setRemoteMatch] = useState<any | null>(null)
|
const [remoteMatch, setRemoteMatch] = useState<any | null>(null)
|
||||||
const [searchingRemote, setSearchingRemote] = useState<boolean>(false)
|
const [searchingRemote, setSearchingRemote] = useState<boolean>(false)
|
||||||
|
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest' | 'custom'>('newest')
|
||||||
|
const [filterDate, setFilterDate] = useState<string>('')
|
||||||
|
|
||||||
// derived filtered list based on search term
|
// derived filtered list based on search term and date filters
|
||||||
const filteredReports = useMemo(() => {
|
const filteredReports = useMemo(() => {
|
||||||
if (!reports || !Array.isArray(reports)) return []
|
if (!reports || !Array.isArray(reports)) return []
|
||||||
const qRaw = String(searchTerm || '').trim()
|
const qRaw = String(searchTerm || '').trim()
|
||||||
@ -964,8 +982,8 @@ export default function PacientePage() {
|
|||||||
return [remoteMatch]
|
return [remoteMatch]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!q) return reports
|
// Start with all reports or filtered by search
|
||||||
return reports.filter((r: any) => {
|
let filtered = !q ? reports : reports.filter((r: any) => {
|
||||||
try {
|
try {
|
||||||
const id = r.id ? String(r.id).toLowerCase() : ''
|
const id = r.id ? String(r.id).toLowerCase() : ''
|
||||||
const title = String(reportTitle(r) || '').toLowerCase()
|
const title = String(reportTitle(r) || '').toLowerCase()
|
||||||
@ -997,8 +1015,38 @@ export default function PacientePage() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Apply date filter if specified
|
||||||
|
if (filterDate) {
|
||||||
|
const filterDateObj = new Date(filterDate)
|
||||||
|
filterDateObj.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
filtered = filtered.filter((r: any) => {
|
||||||
|
const reportDateObj = new Date(r.report_date || r.created_at || Date.now())
|
||||||
|
reportDateObj.setHours(0, 0, 0, 0)
|
||||||
|
return reportDateObj.getTime() === filterDateObj.getTime()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
const sorted = [...filtered]
|
||||||
|
if (sortOrder === 'newest') {
|
||||||
|
sorted.sort((a: any, b: any) => {
|
||||||
|
const dateA = new Date(a.report_date || a.created_at || 0).getTime()
|
||||||
|
const dateB = new Date(b.report_date || b.created_at || 0).getTime()
|
||||||
|
return dateB - dateA // Newest first
|
||||||
|
})
|
||||||
|
} else if (sortOrder === 'oldest') {
|
||||||
|
sorted.sort((a: any, b: any) => {
|
||||||
|
const dateA = new Date(a.report_date || a.created_at || 0).getTime()
|
||||||
|
const dateB = new Date(b.report_date || b.created_at || 0).getTime()
|
||||||
|
return dateA - dateB // Oldest first
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [reports, searchTerm, doctorsMap, remoteMatch])
|
}, [reports, searchTerm, doctorsMap, remoteMatch, sortOrder, filterDate])
|
||||||
|
|
||||||
// When the search term looks like an id, attempt a direct fetch using the reports API
|
// When the search term looks like an id, attempt a direct fetch using the reports API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1377,28 +1425,72 @@ export default function PacientePage() {
|
|||||||
}, [reports])
|
}, [reports])
|
||||||
|
|
||||||
return (<>
|
return (<>
|
||||||
<section className="bg-card shadow-md rounded-lg border border-border p-6">
|
<section className="bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-6">
|
||||||
<h2 className="text-2xl font-bold mb-6">Laudos</h2>
|
<h2 className="text-xl sm:text-2xl md:text-2xl font-bold mb-4 sm:mb-5 md:mb-6">Laudos</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Search box: allow searching by id, doctor, exam, date or text */}
|
{/* Search box: allow searching by id, doctor, exam, date or text */}
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-3 sm:mb-4 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||||
<Input placeholder="Pesquisar laudo, médico, exame, data ou id" value={searchTerm} onChange={e => { setSearchTerm(e.target.value); setReportsPage(1) }} />
|
<Input placeholder="Pesquisar laudo, médico, exame, data ou id" value={searchTerm} onChange={e => { setSearchTerm(e.target.value); setReportsPage(1) }} className="text-xs sm:text-sm" />
|
||||||
{searchTerm && (
|
{searchTerm && (
|
||||||
<Button variant="ghost" onClick={() => { setSearchTerm(''); setReportsPage(1) }}>Limpar</Button>
|
<Button variant="ghost" onClick={() => { setSearchTerm(''); setReportsPage(1) }} className="text-xs sm:text-sm w-full sm:w-auto">Limpar</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Date filter and sort controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center flex-wrap">
|
||||||
|
{/* Sort buttons */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={sortOrder === 'newest' ? 'default' : 'outline'}
|
||||||
|
onClick={() => { setSortOrder('newest'); setReportsPage(1) }}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Mais Recente
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={sortOrder === 'oldest' ? 'default' : 'outline'}
|
||||||
|
onClick={() => { setSortOrder('oldest'); setReportsPage(1) }}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
Mais Antigo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date picker */}
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filterDate}
|
||||||
|
onChange={(e) => { setFilterDate(e.target.value); setReportsPage(1) }}
|
||||||
|
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 border border-border rounded bg-background"
|
||||||
|
/>
|
||||||
|
{filterDate && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setFilterDate(''); setReportsPage(1) }}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadingReports ? (
|
{loadingReports ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">{strings.carregando}</div>
|
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-muted-foreground">{strings.carregando}</div>
|
||||||
) : reportsError ? (
|
) : reportsError ? (
|
||||||
<div className="text-center py-8 text-red-600">{reportsError}</div>
|
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-red-600">{reportsError}</div>
|
||||||
) : (!reports || reports.length === 0) ? (
|
) : (!reports || reports.length === 0) ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">Nenhum laudo encontrado para este paciente.</div>
|
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-muted-foreground">Nenhum laudo encontrado para este paciente.</div>
|
||||||
) : (filteredReports.length === 0) ? (
|
) : (filteredReports.length === 0) ? (
|
||||||
searchingRemote ? (
|
searchingRemote ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">Buscando laudo...</div>
|
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-muted-foreground">Buscando laudo...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-muted-foreground">Nenhum laudo corresponde à pesquisa.</div>
|
<div className="text-center py-6 sm:py-8 text-xs sm:text-sm text-muted-foreground">Nenhum laudo corresponde à pesquisa.</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
@ -1415,31 +1507,31 @@ export default function PacientePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{pageItems.map((r) => (
|
{pageItems.map((r) => (
|
||||||
<div key={r.id || JSON.stringify(r)} className="flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-5">
|
<div key={r.id || JSON.stringify(r)} className="flex flex-col md:flex-row md:items-center md:justify-between bg-muted rounded p-3 sm:p-4 md:p-5 gap-3 md:gap-0">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
{(() => {
|
{(() => {
|
||||||
const maybeId = r?.doctor_id || r?.created_by || r?.doctor || null
|
const maybeId = r?.doctor_id || r?.created_by || r?.doctor || null
|
||||||
if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) {
|
if (resolvingDoctors && maybeId && !doctorsMap[String(maybeId)]) {
|
||||||
return <div className="font-medium text-muted-foreground text-lg md:text-xl">{strings.carregando}</div>
|
return <div className="font-medium text-xs sm:text-base md:text-lg text-muted-foreground">{strings.carregando}</div>
|
||||||
}
|
}
|
||||||
return <div className="font-medium text-foreground text-lg md:text-xl">{reportTitle(r)}</div>
|
return <div className="font-medium text-foreground text-sm sm:text-lg md:text-lg truncate">{reportTitle(r)}</div>
|
||||||
})()}
|
})()}
|
||||||
<div className="text-base md:text-base text-muted-foreground mt-1">Data: {new Date(r.report_date || r.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
|
<div className="text-xs sm:text-sm md:text-base text-muted-foreground mt-1">Data: {new Date(r.report_date || r.created_at || Date.now()).toLocaleDateString('pt-BR')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-2 md:mt-0">
|
<div className="flex gap-2 w-full md:w-auto flex-col sm:flex-row">
|
||||||
<Button variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={async () => { router.push(`/laudos/${r.id}`); }}>{strings.visualizarLaudo}</Button>
|
<Button variant="outline" className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm w-full md:w-auto" onClick={async () => { router.push(`/laudos/${r.id}`); }}>{strings.visualizarLaudo}</Button>
|
||||||
<Button variant="secondary" className="hover:bg-primary! hover:text-white! transition-colors" onClick={async () => { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado.' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>{strings.compartilhar}</Button>
|
<Button variant="secondary" className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm w-full md:w-auto" onClick={async () => { try { await navigator.clipboard.writeText(JSON.stringify(r)); setToast({ type: 'success', msg: 'Laudo copiado.' }) } catch { setToast({ type: 'error', msg: 'Falha ao copiar.' }) } }}>{strings.compartilhar}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Pagination controls */}
|
{/* Pagination controls */}
|
||||||
<div className="flex items-center justify-between mt-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-2 mt-4">
|
||||||
<div className="text-sm text-muted-foreground">Mostrando {Math.min(start+1, total)}–{Math.min(end, total)} de {total}</div>
|
<div className="text-xs sm:text-sm text-muted-foreground">Mostrando {Math.min(start+1, total)}–{Math.min(end, total)} de {total}</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between sm:justify-end gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => setReportsPage(p => Math.max(1, p-1))} disabled={page <= 1} className="px-3">Anterior</Button>
|
<Button size="sm" variant="outline" onClick={() => setReportsPage(p => Math.max(1, p-1))} disabled={page <= 1} className="px-2 sm:px-3 text-xs sm:text-sm">Anterior</Button>
|
||||||
<div className="text-sm text-muted-foreground">{page} / {totalPages}</div>
|
<div className="text-xs sm:text-sm text-muted-foreground">{page} / {totalPages}</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => setReportsPage(p => Math.min(totalPages, p+1))} disabled={page >= totalPages} className="px-3">Próxima</Button>
|
<Button size="sm" variant="outline" onClick={() => setReportsPage(p => Math.min(totalPages, p+1))} disabled={page >= totalPages} className="px-2 sm:px-3 text-xs sm:text-sm">Próxima</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -1459,30 +1551,31 @@ export default function PacientePage() {
|
|||||||
|
|
||||||
function Perfil() {
|
function Perfil() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-10 md:px-8">
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 sm:gap-5 md:gap-6 px-3 sm:px-4 md:px-8 py-6 sm:py-8 md:py-10">
|
||||||
{/* Header com Título e Botão */}
|
{/* Header com Título e Botão */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-3xl font-bold">Meu Perfil</h2>
|
<h2 className="text-2xl sm:text-2xl md:text-3xl font-bold">Meu Perfil</h2>
|
||||||
<p className="text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
|
<p className="text-xs sm:text-sm md:text-base text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
|
||||||
</div>
|
</div>
|
||||||
{!isEditingProfile ? (
|
{!isEditingProfile ? (
|
||||||
<Button
|
<Button
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700 w-full sm:w-auto whitespace-nowrap text-xs sm:text-sm"
|
||||||
onClick={() => setIsEditingProfile(true)}
|
onClick={() => setIsEditingProfile(true)}
|
||||||
>
|
>
|
||||||
✏️ Editar Perfil
|
✏️ Editar Perfil
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||||
<Button
|
<Button
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 hover:bg-green-700 text-xs sm:text-sm"
|
||||||
onClick={handleSaveProfile}
|
onClick={handleSaveProfile}
|
||||||
>
|
>
|
||||||
✓ Salvar
|
✓ Salvar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
onClick={handleCancelEdit}
|
onClick={handleCancelEdit}
|
||||||
>
|
>
|
||||||
✕ Cancelar
|
✕ Cancelar
|
||||||
@ -1492,20 +1585,20 @@ export default function PacientePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid de 3 colunas (2 + 1) */}
|
{/* Grid de 3 colunas (2 + 1) */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6">
|
||||||
{/* Coluna Esquerda - Informações Pessoais */}
|
{/* Coluna Esquerda - Informações Pessoais */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-4 sm:space-y-5 md:space-y-6">
|
||||||
{/* Informações Pessoais */}
|
{/* Informações Pessoais */}
|
||||||
<div className="border border-border rounded-lg p-6">
|
<div className="border border-border rounded-lg p-3 sm:p-4 md:p-6">
|
||||||
<h3 className="text-lg font-semibold mb-4">Informações Pessoais</h3>
|
<h3 className="text-base sm:text-lg md:text-lg font-semibold mb-3 sm:mb-4">Informações Pessoais</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{/* Nome Completo */}
|
{/* Nome Completo */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium text-muted-foreground">
|
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
|
||||||
Nome Completo
|
Nome Completo
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-2 p-3 bg-muted rounded text-foreground font-medium">
|
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground font-medium">
|
||||||
{profileData.nome || "Não preenchido"}
|
{profileData.nome || "Não preenchido"}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
@ -1515,10 +1608,10 @@ export default function PacientePage() {
|
|||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium text-muted-foreground">
|
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
|
||||||
Email
|
Email
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
|
||||||
{profileData.email || "Não preenchido"}
|
{profileData.email || "Não preenchido"}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
@ -1528,19 +1621,19 @@ export default function PacientePage() {
|
|||||||
|
|
||||||
{/* Telefone */}
|
{/* Telefone */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium text-muted-foreground">
|
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
|
||||||
Telefone
|
Telefone
|
||||||
</Label>
|
</Label>
|
||||||
{isEditingProfile ? (
|
{isEditingProfile ? (
|
||||||
<Input
|
<Input
|
||||||
value={profileData.telefone || ""}
|
value={profileData.telefone || ""}
|
||||||
onChange={(e) => handleProfileChange('telefone', e.target.value)}
|
onChange={(e) => handleProfileChange('telefone', e.target.value)}
|
||||||
className="mt-2"
|
className="mt-2 text-xs sm:text-sm"
|
||||||
placeholder="(00) 00000-0000"
|
placeholder="(00) 00000-0000"
|
||||||
maxLength={15}
|
maxLength={15}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
|
||||||
{profileData.telefone || "Não preenchido"}
|
{profileData.telefone || "Não preenchido"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1549,24 +1642,24 @@ export default function PacientePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Endereço e Contato */}
|
{/* Endereço e Contato */}
|
||||||
<div className="border border-border rounded-lg p-6">
|
<div className="border border-border rounded-lg p-3 sm:p-4 md:p-6">
|
||||||
<h3 className="text-lg font-semibold mb-4">Endereço e Contato</h3>
|
<h3 className="text-base sm:text-lg md:text-lg font-semibold mb-3 sm:mb-4">Endereço e Contato</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{/* Logradouro */}
|
{/* Logradouro */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium text-muted-foreground">
|
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
|
||||||
Logradouro
|
Logradouro
|
||||||
</Label>
|
</Label>
|
||||||
{isEditingProfile ? (
|
{isEditingProfile ? (
|
||||||
<Input
|
<Input
|
||||||
value={profileData.endereco || ""}
|
value={profileData.endereco || ""}
|
||||||
onChange={(e) => handleProfileChange('endereco', e.target.value)}
|
onChange={(e) => handleProfileChange('endereco', e.target.value)}
|
||||||
className="mt-2"
|
className="mt-2 text-xs sm:text-sm"
|
||||||
placeholder="Rua, avenida, etc."
|
placeholder="Rua, avenida, etc."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
|
||||||
{profileData.endereco || "Não preenchido"}
|
{profileData.endereco || "Não preenchido"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1574,18 +1667,18 @@ export default function PacientePage() {
|
|||||||
|
|
||||||
{/* Cidade */}
|
{/* Cidade */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium text-muted-foreground">
|
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
|
||||||
Cidade
|
Cidade
|
||||||
</Label>
|
</Label>
|
||||||
{isEditingProfile ? (
|
{isEditingProfile ? (
|
||||||
<Input
|
<Input
|
||||||
value={profileData.cidade || ""}
|
value={profileData.cidade || ""}
|
||||||
onChange={(e) => handleProfileChange('cidade', e.target.value)}
|
onChange={(e) => handleProfileChange('cidade', e.target.value)}
|
||||||
className="mt-2"
|
className="mt-2 text-xs sm:text-sm"
|
||||||
placeholder="São Paulo"
|
placeholder="São Paulo"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
|
||||||
{profileData.cidade || "Não preenchido"}
|
{profileData.cidade || "Não preenchido"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1593,18 +1686,18 @@ export default function PacientePage() {
|
|||||||
|
|
||||||
{/* CEP */}
|
{/* CEP */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium text-muted-foreground">
|
<Label className="text-xs sm:text-sm md:text-sm font-medium text-muted-foreground">
|
||||||
CEP
|
CEP
|
||||||
</Label>
|
</Label>
|
||||||
{isEditingProfile ? (
|
{isEditingProfile ? (
|
||||||
<Input
|
<Input
|
||||||
value={profileData.cep || ""}
|
value={profileData.cep || ""}
|
||||||
onChange={(e) => handleProfileChange('cep', e.target.value)}
|
onChange={(e) => handleProfileChange('cep', e.target.value)}
|
||||||
className="mt-2"
|
className="mt-2 text-xs sm:text-sm"
|
||||||
placeholder="00000-000"
|
placeholder="00000-000"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
<div className="mt-2 p-2 sm:p-3 bg-muted rounded text-xs sm:text-sm md:text-base text-foreground">
|
||||||
{profileData.cep || "Não preenchido"}
|
{profileData.cep || "Não preenchido"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1615,11 +1708,11 @@ export default function PacientePage() {
|
|||||||
|
|
||||||
{/* Coluna Direita - Foto do Perfil */}
|
{/* Coluna Direita - Foto do Perfil */}
|
||||||
<div>
|
<div>
|
||||||
<div className="border border-border rounded-lg p-6">
|
<div className="border border-border rounded-lg p-3 sm:p-4 md:p-6">
|
||||||
<h3 className="text-lg font-semibold mb-4">Foto do Perfil</h3>
|
<h3 className="text-base sm:text-lg md:text-lg font-semibold mb-3 sm:mb-4">Foto do Perfil</h3>
|
||||||
|
|
||||||
{isEditingProfile ? (
|
{isEditingProfile ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<UploadAvatar
|
<UploadAvatar
|
||||||
userId={profileData.id}
|
userId={profileData.id}
|
||||||
currentAvatarUrl={profileData.foto_url || "/avatars/01.png"}
|
currentAvatarUrl={profileData.foto_url || "/avatars/01.png"}
|
||||||
@ -1628,15 +1721,15 @@ export default function PacientePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-3 sm:gap-4">
|
||||||
<Avatar className="h-24 w-24">
|
<Avatar className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28">
|
||||||
<AvatarFallback className="bg-primary text-primary-foreground text-2xl font-bold">
|
<AvatarFallback className="bg-primary text-primary-foreground text-lg sm:text-xl md:text-2xl font-bold">
|
||||||
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'}
|
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs sm:text-sm md:text-base text-muted-foreground">
|
||||||
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'}
|
{profileData.nome?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'PC'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -1652,24 +1745,24 @@ export default function PacientePage() {
|
|||||||
// Renderização principal
|
// Renderização principal
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute requiredUserType={["paciente"]}>
|
<ProtectedRoute requiredUserType={["paciente"]}>
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-2 sm:px-4 py-6 sm:py-8">
|
||||||
{/* Header com informações do paciente */}
|
{/* Header com informações do paciente */}
|
||||||
<header className="sticky top-0 z-40 bg-card shadow-md rounded-lg border border-border p-4 mb-6 flex items-center justify-between">
|
<header className="sticky top-0 z-40 bg-card shadow-md rounded-lg border border-border p-3 sm:p-4 md:p-4 mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
<Avatar className="h-12 w-12">
|
<Avatar className="h-10 w-10 sm:h-12 sm:w-12 md:h-12 md:w-12">
|
||||||
<AvatarFallback className="bg-primary text-white font-bold">{profileData.nome?.charAt(0) || 'P'}</AvatarFallback>
|
<AvatarFallback className="bg-primary text-white font-bold text-sm sm:text-base">{profileData.nome?.charAt(0) || 'P'}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="text-sm text-muted-foreground">Conta do paciente</span>
|
<span className="text-xs sm:text-sm md:text-sm text-muted-foreground">Conta do paciente</span>
|
||||||
<span className="font-bold text-lg leading-none">{profileData.nome || 'Paciente'}</span>
|
<span className="font-bold text-sm sm:text-base md:text-lg leading-none">{profileData.nome || 'Paciente'}</span>
|
||||||
<span className="text-sm text-muted-foreground truncate">{profileData.email || 'Email não disponível'}</span>
|
<span className="text-xs sm:text-sm md:text-sm text-muted-foreground truncate">{profileData.email || 'Email não disponível'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 sm:gap-3 w-full sm:w-auto">
|
||||||
<SimpleThemeToggle />
|
<SimpleThemeToggle />
|
||||||
<Button asChild variant="outline" className="hover:bg-primary! hover:text-white! hover:border-primary! transition-colors">
|
<Button asChild variant="outline" className="hover:bg-primary! hover:text-white! hover:border-primary! transition-colors flex-1 sm:flex-none text-xs sm:text-sm">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Home className="h-4 w-4 mr-1" /> Início
|
<Home className="h-3 w-3 sm:h-4 sm:w-4 mr-1" /> Início
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -1685,42 +1778,42 @@ export default function PacientePage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Layout com sidebar e conteúdo */}
|
{/* Layout com sidebar e conteúdo */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-[220px_1fr] gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-[200px_1fr] lg:grid-cols-[220px_1fr] gap-4 sm:gap-5 md:gap-6">
|
||||||
{/* Sidebar vertical - sticky */}
|
{/* Sidebar vertical - sticky */}
|
||||||
<aside className="sticky top-24 h-fit">
|
<aside className="sticky top-24 h-fit md:top-24">
|
||||||
<nav aria-label="Navegação do dashboard" className="bg-card shadow-md rounded-lg border border-border p-3 space-y-1 z-30">
|
<nav aria-label="Navegação do dashboard" className="bg-card shadow-md rounded-lg border border-border p-2 sm:p-3 md:p-3 space-y-1 z-30 flex md:flex-col flex-row md:overflow-auto overflow-x-auto">
|
||||||
<Button
|
<Button
|
||||||
variant={tab==='dashboard'?'default':'ghost'}
|
variant={tab==='dashboard'?'default':'ghost'}
|
||||||
aria-current={tab==='dashboard'}
|
aria-current={tab==='dashboard'}
|
||||||
onClick={()=>setTab('dashboard')}
|
onClick={()=>setTab('dashboard')}
|
||||||
className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
|
className={`flex-1 md:flex-none md:w-full flex items-center justify-center md:justify-start gap-1 md:gap-2 transition-colors hover:bg-primary! hover:text-white! cursor-pointer text-xs sm:text-sm`}
|
||||||
>
|
>
|
||||||
<Calendar className="mr-2 h-4 w-4" />{strings.dashboard}
|
<Calendar className="h-3 w-3 sm:h-4 sm:w-4 md:h-4 md:w-4 flex-shrink-0" /><span>{strings.dashboard}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={tab==='consultas'?'default':'ghost'}
|
variant={tab==='consultas'?'default':'ghost'}
|
||||||
aria-current={tab==='consultas'}
|
aria-current={tab==='consultas'}
|
||||||
onClick={()=>setTab('consultas')}
|
onClick={()=>setTab('consultas')}
|
||||||
className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
|
className={`flex-1 md:flex-none md:w-full flex items-center justify-center md:justify-start gap-1 md:gap-2 transition-colors hover:bg-primary! hover:text-white! cursor-pointer text-xs sm:text-sm`}
|
||||||
>
|
>
|
||||||
<Calendar className="mr-2 h-4 w-4" />{strings.consultas}
|
<Calendar className="h-3 w-3 sm:h-4 sm:w-4 md:h-4 md:w-4 flex-shrink-0" /><span>{strings.consultas}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={tab==='exames'?'default':'ghost'}
|
variant={tab==='exames'?'default':'ghost'}
|
||||||
aria-current={tab==='exames'}
|
aria-current={tab==='exames'}
|
||||||
onClick={()=>setTab('exames')}
|
onClick={()=>setTab('exames')}
|
||||||
className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
|
className={`flex-1 md:flex-none md:w-full flex items-center justify-center md:justify-start gap-1 md:gap-2 transition-colors hover:bg-primary! hover:text-white! cursor-pointer text-xs sm:text-sm`}
|
||||||
>
|
>
|
||||||
<FileText className="mr-2 h-4 w-4" />{strings.exames}
|
<FileText className="h-3 w-3 sm:h-4 sm:w-4 md:h-4 md:w-4 flex-shrink-0" /><span>{strings.exames}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={tab==='perfil'?'default':'ghost'}
|
variant={tab==='perfil'?'default':'ghost'}
|
||||||
aria-current={tab==='perfil'}
|
aria-current={tab==='perfil'}
|
||||||
onClick={()=>setTab('perfil')}
|
onClick={()=>setTab('perfil')}
|
||||||
className={`w-full justify-start transition-colors hover:bg-primary! hover:text-white! cursor-pointer`}
|
className={`flex-1 md:flex-none md:w-full flex items-center justify-center md:justify-start gap-1 md:gap-2 transition-colors hover:bg-primary! hover:text-white! cursor-pointer text-xs sm:text-sm`}
|
||||||
>
|
>
|
||||||
<UserCog className="mr-2 h-4 w-4" />{strings.perfil}
|
<UserCog className="h-3 w-3 sm:h-4 sm:w-4 md:h-4 md:w-4 flex-shrink-0" /><span>{strings.perfil}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@ -1729,12 +1822,12 @@ export default function PacientePage() {
|
|||||||
<main className="flex-1 w-full">
|
<main className="flex-1 w-full">
|
||||||
{/* Toasts de feedback */}
|
{/* Toasts de feedback */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className={`fixed top-24 right-4 z-50 px-4 py-2 rounded shadow-lg ${toast.type==='success'?'bg-green-600 text-white':'bg-red-600 text-white'}`} role="alert">{toast.msg}</div>
|
<div className={`fixed top-24 right-2 sm:right-4 z-50 px-3 sm:px-4 py-2 rounded shadow-lg text-xs sm:text-sm ${toast.type==='success'?'bg-green-600 text-white':'bg-red-600 text-white'}`} role="alert">{toast.msg}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loader global */}
|
{/* Loader global */}
|
||||||
{loading && <div className="flex-1 flex items-center justify-center"><span>{strings.carregando}</span></div>}
|
{loading && <div className="flex-1 flex items-center justify-center"><span className="text-xs sm:text-sm">{strings.carregando}</span></div>}
|
||||||
{error && <div className="flex-1 flex items-center justify-center text-red-600"><span>{error}</span></div>}
|
{error && <div className="flex-1 flex items-center justify-center text-red-600"><span className="text-xs sm:text-sm">{error}</span></div>}
|
||||||
|
|
||||||
{/* Conteúdo principal */}
|
{/* Conteúdo principal */}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
|
|||||||
@ -642,7 +642,7 @@ export default function ResultadosClient() {
|
|||||||
// Render
|
// Render
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-10 md:px-8">
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:py-10 md:px-8">
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded shadow-lg ${toast.type==='success'?'bg-green-600 text-white':'bg-red-600 text-white'}`} role="alert">
|
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded shadow-lg ${toast.type==='success'?'bg-green-600 text-white':'bg-red-600 text-white'}`} role="alert">
|
||||||
@ -699,10 +699,10 @@ export default function ResultadosClient() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Hero de filtros (mantido) */}
|
{/* Hero de filtros (mantido) */}
|
||||||
<section className="rounded-3xl bg-primary p-6 text-primary-foreground shadow-lg">
|
<section className="rounded-2xl sm:rounded-3xl bg-primary p-4 sm:p-6 text-primary-foreground shadow-lg">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold md:text-3xl">Resultados da procura</h1>
|
<h1 className="text-xl font-semibold sm:text-2xl md:text-3xl">Resultados da procura</h1>
|
||||||
<p className="text-sm text-primary-foreground/80">Qual especialização você deseja?</p>
|
<p className="text-sm text-primary-foreground/80">Qual especialização você deseja?</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@ -712,14 +712,14 @@ export default function ResultadosClient() {
|
|||||||
Ajustar filtros
|
Ajustar filtros
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
<div className="mt-4 sm:mt-6 flex flex-wrap gap-2 sm:gap-3">
|
||||||
{especialidadesHero.map(item => (
|
{especialidadesHero.map(item => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEspecialidadeHero(item)}
|
onClick={() => setEspecialidadeHero(item)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full px-5 py-2 text-sm font-medium transition focus-visible:ring-2 focus-visible:ring-primary-foreground/80',
|
'rounded-full px-4 sm:px-5 py-2 text-sm font-medium transition focus-visible:ring-2 focus-visible:ring-primary-foreground/80',
|
||||||
especialidadeHero === item ? 'bg-primary-foreground text-primary' : 'bg-primary-foreground/10'
|
especialidadeHero === item ? 'bg-primary-foreground text-primary' : 'bg-primary-foreground/10'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -729,103 +729,123 @@ export default function ResultadosClient() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Barra de filtros secundários (mantida) */}
|
{/* Barra de filtros secundários (agora fluída, sem sticky) */}
|
||||||
<section className="sticky top-0 z-30 flex flex-wrap gap-3 rounded-2xl border border-border bg-card/90 p-4 shadow-lg backdrop-blur">
|
<section className="rounded-2xl border border-border bg-card/80 p-4 sm:p-5 shadow-md backdrop-blur">
|
||||||
<Toggle
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3">
|
||||||
pressed={tipoConsulta === 'teleconsulta'}
|
{/* Segmented control: tipo da consulta */}
|
||||||
onPressedChange={() => setTipoConsulta('teleconsulta')}
|
<div className="sm:col-span-12">
|
||||||
className={cn('rounded-full px-4 py-2.5 text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
|
<div className="flex w-full overflow-hidden rounded-full border border-primary/25 bg-primary/5 shadow-sm ring-1 ring-primary/10">
|
||||||
tipoConsulta === 'teleconsulta' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
|
<Toggle
|
||||||
>
|
pressed={tipoConsulta === 'teleconsulta'}
|
||||||
<Globe className="mr-2 h-4 w-4" />
|
onPressedChange={() => setTipoConsulta('teleconsulta')}
|
||||||
Teleconsulta
|
className="flex-1 rounded-none first:rounded-l-full px-4 py-2.5 text-sm font-medium transition data-[state=on]:bg-primary data-[state=on]:text-primary-foreground hover:bg-primary/10"
|
||||||
</Toggle>
|
>
|
||||||
<Toggle
|
<Globe className="mr-2 h-4 w-4" />
|
||||||
pressed={tipoConsulta === 'local'}
|
Teleconsulta
|
||||||
onPressedChange={() => setTipoConsulta('local')}
|
</Toggle>
|
||||||
className={cn('rounded-full px-4 py-2.5 text-sm font-medium transition hover:bg-primary hover:text-primary-foreground focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-[0.97]',
|
<Toggle
|
||||||
tipoConsulta === 'local' ? 'bg-primary text-primary-foreground' : 'border border-primary/40 text-primary')}
|
pressed={tipoConsulta === 'local'}
|
||||||
>
|
onPressedChange={() => setTipoConsulta('local')}
|
||||||
<Building2 className="mr-2 h-4 w-4" />
|
className="flex-1 rounded-none last:rounded-r-full px-4 py-2.5 text-sm font-medium transition data-[state=on]:bg-primary data-[state=on]:text-primary-foreground hover:bg-primary/10"
|
||||||
Consulta no local
|
>
|
||||||
</Toggle>
|
<Building2 className="mr-2 h-4 w-4" />
|
||||||
|
Consulta no local
|
||||||
|
</Toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Select value={convenio} onValueChange={setConvenio}>
|
{/* divider visual */}
|
||||||
<SelectTrigger className="h-10 min-w-[180px] rounded-full border border-primary/40 bg-primary/10 text-primary transition duration-200 hover:border-primary! focus:ring-2 focus:ring-primary cursor-pointer">
|
<div className="sm:col-span-12 h-px bg-border/60 my-1" />
|
||||||
<SelectValue placeholder="Convênio" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Todos">Todos os convênios</SelectItem>
|
|
||||||
<SelectItem value="Amil">Amil</SelectItem>
|
|
||||||
<SelectItem value="Unimed">Unimed</SelectItem>
|
|
||||||
<SelectItem value="SulAmérica">SulAmérica</SelectItem>
|
|
||||||
<SelectItem value="Bradesco Saúde">Bradesco Saúde</SelectItem>
|
|
||||||
<SelectItem value="Particular">Particular</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* Search input para buscar médico por nome (movido antes do Select de bairro para ficar ao lado visualmente) */}
|
{/* Convênio */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="sm:col-span-6 lg:col-span-4">
|
||||||
<Input
|
<Select value={convenio} onValueChange={setConvenio}>
|
||||||
placeholder="Buscar médico por nome"
|
<SelectTrigger className="h-10 w-full rounded-full border border-primary/30 bg-primary/5 text-primary hover:border-primary focus:ring-2 focus:ring-primary">
|
||||||
value={searchQuery}
|
<SelectValue placeholder="Convênio" />
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
</SelectTrigger>
|
||||||
className="min-w-[220px] rounded-full"
|
<SelectContent>
|
||||||
/>
|
<SelectItem value="Todos">Todos os convênios</SelectItem>
|
||||||
{searchQuery ? (
|
<SelectItem value="Amil">Amil</SelectItem>
|
||||||
|
<SelectItem value="Unimed">Unimed</SelectItem>
|
||||||
|
<SelectItem value="SulAmérica">SulAmérica</SelectItem>
|
||||||
|
<SelectItem value="Bradesco Saúde">Bradesco Saúde</SelectItem>
|
||||||
|
<SelectItem value="Particular">Particular</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Busca por nome + Mais filtros/Limpar */}
|
||||||
|
<div className="sm:col-span-6 lg:col-span-4">
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar médico por nome"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full sm:min-w-[220px] rounded-full"
|
||||||
|
/>
|
||||||
|
{searchQuery ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-10 w-full sm:w-auto rounded-full"
|
||||||
|
onClick={async () => {
|
||||||
|
setSearchQuery('')
|
||||||
|
setCurrentPage(1)
|
||||||
|
try {
|
||||||
|
setLoadingMedicos(true)
|
||||||
|
setMedicos([])
|
||||||
|
setAgendaByDoctor({})
|
||||||
|
setAgendasExpandida({})
|
||||||
|
const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : (params?.get('q') || 'medico')
|
||||||
|
const list = await buscarMedicos(termo).catch(() => [])
|
||||||
|
setMedicos(Array.isArray(list) ? list : [])
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast('error', e?.message || 'Falha ao buscar profissionais')
|
||||||
|
} finally {
|
||||||
|
setLoadingMedicos(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 w-full sm:w-auto rounded-full border border-primary/30 bg-primary/5 text-primary hover:bg-primary hover:text-primary-foreground"
|
||||||
|
>
|
||||||
|
<Filter className="mr-2 h-4 w-4" />
|
||||||
|
Mais filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bairro */}
|
||||||
|
<div className="sm:col-span-6 lg:col-span-4">
|
||||||
|
<Select value={bairro} onValueChange={setBairro}>
|
||||||
|
<SelectTrigger className="h-10 w-full rounded-full border border-primary/30 bg-primary/5 text-primary hover:border-primary focus:ring-2 focus:ring-primary">
|
||||||
|
<SelectValue placeholder="Bairro" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Todos">Todos os bairros</SelectItem>
|
||||||
|
<SelectItem value="Centro">Centro</SelectItem>
|
||||||
|
<SelectItem value="Jardins">Jardins</SelectItem>
|
||||||
|
<SelectItem value="Farolândia">Farolândia</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voltar */}
|
||||||
|
<div className="sm:col-span-12">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-10"
|
className="w-full rounded-full text-primary hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||||
onClick={async () => {
|
onClick={() => router.back()}
|
||||||
// limpar o termo de busca e restaurar a lista por especialidade
|
|
||||||
setSearchQuery('')
|
|
||||||
setCurrentPage(1)
|
|
||||||
try {
|
|
||||||
setLoadingMedicos(true)
|
|
||||||
setMedicos([])
|
|
||||||
setAgendaByDoctor({})
|
|
||||||
setAgendasExpandida({})
|
|
||||||
const termo = (especialidadeHero && especialidadeHero !== 'Veja mais') ? especialidadeHero : (params?.get('q') || 'medico')
|
|
||||||
const list = await buscarMedicos(termo).catch(() => [])
|
|
||||||
setMedicos(Array.isArray(list) ? list : [])
|
|
||||||
} catch (e: any) {
|
|
||||||
showToast('error', e?.message || 'Falha ao buscar profissionais')
|
|
||||||
} finally {
|
|
||||||
setLoadingMedicos(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>Limpar</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full border border-primary/40 bg-primary/10 text-primary hover:bg-primary! hover:text-white! transition-colors"
|
|
||||||
>
|
>
|
||||||
<Filter className="mr-2 h-4 w-4" />
|
Voltar
|
||||||
Mais filtros
|
<ChevronRight className="ml-1 h-4 w-4 rotate-180" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={bairro} onValueChange={setBairro}>
|
|
||||||
<SelectTrigger className="h-10 min-w-40 rounded-full border border-primary/40 bg-primary/10 text-primary transition duration-200 hover:border-primary! focus:ring-2 focus:ring-primary cursor-pointer">
|
|
||||||
<SelectValue placeholder="Bairro" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Todos">Todos os bairros</SelectItem>
|
|
||||||
<SelectItem value="Centro">Centro</SelectItem>
|
|
||||||
<SelectItem value="Jardins">Jardins</SelectItem>
|
|
||||||
<SelectItem value="Farolândia">Farolândia</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="ml-auto rounded-full text-primary hover:bg-primary! hover:text-white! transition-colors"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
>
|
|
||||||
Voltar
|
|
||||||
<ChevronRight className="ml-1 h-4 w-4 rotate-180" />
|
|
||||||
</Button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Lista de profissionais */}
|
{/* Lista de profissionais */}
|
||||||
@ -879,7 +899,7 @@ export default function ResultadosClient() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="ml-auto h-fit rounded-full text-primary hover:bg-primary! hover:text-white! transition-colors"
|
className="ml-0 sm:ml-auto w-full sm:w-auto h-fit rounded-full text-primary hover:bg-primary! hover:text-white! transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMedicoSelecionado(medico)
|
setMedicoSelecionado(medico)
|
||||||
setAbaDetalhe('experiencia')
|
setAbaDetalhe('experiencia')
|
||||||
@ -945,7 +965,7 @@ export default function ResultadosClient() {
|
|||||||
|
|
||||||
<div className="flex flex-wrap gap-3 pt-2">
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
className="h-11 rounded-full bg-primary text-primary-foreground hover:bg-primary/90"
|
className="h-11 w-full sm:w-auto rounded-full bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
// If we don't have the agenda loaded, load it and try to open the nearest slot.
|
// If we don't have the agenda loaded, load it and try to open the nearest slot.
|
||||||
if (!agendaByDoctor[id]) {
|
if (!agendaByDoctor[id]) {
|
||||||
@ -972,12 +992,12 @@ export default function ResultadosClient() {
|
|||||||
>
|
>
|
||||||
Agendar consulta
|
Agendar consulta
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="h-11 rounded-full border-primary/40 bg-primary/10 text-primary hover:bg-primary! hover:text-white! transition-colors">
|
<Button variant="outline" className="h-11 w-full sm:w-auto rounded-full border-primary/40 bg-primary/10 text-primary hover:bg-primary! hover:text-white! transition-colors">
|
||||||
Enviar mensagem
|
Enviar mensagem
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-11 rounded-full text-primary hover:bg-primary! hover:text-white! transition-colors"
|
className="h-11 w-full sm:w-auto rounded-full text-primary hover:bg-primary! hover:text-white! transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const willOpen = !agendasExpandida[id]
|
const willOpen = !agendasExpandida[id]
|
||||||
setAgendasExpandida(prev => ({ ...prev, [id]: !prev[id] }))
|
setAgendasExpandida(prev => ({ ...prev, [id]: !prev[id] }))
|
||||||
@ -1009,18 +1029,23 @@ export default function ResultadosClient() {
|
|||||||
|
|
||||||
{/* Pagination controls */}
|
{/* Pagination controls */}
|
||||||
{!loadingMedicos && profissionais.length > 0 && (
|
{!loadingMedicos && profissionais.length > 0 && (
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mt-2 gap-3">
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 text-sm text-muted-foreground w-full sm:w-auto">
|
||||||
<span>Itens por página:</span>
|
<span>Itens por página:</span>
|
||||||
<select value={itemsPerPage} onChange={(e) => setItemsPerPage(Number(e.target.value))} className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer">
|
<Select value={String(itemsPerPage)} onValueChange={(v) => setItemsPerPage(Number(v))}>
|
||||||
<option value={5}>5</option>
|
<SelectTrigger className="h-9 w-full sm:w-28 min-w-[110px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus:ring-2 focus:ring-primary">
|
||||||
<option value={10}>10</option>
|
<SelectValue placeholder="Itens" />
|
||||||
<option value={20}>20</option>
|
</SelectTrigger>
|
||||||
</select>
|
<SelectContent className="z-50">
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="20">20</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<span>Mostrando {startItem} a {endItem} de {profissionais.length}</span>
|
<span>Mostrando {startItem} a {endItem} de {profissionais.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="hover:bg-primary! hover:text-white!">Primeira</Button>
|
<Button variant="outline" size="sm" onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="hover:bg-primary! hover:text-white!">Primeira</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="hover:bg-primary! hover:text-white!">Anterior</Button>
|
<Button variant="outline" size="sm" onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="hover:bg-primary! hover:text-white!">Anterior</Button>
|
||||||
<span className="text-sm text-muted-foreground">Página {currentPage} de {totalPages}</span>
|
<span className="text-sm text-muted-foreground">Página {currentPage} de {totalPages}</span>
|
||||||
@ -1033,7 +1058,7 @@ export default function ResultadosClient() {
|
|||||||
|
|
||||||
{/* Dialog de perfil completo (mantido e adaptado) */}
|
{/* Dialog de perfil completo (mantido e adaptado) */}
|
||||||
<Dialog open={!!medicoSelecionado} onOpenChange={(open: boolean) => !open && setMedicoSelecionado(null)}>
|
<Dialog open={!!medicoSelecionado} onOpenChange={(open: boolean) => !open && setMedicoSelecionado(null)}>
|
||||||
<DialogContent className="max-h[90vh] max-h-[90vh] w-full max-w-5xl overflow-y-auto border border-border bg-card p-0">
|
<DialogContent className="w-full max-w-[95vw] sm:max-w-5xl max-h-[90vh] overflow-y-auto border border-border bg-card p-0 sm:rounded-lg">
|
||||||
{medicoSelecionado && (
|
{medicoSelecionado && (
|
||||||
<>
|
<>
|
||||||
<DialogHeader className="border-b border-border px-6 py-4">
|
<DialogHeader className="border-b border-border px-6 py-4">
|
||||||
@ -1147,9 +1172,10 @@ export default function ResultadosClient() {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
{/* Dialog: Mostrar mais horários (escolher data arbitrária) */}
|
|
||||||
|
{/* Dialog: Mostrar mais horários */}
|
||||||
<Dialog open={!!moreTimesForDoctor} onOpenChange={(open: boolean) => { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}>
|
<Dialog open={!!moreTimesForDoctor} onOpenChange={(open: boolean) => { if (!open) { setMoreTimesForDoctor(null); setMoreTimesSlots([]); setMoreTimesException(null); } }}>
|
||||||
<DialogContent className="w-full max-w-2xl border border-border bg-card p-6">
|
<DialogContent className="w-full max-w-[95vw] sm:max-w-2xl border border-border bg-card p-4 sm:p-6">
|
||||||
<DialogHeader className="mb-4">
|
<DialogHeader className="mb-4">
|
||||||
<DialogTitle>Mais horários</DialogTitle>
|
<DialogTitle>Mais horários</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@ -1293,7 +1293,23 @@ export async function listarAgendamentos(query?: string): Promise<Appointment[]>
|
|||||||
if (!res.ok && res.status === 401) {
|
if (!res.ok && res.status === 401) {
|
||||||
throw new Error('Não autenticado. Token ausente ou expirado. Faça login novamente.');
|
throw new Error('Não autenticado. Token ausente ou expirado. Faça login novamente.');
|
||||||
}
|
}
|
||||||
return await parse<Appointment[]>(res);
|
const appointments = await parse<Appointment[]>(res);
|
||||||
|
// Filter out soft-deleted appointments (those with cancelled_at set OR status='cancelled' OR in deleted cache)
|
||||||
|
return appointments.filter((a) => {
|
||||||
|
const id = String(a.id);
|
||||||
|
|
||||||
|
// Check if in deleted cache
|
||||||
|
if (deletedAppointmentIds.has(id)) return false;
|
||||||
|
|
||||||
|
// Check cancelled_at field
|
||||||
|
const cancelled = a.cancelled_at;
|
||||||
|
if (cancelled && cancelled !== '' && cancelled !== 'null') return false;
|
||||||
|
|
||||||
|
// Check status field
|
||||||
|
if (a.status && String(a.status).toLowerCase() === 'cancelled') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1309,13 +1325,68 @@ export async function buscarAgendamentoPorId(id: string | number, select: string
|
|||||||
const url = `${REST}/appointments?id=eq.${encodeURIComponent(sId)}&${params.toString()}`;
|
const url = `${REST}/appointments?id=eq.${encodeURIComponent(sId)}&${params.toString()}`;
|
||||||
const headers = baseHeaders();
|
const headers = baseHeaders();
|
||||||
const arr = await fetchWithFallback<Appointment[]>(url, headers);
|
const arr = await fetchWithFallback<Appointment[]>(url, headers);
|
||||||
if (arr && arr.length) return arr[0];
|
// Filter out soft-deleted appointments (those with cancelled_at set OR status='cancelled' OR in deleted cache)
|
||||||
|
const active = arr?.filter((a) => {
|
||||||
|
const id = String(a.id);
|
||||||
|
|
||||||
|
// Check if in deleted cache
|
||||||
|
if (deletedAppointmentIds.has(id)) return false;
|
||||||
|
|
||||||
|
// Check cancelled_at field
|
||||||
|
const cancelled = a.cancelled_at;
|
||||||
|
if (cancelled && cancelled !== '' && cancelled !== 'null') return false;
|
||||||
|
|
||||||
|
// Check status field
|
||||||
|
if (a.status && String(a.status).toLowerCase() === 'cancelled') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (active && active.length) return active[0];
|
||||||
throw new Error('404: Agendamento não encontrado');
|
throw new Error('404: Agendamento não encontrado');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deleta um agendamento por ID (DELETE /rest/v1/appointments?id=eq.<id>)
|
* Deleta um agendamento por ID (DELETE /rest/v1/appointments?id=eq.<id>)
|
||||||
*/
|
*/
|
||||||
|
// Track deleted appointment IDs in localStorage to persist across page reloads
|
||||||
|
const DELETED_APPOINTMENTS_KEY = 'deleted_appointment_ids';
|
||||||
|
|
||||||
|
function getDeletedAppointmentIds(): Set<string> {
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined') return new Set();
|
||||||
|
const stored = localStorage.getItem(DELETED_APPOINTMENTS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const ids = JSON.parse(stored);
|
||||||
|
return new Set(Array.isArray(ids) ? ids : []);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[API] Erro ao ler deleted appointments do localStorage', e);
|
||||||
|
}
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDeletedAppointmentIds(ids: Set<string>) {
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem(DELETED_APPOINTMENTS_KEY, JSON.stringify(Array.from(ids)));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[API] Erro ao salvar deleted appointments no localStorage', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedAppointmentIds = getDeletedAppointmentIds();
|
||||||
|
|
||||||
|
export function addDeletedAppointmentId(id: string | number) {
|
||||||
|
const idStr = String(id);
|
||||||
|
deletedAppointmentIds.add(idStr);
|
||||||
|
saveDeletedAppointmentIds(deletedAppointmentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDeletedAppointments() {
|
||||||
|
deletedAppointmentIds.clear();
|
||||||
|
saveDeletedAppointmentIds(deletedAppointmentIds);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deletarAgendamento(id: string | number): Promise<void> {
|
export async function deletarAgendamento(id: string | number): Promise<void> {
|
||||||
if (!id) throw new Error('ID do agendamento é obrigatório');
|
if (!id) throw new Error('ID do agendamento é obrigatório');
|
||||||
const url = `${REST}/appointments?id=eq.${encodeURIComponent(String(id))}`;
|
const url = `${REST}/appointments?id=eq.${encodeURIComponent(String(id))}`;
|
||||||
@ -1325,9 +1396,11 @@ export async function deletarAgendamento(id: string | number): Promise<void> {
|
|||||||
headers: withPrefer({ ...baseHeaders() }, 'return=minimal'),
|
headers: withPrefer({ ...baseHeaders() }, 'return=minimal'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 204) return;
|
if (res.status === 204 || res.status === 200) {
|
||||||
// Some deployments may return 200 with a representation — accept that too
|
// Mark as deleted locally AND persist in localStorage
|
||||||
if (res.status === 200) return;
|
addDeletedAppointmentId(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Otherwise surface a friendly error using parse()
|
// Otherwise surface a friendly error using parse()
|
||||||
await parse(res as Response);
|
await parse(res as Response);
|
||||||
}
|
}
|
||||||
@ -3018,7 +3091,8 @@ export async function countAppointmentsToday(): Promise<number> {
|
|||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const tomorrow = new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
const tomorrow = new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
||||||
|
|
||||||
const url = `${REST}/appointments?scheduled_at=gte.${today}T00:00:00&scheduled_at=lt.${tomorrow}T00:00:00&select=id&limit=1`;
|
// Filter out soft-deleted appointments: cancelled_at is null
|
||||||
|
const url = `${REST}/appointments?scheduled_at=gte.${today}T00:00:00&scheduled_at=lt.${tomorrow}T00:00:00&cancelled_at=is.null&select=id&limit=1`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
...baseHeaders(),
|
...baseHeaders(),
|
||||||
@ -3045,9 +3119,25 @@ export async function getUpcomingAppointments(limit: number = 10): Promise<any[]
|
|||||||
const today = new Date().toISOString();
|
const today = new Date().toISOString();
|
||||||
const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString();
|
const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString();
|
||||||
|
|
||||||
const url = `${REST}/appointments?scheduled_at=gte.${today}&scheduled_at=lt.${nextWeek}&order=scheduled_at.asc&limit=${limit}&select=id,scheduled_at,status,doctor_id,patient_id`;
|
const url = `${REST}/appointments?scheduled_at=gte.${today}&scheduled_at=lt.${nextWeek}&order=scheduled_at.asc&limit=${limit}&select=id,scheduled_at,status,doctor_id,patient_id,cancelled_at`;
|
||||||
const res = await fetch(url, { headers: baseHeaders() });
|
const res = await fetch(url, { headers: baseHeaders() });
|
||||||
return await parse<any[]>(res);
|
const appointments = await parse<any[]>(res);
|
||||||
|
// Filter out soft-deleted appointments (those with cancelled_at set OR status='cancelled' OR in deleted cache)
|
||||||
|
return appointments.filter((a) => {
|
||||||
|
const id = String(a.id);
|
||||||
|
|
||||||
|
// Check if in deleted cache
|
||||||
|
if (deletedAppointmentIds.has(id)) return false;
|
||||||
|
|
||||||
|
// Check cancelled_at field
|
||||||
|
const cancelled = a.cancelled_at;
|
||||||
|
if (cancelled && cancelled !== '' && cancelled !== 'null') return false;
|
||||||
|
|
||||||
|
// Check status field
|
||||||
|
if (a.status && String(a.status).toLowerCase() === 'cancelled') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[getUpcomingAppointments] Erro:', err);
|
console.error('[getUpcomingAppointments] Erro:', err);
|
||||||
return [];
|
return [];
|
||||||
@ -3063,9 +3153,25 @@ export async function getAppointmentsByDateRange(days: number = 14): Promise<any
|
|||||||
startDate.setDate(startDate.getDate() - days);
|
startDate.setDate(startDate.getDate() - days);
|
||||||
const endDate = new Date().toISOString();
|
const endDate = new Date().toISOString();
|
||||||
|
|
||||||
const url = `${REST}/appointments?scheduled_at=gte.${startDate.toISOString()}&scheduled_at=lt.${endDate}&select=scheduled_at,status&order=scheduled_at.asc`;
|
const url = `${REST}/appointments?scheduled_at=gte.${startDate.toISOString()}&scheduled_at=lt.${endDate}&select=scheduled_at,status,cancelled_at,id&order=scheduled_at.asc`;
|
||||||
const res = await fetch(url, { headers: baseHeaders() });
|
const res = await fetch(url, { headers: baseHeaders() });
|
||||||
return await parse<any[]>(res);
|
const appointments = await parse<any[]>(res);
|
||||||
|
// Filter out soft-deleted appointments (those with cancelled_at set OR status='cancelled' OR in deleted cache)
|
||||||
|
return appointments.filter((a) => {
|
||||||
|
const id = String(a.id);
|
||||||
|
|
||||||
|
// Check if in deleted cache
|
||||||
|
if (deletedAppointmentIds.has(id)) return false;
|
||||||
|
|
||||||
|
// Check cancelled_at field
|
||||||
|
const cancelled = a.cancelled_at;
|
||||||
|
if (cancelled && cancelled !== '' && cancelled !== 'null') return false;
|
||||||
|
|
||||||
|
// Check status field
|
||||||
|
if (a.status && String(a.status).toLowerCase() === 'cancelled') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[getAppointmentsByDateRange] Erro:', err);
|
console.error('[getAppointmentsByDateRange] Erro:', err);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
6
susconecta/package-lock.json
generated
6
susconecta/package-lock.json
generated
@ -51,6 +51,7 @@
|
|||||||
"embla-carousel-react": "latest",
|
"embla-carousel-react": "latest",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"input-otp": "latest",
|
"input-otp": "latest",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
@ -4017,7 +4018,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6.0"
|
"node": ">= 0.6.0"
|
||||||
}
|
}
|
||||||
@ -4386,7 +4386,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"utrie": "^1.0.2"
|
"utrie": "^1.0.2"
|
||||||
}
|
}
|
||||||
@ -6066,7 +6065,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"css-line-break": "^2.1.0",
|
"css-line-break": "^2.1.0",
|
||||||
"text-segmentation": "^1.0.3"
|
"text-segmentation": "^1.0.3"
|
||||||
@ -8802,7 +8800,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"utrie": "^1.0.2"
|
"utrie": "^1.0.2"
|
||||||
}
|
}
|
||||||
@ -9214,7 +9211,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-arraybuffer": "^1.0.2"
|
"base64-arraybuffer": "^1.0.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,7 @@
|
|||||||
"embla-carousel-react": "latest",
|
"embla-carousel-react": "latest",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"input-otp": "latest",
|
"input-otp": "latest",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user