429 lines
17 KiB
TypeScript
429 lines
17 KiB
TypeScript
"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ó são visíveis para
|
||
você. Lembre-se de clicar em{" "}
|
||
<strong>"Enviar para Aprovação"</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>
|
||
);
|
||
}
|