chore(ops): migrate clube67 to newwhats.clube67.com directory
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-18 03:26:41 +02:00
parent 298b1c64b0
commit 5ec6bd6354
576 changed files with 29016 additions and 235673 deletions
@@ -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>
);
};