sgmp/frontend/app/dashboard/page.tsx

429 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
type Usuario = {
matricula: string;
nome: string;
perfil: string;
perfil_display: string;
};
type Solicitacao = {
id: string;
tipo: string;
tipo_display: string;
colaborador: string | null;
status: string;
status_display: string;
criado_em: string | null;
enviada_em: string | null;
solicitante_nome: string | null;
pode_aprovar: boolean;
pode_dar_parecer: boolean;
};
type DashboardData = {
usuario: Usuario;
total: number;
pendentes: number;
solicitacoes: Solicitacao[];
pagination: {
page: number;
per_page: number;
total_pages: number;
total_count: number;
};
};
const STATUS_CLASSES: Record<string, string> = {
RASCUNHO: "bg-amber-50 text-amber-800 border-amber-200",
AGUARDANDO_HEAD: "bg-amber-50 text-amber-800 border-amber-200",
ENVIADA: "bg-blue-50 text-blue-800 border-blue-200",
APROVADA_GG: "bg-emerald-50 text-emerald-800 border-emerald-200",
APROVADA_CONTROLADORIA: "bg-emerald-50 text-emerald-800 border-emerald-200",
APROVADA_DIRETORIA: "bg-emerald-50 text-emerald-800 border-emerald-200",
AGUARDANDO_DIRETORIA: "bg-amber-50 text-amber-800 border-amber-200",
FINALIZADA: "bg-slate-100 text-slate-700 border-slate-200",
REPROVADA: "bg-red-50 text-red-800 border-red-200",
};
const DJANGO_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8888";
function formatarData(iso: string | null) {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
export default function DashboardPage() {
const router = useRouter();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
useEffect(() => {
async function carregar() {
setLoading(true);
setError("");
try {
const res = await fetch(`/api/dashboard/?page=${page}`, {
credentials: "include",
});
// #region agent log
fetch("http://localhost:7701/ingest/5073259c-ddcc-441a-a087-e13a2cf7ac9e", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "ca90b5",
},
body: JSON.stringify({
sessionId: "ca90b5",
runId: "login-debug-01",
hypothesisId: "H1",
location: "frontend/app/dashboard/page.tsx:fetch",
message: "dashboard_fetch_status",
data: { status: res.status, page },
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
if (res.status === 401) {
router.push(`/tela_login?next=${encodeURIComponent("/dashboard")}`);
return;
}
if (!res.ok) {
setError("Erro ao carregar o dashboard.");
return;
}
const json = await res.json();
setData(json);
} catch {
setError("Erro de conexão.");
} finally {
setLoading(false);
}
}
carregar();
}, [page, router]);
async function handleLogout() {
try {
await fetch("/api/auth/logout/", {
method: "POST",
credentials: "include",
});
} catch {
/* ignore */
}
router.push("/tela_login");
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100">
<p className="text-slate-500">Carregando</p>
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-slate-100">
<p className="text-red-600">{error || "Erro ao carregar."}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Tentar novamente
</button>
</div>
);
}
const { usuario, total, pendentes, solicitacoes, pagination } = data;
const isGestor = usuario.perfil === "GESTOR";
return (
<div className="min-h-screen bg-slate-100">
{/* Header */}
<header className="bg-slate-900 text-white border-b border-slate-800">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<h1 className="text-lg font-bold tracking-tight">SGMP CORP</h1>
<nav className="flex flex-wrap items-center gap-x-4 gap-y-1">
<Link
href="/dashboard"
className="text-sm font-medium text-slate-300 hover:text-white"
>
Dashboard
</Link>
<button
onClick={handleLogout}
className="text-sm text-red-400 hover:text-red-300 flex items-center gap-1"
>
Sair
</button>
</nav>
</div>
</header>
<main className="max-w-6xl mx-auto py-6 md:py-8 px-4 sm:px-6">
{/* Saudação */}
<div className="mb-8">
<h2 className="text-2xl md:text-3xl font-bold text-slate-800 tracking-tight">
SGMP - Movimentação de Pessoas
</h2>
<p className="text-slate-500 text-sm md:text-base mt-2">
Olá, <strong>{usuario.nome}</strong>!
<span className="inline-block bg-slate-200 py-0.5 px-2 rounded text-xs ml-2 text-slate-600">
{usuario.perfil_display}
</span>
<br />
<small className="text-slate-400">Matrícula: {usuario.matricula}</small>
</p>
{isGestor && (
<div className="mt-4">
<Link
href="/nova-solicitacao"
className="inline-flex items-center gap-2 py-2.5 px-4 rounded-md text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700"
>
+ Nova Solicitação
</Link>
</div>
)}
</div>
{isGestor && (
<div className="mb-8 bg-amber-50 border border-amber-300 rounded-xl p-4 flex gap-3 items-start">
<span className="text-2xl">💡</span>
<div>
<h4 className="m-0 mb-1 text-amber-800 font-semibold">
Lembrete Rápido
</h4>
<p className="m-0 text-amber-700 text-sm">
Solicitações em <strong>Rascunho</strong> são visíveis para
você. Lembre-se de clicar em{" "}
<strong>&quot;Enviar para Aprovação&quot;</strong> na página de
detalhes para iniciar o fluxo.
</p>
</div>
</div>
)}
{/* Métricas */}
<div className="grid grid-cols-2 md:grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4 md:gap-6 mb-10">
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm border-l-4 border-l-blue-500">
<div className="text-xs uppercase tracking-wider text-slate-500 font-bold mb-2">
Total
</div>
<div className="text-3xl font-extrabold leading-none text-blue-600">
{total}
</div>
</div>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm border-l-4 border-l-amber-500">
<div className="text-xs uppercase tracking-wider text-slate-500 font-bold mb-2">
Pendentes
</div>
<div className="text-3xl font-extrabold leading-none text-amber-600">
{pendentes}
</div>
</div>
</div>
{/* Tabela de solicitações */}
<div className="mb-8">
<h3 className="text-slate-800 text-xl font-semibold mb-4 flex items-center gap-2">
{isGestor ? "📋 Minhas Solicitações" : "⏳ Pendentes de Aprovação"}
</h3>
{!isGestor && (
<div className="bg-blue-50 border border-blue-200 rounded-lg py-3 px-4 mb-5 text-blue-800 text-sm flex items-center gap-2">
Você está vendo solicitações com status{" "}
<strong>Enviada</strong> aguardando sua análise.
</div>
)}
{solicitacoes.length > 0 ? (
<>
<div className="md:hidden space-y-3">
{solicitacoes.map((s) => (
<div
key={s.id}
className="bg-white rounded-xl shadow border border-slate-200 p-4"
>
<div className="flex items-start justify-between gap-3 mb-2 min-w-0">
<p className="font-semibold text-slate-900 leading-tight min-w-0 flex-1 text-sm">
{s.tipo_display}
</p>
<span
className={`inline-flex max-w-[min(100%,14rem)] text-left break-words leading-snug py-1 px-2.5 rounded-full text-xs font-bold border ${
STATUS_CLASSES[s.status] ||
"bg-slate-100 text-slate-700 border-slate-200"
}`}
>
{s.status_display}
</span>
</div>
<p className="text-sm text-slate-700 font-medium">
{s.colaborador || "N/A"}
</p>
<p className="text-sm text-slate-600 mt-1">
{formatarData(s.criado_em)}
</p>
<div className="flex items-center gap-2 flex-wrap mt-3">
<Link
href={`/solicitacoes/${s.id}`}
className="text-blue-600 font-semibold text-sm hover:underline"
>
Detalhes
</Link>
{s.pode_dar_parecer && (
<a
href={`${DJANGO_BASE_URL}/solicitacao/${s.id}/`}
className="inline-flex items-center gap-1.5 py-2 px-3 rounded-md text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 no-underline"
>
📝 Parecer
</a>
)}
</div>
{s.pode_aprovar && (
<p className="text-slate-600 text-sm font-medium mt-2">
Aprovar/Reprovar na página de detalhes.
</p>
)}
</div>
))}
</div>
<div className="hidden md:block overflow-x-auto bg-white rounded-xl shadow border border-slate-200">
<table className="w-full min-w-[640px] border-collapse text-slate-900">
<thead>
<tr>
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
Tipo
</th>
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
Colaborador
</th>
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
Status
</th>
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
Data
</th>
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
Ações
</th>
</tr>
</thead>
<tbody>
{solicitacoes.map((s) => (
<tr
key={s.id}
className="border-b border-slate-200 hover:bg-slate-50"
>
<td className="p-4 font-semibold min-w-0 max-w-[11rem] break-words">
{s.tipo_display}
</td>
<td className="p-4 text-slate-900 font-medium min-w-0 max-w-[12rem] break-words">
{s.colaborador || (
<span className="text-slate-700 font-medium">N/A</span>
)}
</td>
<td className="p-4 min-w-0 max-w-[14rem]">
<span
className={`inline-flex max-w-full text-left break-words leading-snug py-1 px-2.5 rounded-full text-xs font-bold border ${
STATUS_CLASSES[s.status] ||
"bg-slate-100 text-slate-700 border-slate-200"
}`}
>
{s.status_display}
</span>
</td>
<td className="p-4 text-slate-900 font-medium whitespace-nowrap">
{formatarData(s.criado_em)}
</td>
<td className="p-4">
<div className="flex gap-3 items-center flex-wrap">
<Link
href={`/solicitacoes/${s.id}`}
className="text-blue-600 font-semibold text-sm hover:underline"
>
Detalhes
</Link>
{s.pode_dar_parecer && (
<a
href={`${DJANGO_BASE_URL}/solicitacao/${s.id}/`}
className="inline-flex items-center gap-1.5 py-2 px-4 rounded-md text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 no-underline"
>
📝 Parecer
</a>
)}
{s.pode_aprovar && (
<span className="text-slate-600 text-sm font-medium">
(Aprovar/Reprovar na página de detalhes)
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{pagination.total_pages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={pagination.page <= 1}
className="py-2 px-3.5 border border-slate-200 rounded-md text-slate-600 bg-white text-sm hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<span className="py-2 px-3.5 bg-blue-600 text-white rounded-md font-medium text-sm">
{pagination.page} / {pagination.total_pages}
</span>
<button
onClick={() =>
setPage((p) =>
Math.min(pagination.total_pages, p + 1)
)
}
disabled={pagination.page >= pagination.total_pages}
className="py-2 px-3.5 border border-slate-200 rounded-md text-slate-600 bg-white text-sm hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Próxima
</button>
</div>
)}
</>
) : (
<div className="text-center py-16 px-5 text-slate-500 bg-white rounded-xl border-2 border-dashed border-slate-300">
<p className="m-0 text-lg">🎉 Nenhuma solicitação encontrada.</p>
{isGestor && (
<p className="text-sm mt-2">
Clique em <strong>Nova Solicitação</strong> para iniciar um novo fluxo.
</p>
)}
</div>
)}
</div>
</main>
</div>
);
}