feat(): Criado a visualização de pendência por cliente e criado o serviço que acesso o endpoit de warning

This commit is contained in:
Kenio 2025-11-11 11:33:51 -03:00
parent 588f3b3d06
commit 30b5d29c37
13 changed files with 501 additions and 67 deletions

View file

@ -3,6 +3,7 @@
import React from "react";
import { useParams } from 'next/navigation';
import { useEffect } from 'react';
import Link from 'next/link';
// Helpers e hooks customizados
import { formatLogDateTime } from "@/shared/utils/formatLogDateTime";
@ -15,6 +16,7 @@ import { useLogDatabaseHook } from '@/packages/administrativo/hooks/Log/useLogDa
import { useLogGedHook } from '@/packages/administrativo/hooks/Log/useLogGedHook';
import { useLogDiskHook } from '@/packages/administrativo/hooks/Log/useLogDiskHook';
import { useLogBackupHook } from '@/packages/administrativo/hooks/Log/useLogBackupHook';
import { useLogWarningHook } from '@/packages/administrativo/hooks/Log/useLogWarningHook';
// Componentes do Shadcn/UI
import {
@ -43,6 +45,7 @@ export default function ClientePage() {
const { logGed, fetchLogGed } = useLogGedHook();
const { logDisk, fetchLogDisk } = useLogDiskHook();
const { logBackup, fetchLogBackup } = useLogBackupHook();
const { logWarning, fetchLogWarning } = useLogWarningHook();
// Efeito responsável por buscar logs de forma sequencial
useEffect(() => {
@ -102,6 +105,17 @@ export default function ClientePage() {
} catch (error) {
console.error("Erro ao buscar log do Backup:", error);
} finally {
// E SOMENTE após a conclusão da busca do Backup
// (mesmo que dê erro ou traga 0 registros),
// executa a busca no Warning
try {
await fetchLogWarning(Number(id));
} catch (error) {
console.error("Erro ao buscar log do Warning:", error);
}
}
}
}
@ -123,17 +137,25 @@ export default function ClientePage() {
// ============================================================
return (
<div className="p-4">
<h1 className="text-2xl font-semibold mb-6">
{logServer?.cns ?? 'CNS não disponível'} - {logServer?.cartorio ?? 'Cartório não disponível'}
</h1>
<h1 className="text-2xl font-semibold mb-6">
{logServer?.cns ?? 'CNS não disponível'} - {logServer?.cartorio ?? 'Cartório não disponível'}
<span className="m-4 text-gray-500 "> :: </span>
<Link href={`/administrativo/clientes`} className="ml-2 text-gray-500 hover:underline">
Voltar
</Link>
</h1>
<Tabs defaultValue="server" className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger className='cursor-pointer' value="server">Informações do Servidor</TabsTrigger>
<TabsTrigger className='cursor-pointer' value="database">Banco de Dados</TabsTrigger>
<TabsTrigger className='cursor-pointer' value="ged">GED</TabsTrigger>
<TabsTrigger className='cursor-pointer' value="disk">Unidades de Disco</TabsTrigger>
<TabsTrigger className='cursor-pointer' value="backup">Backup</TabsTrigger>
<TabsTrigger className='cursor-pointer' value="warning">Avisos</TabsTrigger>
</TabsList>
{/* ===================================================== */}
@ -620,7 +642,7 @@ export default function ClientePage() {
{/* ===================================================== */}
{/* Aba: Informação do servidor */}
{/* Aba: Informação do Backup */}
{/* ===================================================== */}
<TabsContent value="backup">
{logBackup?.data ? (
@ -689,6 +711,54 @@ export default function ClientePage() {
</TabsContent>
{/* ===================================================== */}
{/* Aba: Avisos (Warnings) */}
{/* ===================================================== */}
<TabsContent value="warning">
{logWarning?.data?.warning && logWarning.data.warning.length > 0 ? (
<div className="mt-4 space-y-6">
{/* Cabeçalho com data e hora */}
<div>
<Badge variant="outline" className="ml-2 bg-yellow-100 text-yellow-800 border-yellow-400">
Data do log: {logWarning?.data?.data ?? "Não informado"} às {logWarning?.data?.hora ?? "00:00"}
</Badge>
</div>
{/* Card de lista de avisos */}
<Card>
<CardHeader>
<CardTitle>Lista de Avisos</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] w-full rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center w-[80px]">#</TableHead>
<TableHead>Mensagem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logWarning.data.warning.map((msg: string, index: number) => (
<TableRow key={index}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell className="text-sm text-gray-800">{msg}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
</div>
) : (
<p className="p-4 text-gray-500 italic">
Nenhum aviso disponível para este cliente.
</p>
)}
</TabsContent>
</Tabs>
</div>
);

View file

@ -56,7 +56,7 @@ export default function RootLayout({
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">
<BreadcrumbLink href="/">
Orius Tecnologia
</BreadcrumbLink>
</BreadcrumbItem>

View file

@ -8,7 +8,6 @@ import {
} from 'lucide-react';
import { NavMain } from '@/components/nav-main';
import { NavProjects } from '@/components/nav-projects';
import { NavUser } from '@/components/nav-user';
import {
Sidebar,

View file

@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View file

@ -4,5 +4,6 @@
"url": "https://monitoring-api.oriustecnologia.com/",
"prefix": "api/v1/",
"content_type": "application/json"
}
},
"api_debit": "https://admin.oriustecnologia.com/router.php"
}

View file

@ -1,6 +1,9 @@
'use client';
'use client'; // Indica que este componente é executado no lado do cliente (Next.js)
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
// Componentes de UI
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@ -20,88 +23,186 @@ import {
TableRow,
} from '@/components/ui/table';
import { ChartPie, EllipsisIcon, PencilIcon, Trash2Icon } from 'lucide-react';
// Ícones da biblioteca Lucide
import { ChartPie, EllipsisIcon, PencilIcon } from 'lucide-react';
import { Spinner } from "@/components/ui/spinner"
// Interfaces e componentes auxiliares
import { ClientTableInterface } from '../../interfaces/Client/ClienteTableInterface';
import { StatusBadge } from '@/components/StatusBadge';
import { CheckLiberationHelper } from '@/shared/utils/CheckLiberationHelper';
import FinanceStatusDialog from '@/shared/components/finance/FinanceStatusDialog';
// Tipagem do retorno financeiro do helper
interface FinancialStatus {
status: string;
message: string;
expired_count: number;
next_due_count?: number;
details: {
type: string;
description: string;
reference: string;
days: number;
}[];
}
export default function ClientTable({ data, pagination, onEdit, onDelete }: ClientTableInterface) {
const router = useRouter(); // Hook do Next.js para navegação
// Inicializa o roteador
const router = useRouter();
// Estado que armazena o status financeiro de cada cliente, indexado pelo ID
const [financialStatus, setFinancialStatus] = useState<Record<number, FinancialStatus>>({});
// Efeito que busca o status financeiro de todos os clientes ao carregar ou atualizar a tabela
useEffect(() => {
// Se não houver dados válidos, encerra a execução
if (!Array.isArray(data)) return;
// Função assíncrona responsável por consultar os status financeiros
const fetchStatus = async () => {
const newStatuses: Record<number, FinancialStatus> = {}; // Armazena os resultados temporariamente
// Faz as requisições em paralelo para cada cliente
await Promise.all(
data.map(async (client) => {
const res = await CheckLiberationHelper(client.cns); // Chama o helper externo
// Normaliza o retorno e armazena no objeto temporário
newStatuses[client.client_id] = {
status: res.status || 'indefinido',
message: res.message || '',
expired_count: res.expired_count || 0,
next_due_count: res.next_due_count || 0,
details: res.details || [],
};
})
);
// Atualiza o estado global com todos os resultados de uma vez
setFinancialStatus(newStatuses);
};
// Executa a função
fetchStatus();
}, [data]); // Dependência: roda novamente se o array de clientes mudar
return (
<Table>
{/* Cabeçalho da tabela */}
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Status</TableHead>
<TableHead>Financeiro</TableHead>
<TableHead>CNS</TableHead>
<TableHead>Nome</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
{/* Corpo da tabela */}
<TableBody>
{Array.isArray(data) && data.length > 0 ? ( data.map((client) => (
<TableRow key={client.client_id}>
<TableCell className="font-medium">{client.client_id}</TableCell>
<TableCell>
<StatusBadge status={client.status as any} />
</TableCell>
<TableCell>{client.cns}</TableCell>
<TableCell>{client.name}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
{Array.isArray(data) && data.length > 0 ? (
data.map((client) => (
<TableRow key={client.client_id}>
{/* Coluna: ID do cliente */}
<TableCell className="font-medium">{client.client_id}</TableCell>
<DropdownMenuContent side="left" align="start">
<DropdownMenuGroup>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => onEdit(client)}
>
<PencilIcon className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => router.push(`/administrativo/clientes/${client.client_id}`)}
>
<ChartPie className="mr-2 h-4 w-4" />
Dashboard
</DropdownMenuItem>
{/* <DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => onDelete(client)}
>
<Trash2Icon className="mr-2 h-4 w-4" />
Remover
</DropdownMenuItem> */}
{/* Coluna: Status cadastral (ativo, inativo, etc) */}
<TableCell>
<StatusBadge status={client.status as any} />
</TableCell>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
{/* Coluna: Status financeiro retornado da API */}
<TableCell>
{financialStatus[client.client_id] ? (
<FinanceStatusDialog
triggerElement={
// Usar <button> garante que o elemento seja interativo e receba handlers clonados
<button
type="button"
className={
financialStatus[client.client_id].status === 'em atraso'
? 'text-red-600 font-semibold cursor-pointer underline underline-offset-2 bg-transparent border-0 p-0'
: 'text-green-600 font-semibold cursor-pointer underline underline-offset-2 bg-transparent border-0 p-0'
}
title={financialStatus[client.client_id].message}
>
{financialStatus[client.client_id].status.toUpperCase()}
{financialStatus[client.client_id].expired_count > 0 &&
` (${financialStatus[client.client_id].expired_count} pendência${
financialStatus[client.client_id].expired_count > 1 ? 's' : ''
})`}
</button>
}
status={financialStatus[client.client_id].status}
message={financialStatus[client.client_id].message}
details={financialStatus[client.client_id].details}
expired_count={financialStatus[client.client_id].expired_count}
/>
) : (
<span className="text-gray-400 italic"><Spinner /></span>
)}
</TableCell>
{/* Coluna: CNS do cliente */}
<TableCell>{client.cns}</TableCell>
{/* Coluna: Nome do cliente */}
<TableCell>{client.name}</TableCell>
{/* Coluna: Ações (menu suspenso) */}
<TableCell className="text-right">
<DropdownMenu>
{/* Botão principal do menu */}
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
{/* Conteúdo do menu suspenso */}
<DropdownMenuContent side="left" align="start">
<DropdownMenuGroup>
{/* Ação: Editar cliente */}
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => onEdit(client)}
>
<PencilIcon className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Ação: Ir para dashboard do cliente */}
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => router.push(`/administrativo/clientes/${client.client_id}`)}
>
<ChartPie className="mr-2 h-4 w-4" />
Dashboard
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
// Caso não existam registros
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
<TableCell colSpan={6} className="text-center text-muted-foreground">
Nenhum cliente encontrado.
</TableCell>
</TableRow>
)}
</TableBody>
{/* Rodapé da tabela com totalização */}
<TableFooter>
<TableRow>
<TableCell colSpan={5}>
{/* Se existir paginação, mostra o total de registros; senão, usa o tamanho da lista */}
Total de clientes: {pagination?.total_records ?? data?.length}
</TableCell>
</TableRow>

View file

@ -11,7 +11,7 @@ import { withClientErrorHandler } from '@/withClientErrorHandler/withClientError
// Importa o decorador que adiciona tratamento global de erros às funções assíncronas
// Função principal responsável por enviar a requisição de exclusão de um cliente
async function executeClientDeleteData(usuarioId: number) {
async function executeClientDeleteData(client_id: number) {
// Cria uma nova instância da classe API, que gerencia headers, baseURL e envio das requisições
const api = new API();
@ -19,7 +19,7 @@ async function executeClientDeleteData(usuarioId: number) {
// Envia a requisição DELETE para o endpoint correspondente ao cliente informado
const response = await api.send({
'method': Methods.DELETE, // Define o método HTTP como DELETE
'endpoint': `administrativo/client/${usuarioId}` // Define o endpoint incluindo o ID do cliente
'endpoint': `administrativo/client/${client_id}` // Define o endpoint incluindo o ID do cliente
});
// Retorna a resposta da API, contendo status, mensagem e possíveis dados adicionais

View file

@ -0,0 +1,31 @@
'use server'
// Indica que este módulo será executado no lado do servidor (Warning Action do Next.js)
import { Methods } from '@/shared/services/api/enums/ApiMethodEnum';
// Importa o enumerador que contém os métodos HTTP padronizados (GET, POST, PUT, DELETE)
import API from '@/shared/services/api/Api';
// Importa a classe responsável por realizar requisições HTTP à API backend
import { withClientErrorHandler } from '@/withClientErrorHandler/withClientErrorHandler';
// Importa o wrapper que padroniza o tratamento de erros e respostas para o cliente
// Função principal responsável por buscar um usuário específico pelo seu ID
async function executeLogWarningData(client_id: number) {
// Cria uma nova instância da classe de comunicação com a API
const api = new API();
// Envia uma requisição GET ao endpoint que retorna os dados de um usuário específico
const response = await api.send({
'method': Methods.GET, // Define o método HTTP da requisição
'endpoint': `administrativo/log/warning/${client_id}` // Monta dinamicamente o endpoint com o ID do usuário
});
// Retorna a resposta recebida da API (dados do usuário ou erro)
return response;
}
// Exporta a função encapsulada com o handler de erro
// Isso garante que exceções sejam tratadas de forma padronizada na camada superior
export const LogWarningData = withClientErrorHandler(executeLogWarningData);

View file

@ -0,0 +1,23 @@
'use client';
import { useState } from 'react';
import { LogWarningInterface } from '../../interfaces/Log/LogWarningInterface';
import { LogWarningService } from '../../services/Log/LogWarningService';
import { useResponse } from '@/shared/components/response/ResponseContext';
export const useLogWarningHook = () => {
const { setResponse } = useResponse();
const [logWarning, setLog] = useState<LogWarningInterface | null>(null);
const fetchLogWarning = async (client_id: number) => {
try {
const response = await LogWarningService(client_id);
setLog(response as LogWarningInterface);
setResponse(response);
} catch (error) {
console.error("Erro ao buscar informação do banco de dados:", error);
}
};
return { logWarning, fetchLogWarning };
};

View file

@ -0,0 +1,13 @@
/**
* Interface que representa o log de banco de dados retornado pelo endpoint /log/database.
*/
export interface LogDiskInterface {
message?: string;
data: {
cns: string;
cartorio: string;
data: string;
hora: string;
warning: []
};
}

View file

@ -0,0 +1,22 @@
'use server'
// Indica que este arquivo é um "Warning Action", executado no lado do servidor pelo Next.js
import { withClientErrorHandler } from "@/withClientErrorHandler/withClientErrorHandler";
// Importa o wrapper responsável por padronizar o tratamento de erros nas requisições do Loge
import { LogWarningData } from "../../data/Log/LogWarningData";
// Importa a função que acessa a camada de dados e retorna as informações do usuário a partir do ID
// Função assíncrona principal responsável por buscar um usuário pelo seu ID
async function executeLogWarningService(client_id: number) {
// Executa a função de busca de usuário, passando o ID recebido como parâmetro
const response = await LogWarningData(client_id);
// Retorna a resposta vinda da camada de dados (usuário encontrado ou erro)
return response;
}
// Exporta o serviço com o tratamento de erros encapsulado
// O wrapper "withClientErrorHandler" assegura respostas consistentes em caso de falhas
export const LogWarningService = withClientErrorHandler(executeLogWarningService);

View file

@ -0,0 +1,70 @@
// src/shared/components/finance/FinanceStatusDialog.tsx
'use client';
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface FinanceDetail { type: string; description: string; reference: string; days: number; }
interface Props {
status: string;
message?: string;
details?: FinanceDetail[];
expired_count?: number;
// Aceitar apenas ReactElement garante que as props clonadas pelo DialogTrigger funcionem
triggerElement?: React.ReactElement | null;
}
export default function FinanceStatusDialog({
status,
message,
details = [],
expired_count = 0,
triggerElement = null,
}: Props) {
const isLate = status?.toLowerCase() === 'em atraso';
return (
<Dialog>
{/* asChild permite que o Dialog clone o elemento e injete handlers */}
<DialogTrigger asChild>
{triggerElement ?? (
<button
type="button"
className={isLate ? 'bg-red-200 text-red-800 px-3 py-1.5 rounded cursor-pointer' : 'bg-green-200 text-green-800 px-3 py-1.5 rounded cursor-pointer'}
>
{status?.toUpperCase()}
{expired_count > 0 && <span className="ml-1">({expired_count})</span>}
</button>
)}
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isLate ? 'Pendências Financeiras' : 'Situação Financeira - Em Dia'}</DialogTitle>
</DialogHeader>
<p className="text-sm text-gray-600 mb-3">{message}</p>
<ScrollArea className="max-h-[300px] pr-2">
{details.length === 0 ? (
<p className="text-gray-500 text-sm">Nenhum detalhe disponível.</p>
) : (
<div className="space-y-3">
{details.map((item, i) => (
<div key={i} className={`p-3 rounded-lg border ${item.type === 'vencido' ? 'bg-red-50 border-red-300' : 'bg-yellow-50 border-yellow-300'}`}>
<p className="text-sm font-medium">{item.description}</p>
<p className="text-xs text-gray-600 mt-1">Referência: {format(new Date(item.reference), 'dd/MM/yyyy', { locale: ptBR })}</p>
<p className="text-xs text-gray-600">{item.type === 'vencido' ? `${item.days} dia(s) de atraso` : `${Math.abs(item.days)} dia(s) restantes`}</p>
</div>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,88 @@
// src/shared/helpers/CheckLiberationHelper.ts
'use server';
import appConfig from '@/config/app.json';
/**
* Consulta o status financeiro de um cliente a partir do CNS.
* Faz POST x-www-form-urlencoded para a URL definida em app.json (api_debit).
* Interpreta dinamicamente os campos retornados, como expired, next_due_date etc.
*/
export async function CheckLiberationHelper(cns: string) {
try {
const apiUrl = appConfig.api_debit;
// Monta o corpo no formato x-www-form-urlencoded
const body = new URLSearchParams({
TABLE: 'liberation',
ACTION: 'liberation',
FOLDER: 'action',
hash: 'DOa62FKFI9yjcRi/k/n3fSzz67CjrWRW6qCl8IbC3RjQEQHgAPc6Wz0fjXSUlJKdPnRVw/Ejwj3DKTOFNuPxXX4oFzfCUo1Eqqc8tl7sJ1slbRjwL6XSahaXxxboWHiiltirhr/nu5FDeOH1oLxH2w==',
cns: cns,
});
// Requisição POST
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString(),
cache: 'no-store',
});
if (!response.ok) {
return { status: 'erro', message: 'Falha ao consultar status financeiro' };
}
const json = await response.json();
// Se não existir campo "data", retorna mensagem padrão
if (!json?.data) {
return { status: 'indefinido', message: 'Sem informações financeiras' };
}
// Extrai dinamicamente as listas retornadas
const data = json.data;
const expired = Array.isArray(data.expired) ? data.expired : [];
const next = Array.isArray(data.next_due_date) ? data.next_due_date : [];
// Determina status geral com base nas listas
let status = 'em dia';
if (expired.length > 0) {
status = 'em atraso';
} else if (next.length > 0) {
const nextDays = next[0]?.days ?? 0;
status = nextDays === 0 ? 'vence hoje' : 'em dia';
}
// Cria um resumo com detalhes de cada item (útil para exibir tooltip, modal, etc.)
const details = [
...expired.map((item) => ({
type: 'vencido',
description: item.description,
reference: item.reference,
days: item.days,
})),
...next.map((item) => ({
type: 'a_vencer',
description: item.description,
reference: item.reference,
days: item.days,
})),
];
// Retorna objeto completo
return {
code: json.code ?? 200,
status, // "em atraso", "vence hoje", "em dia", etc.
message: json.message ?? 'Sem mensagem',
expired_count: expired.length,
next_due_count: next.length,
details,
};
} catch (error) {
console.error('Erro ao consultar API externa:', error);
return { status: 'erro', message: 'Erro de comunicação com servidor externo' };
}
}