chore(ops): migrate clube67 to newwhats.clube67.com directory
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
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 res = await fetch(`http://localhost:3002/api/guias?${params}`);
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user