1330147eaf
- Backend: substitui string literals com aspas duplas por aspas simples nas queries PostgreSQL (settings, GTO builder, google_tokens delete) - Backend: corrige alias SQL 'dentistanome' para 'dentistaNome' (quoted) e ajusta frontend para usar 'pacientenome' (lowercase do PG) - Backend: move app.listen() para após todas as rotas registradas - Backend: ativa generateIntelligentNotifications no startup + intervalo 5min - Backend: magic link usa process.env.APP_URL em vez de localhost:3000 hardcoded - Frontend: adiciona 'relatorios' às permissões do role 'funcionario' - Frontend: adiciona onClick ao botão Relatórios do Dashboard - Frontend: adiciona Content-Type e JWT token em salvarAgendamento/atualizarAgendamento - Frontend: MeusTratamentos passa JWT token nas chamadas a /api/guias - Frontend: ClinicasView passa JWT token no endpoint de atualização de cor - Frontend: corrige subtítulo 'MYSQL + GOOGLE' para 'POSTGRESQL + GOOGLE' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
349 lines
18 KiB
TypeScript
349 lines
18 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import {
|
|
Search,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Download,
|
|
ArrowUpDown,
|
|
Filter,
|
|
Calendar as CalendarIcon
|
|
} from 'lucide-react';
|
|
import { format, parseISO } from 'date-fns';
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
|
|
// Debounce hook
|
|
function useDebounce<T>(value: T, delay: number): T {
|
|
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
|
React.useEffect(() => {
|
|
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
|
return () => clearTimeout(handler);
|
|
}, [value, delay]);
|
|
return debouncedValue;
|
|
}
|
|
|
|
// --- Types ---
|
|
interface Guia {
|
|
id: string;
|
|
numeroGuiaPrestador: string;
|
|
numeroGuiaOperadora: string | null;
|
|
dataSolicitacao: string;
|
|
tipoTratamento: string;
|
|
status: 'EM_ANALISE' | 'AUTORIZADO' | 'AUTORIZADO_PARCIAL' | 'NEGADO' | 'CANCELADO';
|
|
beneficiarioId: string;
|
|
beneficiarioNome: string;
|
|
beneficiarioIdentificacao: string;
|
|
}
|
|
|
|
interface GuiasResponse {
|
|
data: Guia[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
// --- Status Badge Component ---
|
|
const StatusBadge: React.FC<{ status: Guia['status'] }> = ({ status }) => {
|
|
const configs = {
|
|
AUTORIZADO: { label: 'Autorizado', color: 'bg-green-100 text-green-700 border-green-200' },
|
|
AUTORIZADO_PARCIAL: { label: 'Autorizado Parcialmente', color: 'bg-yellow-100 text-yellow-700 border-yellow-200' },
|
|
NEGADO: { label: 'Negado', color: 'bg-red-100 text-red-700 border-red-200' },
|
|
EM_ANALISE: { label: 'Em Análise', color: 'bg-blue-100 text-blue-700 border-blue-200' },
|
|
CANCELADO: { label: 'Cancelado', color: 'bg-gray-100 text-gray-700 border-gray-200' },
|
|
};
|
|
|
|
const config = configs[status] || configs.EM_ANALISE;
|
|
|
|
return (
|
|
<span className={`px-2 py-1 rounded-full text-[10px] font-bold border uppercase ${config.color}`}>
|
|
{config.label}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// --- Main View ---
|
|
export const MeusTratamentos: React.FC = () => {
|
|
// Filters & State
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize] = useState(10);
|
|
const [search, setSearch] = useState('');
|
|
const [gto, setGto] = useState('');
|
|
const [status, setStatus] = useState('');
|
|
const [dataInicio, setDataInicio] = useState('');
|
|
const [dataFim, setDataFim] = useState('');
|
|
const [sortField, setSortField] = useState('dataSolicitacao');
|
|
const [sortOrder, setSortOrder] = useState<'ASC' | 'DESC'>('DESC');
|
|
|
|
const debouncedSearch = useDebounce(search, 500);
|
|
const debouncedGto = useDebounce(gto, 500);
|
|
|
|
// API Fetcher
|
|
const fetchGuias = async (): Promise<GuiasResponse> => {
|
|
const params = new URLSearchParams({
|
|
page: page.toString(),
|
|
pageSize: pageSize.toString(),
|
|
search: debouncedSearch,
|
|
gto: debouncedGto,
|
|
status,
|
|
dataInicio,
|
|
dataFim,
|
|
sortField,
|
|
sortOrder
|
|
});
|
|
|
|
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
|
const res = await fetch(`/api/guias?${params}`, {
|
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
|
});
|
|
if (!res.ok) throw new Error('Falha ao carregar guias');
|
|
return res.json();
|
|
};
|
|
|
|
const { data, isLoading, isError, refetch } = useQuery({
|
|
queryKey: ['guias', page, debouncedSearch, debouncedGto, status, dataInicio, dataFim, sortField, sortOrder],
|
|
queryFn: fetchGuias,
|
|
placeholderData: (previousData) => previousData,
|
|
});
|
|
|
|
const handleSort = (field: string) => {
|
|
if (sortField === field) {
|
|
setSortOrder(sortOrder === 'ASC' ? 'DESC' : 'ASC');
|
|
} else {
|
|
setSortField(field);
|
|
setSortOrder('DESC');
|
|
}
|
|
};
|
|
|
|
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
|
|
|
|
const exportToCSV = () => {
|
|
if (!data?.data) return;
|
|
const headers = ['Data Solicitação', 'Identificação', 'Beneficiário', 'Tipo Tratamento', 'GTO', 'Situação'];
|
|
const rows = data.data.map(g => [
|
|
format(parseISO(g.dataSolicitacao), 'dd/MM/yyyy'),
|
|
g.beneficiarioIdentificacao,
|
|
g.beneficiarioNome,
|
|
g.tipoTratamento,
|
|
g.numeroGuiaPrestador,
|
|
g.status
|
|
]);
|
|
|
|
const csvContent = "data:text/csv;charset=utf-8," +
|
|
headers.join(",") + "\n" +
|
|
rows.map(e => e.join(",")).join("\n");
|
|
|
|
const encodedUri = encodeURI(csvContent);
|
|
const link = document.createElement("a");
|
|
link.setAttribute("href", encodedUri);
|
|
link.setAttribute("download", `tratamentos_${format(new Date(), 'yyyyMMdd')}.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full space-y-4">
|
|
<PageHeader
|
|
title="Meus Tratamentos"
|
|
description="LISTAGEM E GESTÃO DE GUIAS DE TRATAMENTO ODONTOLÓGICO (GTO)."
|
|
>
|
|
<button
|
|
onClick={exportToCSV}
|
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm font-bold uppercase shadow-sm"
|
|
>
|
|
<Download size={18} /> Exportar CSV
|
|
</button>
|
|
</PageHeader>
|
|
|
|
{/* Filters Bar */}
|
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-4">
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<div className="flex-1 min-w-[200px]">
|
|
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">Status da Guia</label>
|
|
<select
|
|
value={status}
|
|
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
|
|
className="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-semibold uppercase"
|
|
>
|
|
<option value="">TODOS OS STATUS</option>
|
|
<option value="AUTORIZADO">AUTORIZADO</option>
|
|
<option value="AUTORIZADO_PARCIAL">AUTORIZADO PARCIALMENTE</option>
|
|
<option value="NEGADO">NEGADO</option>
|
|
<option value="EM_ANALISE">EM ANÁLISE</option>
|
|
<option value="CANCELADO">CANCELADO</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="w-48">
|
|
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">GTO (Número)</label>
|
|
<div className="relative">
|
|
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder="EX: 177903"
|
|
value={gto}
|
|
onChange={(e) => { setGto(e.target.value); setPage(1); }}
|
|
className="w-full pl-10 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-semibold uppercase"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-[300px]">
|
|
<label className="block text-[10px] font-bold text-gray-400 uppercase mb-1">Pesquisar Beneficiário</label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder="DIGITE NOME OU IDENTIFICAÇÃO..."
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value.toUpperCase()); setPage(1); }}
|
|
className="w-full pl-10 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm font-semibold uppercase"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 pt-2 border-t border-gray-50">
|
|
<div className="flex items-center gap-2">
|
|
<CalendarIcon size={16} className="text-gray-400" />
|
|
<input
|
|
type="date"
|
|
value={dataInicio}
|
|
onChange={(e) => { setDataInicio(e.target.value); setPage(1); }}
|
|
className="px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-xs font-semibold"
|
|
/>
|
|
<span className="text-gray-400 font-bold text-xs uppercase">até</span>
|
|
<input
|
|
type="date"
|
|
value={dataFim}
|
|
onChange={(e) => { setDataFim(e.target.value); setPage(1); }}
|
|
className="px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-xs font-semibold"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setSearch(''); setGto(''); setStatus(''); setDataInicio(''); setDataFim(''); setPage(1);
|
|
}}
|
|
className="text-[10px] font-bold text-blue-600 hover:text-blue-800 uppercase"
|
|
>
|
|
Limpar Filtros
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table Section */}
|
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex-1 flex flex-col min-h-[400px]">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left">
|
|
<thead>
|
|
<tr className="bg-gray-50 border-b border-gray-200">
|
|
<th className="px-6 py-4">
|
|
<button onClick={() => handleSort('dataSolicitacao')} className="flex items-center gap-2 text-[11px] font-extrabold text-gray-500 uppercase tracking-wider hover:text-blue-600 transition-colors">
|
|
Data Solicitação <ArrowUpDown size={14} />
|
|
</button>
|
|
</th>
|
|
<th className="px-6 py-4">
|
|
<span className="text-[11px] font-extrabold text-gray-500 uppercase tracking-wider">Identificação do Beneficiário</span>
|
|
</th>
|
|
<th className="px-6 py-4">
|
|
<button onClick={() => handleSort('beneficiarioNome')} className="flex items-center gap-2 text-[11px] font-extrabold text-gray-500 uppercase tracking-wider hover:text-blue-600 transition-colors">
|
|
Nome Beneficiário <ArrowUpDown size={14} />
|
|
</button>
|
|
</th>
|
|
<th className="px-6 py-4">
|
|
<span className="text-[11px] font-extrabold text-gray-500 uppercase tracking-wider">Tipo Tratamento</span>
|
|
</th>
|
|
<th className="px-6 py-4">
|
|
<button onClick={() => handleSort('numeroGuiaPrestador')} className="flex items-center gap-2 text-[11px] font-extrabold text-gray-500 uppercase tracking-wider hover:text-blue-600 transition-colors">
|
|
GTO <ArrowUpDown size={14} />
|
|
</button>
|
|
</th>
|
|
<th className="px-6 py-4">
|
|
<button onClick={() => handleSort('status')} className="flex items-center gap-2 text-[11px] font-extrabold text-gray-500 uppercase tracking-wider hover:text-blue-600 transition-colors">
|
|
Situação da GTO <ArrowUpDown size={14} />
|
|
</button>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{isLoading ? (
|
|
Array.from({ length: 5 }).map((_, i) => (
|
|
<tr key={i} className="animate-pulse">
|
|
<td colSpan={6} className="px-6 py-4 h-16 bg-gray-50/30"></td>
|
|
</tr>
|
|
))
|
|
) : isError ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-red-500 font-bold uppercase">Erro ao carregar dados.</td>
|
|
</tr>
|
|
) : data?.data.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-400 font-bold uppercase">Nenhum tratamento encontrado.</td>
|
|
</tr>
|
|
) : (
|
|
data?.data.map((gui) => (
|
|
<tr key={gui.id} className="hover:bg-blue-50/50 transition-colors cursor-pointer group">
|
|
<td className="px-6 py-4 text-sm font-semibold text-gray-700">
|
|
{format(parseISO(gui.dataSolicitacao), 'dd/MM/yyyy')}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm font-bold text-blue-600 group-hover:underline">
|
|
{gui.beneficiarioIdentificacao}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<p className="text-sm font-bold text-gray-900 uppercase">{gui.beneficiarioNome}</p>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600">
|
|
{gui.tipoTratamento}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm font-bold text-gray-800">
|
|
{gui.numeroGuiaPrestador}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<StatusBadge status={gui.status} />
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination Footer */}
|
|
<div className="mt-auto bg-gray-50 px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
|
<div className="text-xs font-bold text-gray-500 uppercase">
|
|
Mostrando <span className="text-gray-900">{data?.data.length || 0}</span> de <span className="text-gray-900">{data?.total || 0}</span> resultados
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
disabled={page === 1}
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
className="p-2 border border-gray-300 rounded-lg hover:bg-white disabled:opacity-30 disabled:hover:bg-transparent transition-colors shadow-sm"
|
|
>
|
|
<ChevronLeft size={16} />
|
|
</button>
|
|
<div className="flex items-center gap-1">
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
|
<button
|
|
key={p}
|
|
onClick={() => setPage(p)}
|
|
className={`w-8 h-8 rounded-lg text-xs font-bold transition-all ${p === page ? 'bg-blue-600 text-white shadow-md' : 'text-gray-600 hover:bg-white border border-transparent hover:border-gray-200'}`}
|
|
>
|
|
{p}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
disabled={page === totalPages}
|
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
className="p-2 border border-gray-300 rounded-lg hover:bg-white disabled:opacity-30 disabled:hover:bg-transparent transition-colors shadow-sm"
|
|
>
|
|
<ChevronRight size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|