feat(protocols): implement ProtocolsDashboard and ProtocolsDialog with pagination and search functionality

main
guilherme 2025-11-25 17:18:54 -03:00
parent d0db593171
commit 6b997c4c8c
6 changed files with 605 additions and 54 deletions

View File

@ -11,48 +11,17 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from '@/views/components/ui/chart'; } from '@/views/components/ui/chart';
import { Progress } from '@/views/components/ui/progress';
import { TrendingUp } from 'lucide-react'; import { TrendingUp } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, Pie, PieChart, XAxis } from 'recharts'; import { Bar, BarChart, CartesianGrid, Pie, PieChart, XAxis } from 'recharts';
import { Header } from './views/components/header'; import { Header } from './views/components/header';
import type { ChartConfig } from './views/components/ui/chart'; import type { ChartConfig } from './views/components/ui/chart';
import ProtocolsDashboard from './views/protocols/ProtocolsDashboard';
export const description = 'A stacked bar chart with a legend'; export const description = 'A stacked bar chart with a legend';
export function App() { export function App() {
const stats = [ // dashboard now uses ProtocolsDashboard for protocol status cards
{
title: 'Protocolos criados',
value: 'R$ 0,00',
description: '',
progress: 44.6,
},
{
title: 'Protocolos tramitados',
value: '0 KM',
description: '',
progress: 30.6,
},
{
title: 'Protocolos recebidos',
value: '0',
description: '',
progress: 24.8,
},
{
title: 'Protocolos finalizados',
value: '0',
description: '',
progress: 67.2,
},
{
title: 'Protocolos cancelados',
value: '0',
description: '',
progress: 67.2,
},
];
const bestPricesConfig = { const bestPricesConfig = {
price: { price: {
@ -98,26 +67,7 @@ export function App() {
<div className="p-[18px] flex flex-col gap-6"> <div className="p-[18px] flex flex-col gap-6">
<Header /> <Header />
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-5 lg:grid-cols-2 xl:grid-cols-5"> <ProtocolsDashboard />
{stats.map((stat) => (
<Card
key={stat.title}
className="hover:shadow-md transition-shadow duration-300">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{stat.title}
</CardTitle>
<div className="text-4xl font-bold">{stat.value}</div>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-4">
{stat.description}
</p>
<Progress value={stat.progress} className="h-2" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 grid-cols-1 md:grid-cols-2"> <div className="grid gap-6 grid-cols-1 md:grid-cols-2">
<Card> <Card>
@ -157,7 +107,7 @@ export function App() {
<CardContent className="flex-1 pb-0"> <CardContent className="flex-1 pb-0">
<ChartContainer <ChartContainer
config={vehicleStatusConfig} config={vehicleStatusConfig}
className="[&_.recharts-pie-label-text]:fill-foreground mx-auto aspect-square max-h-[320px] pb-0"> className="[&_.recharts-pie-label-text]:fill-foreground mx-auto aspect-square max-h-80 pb-0">
<PieChart> <PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} /> <ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Pie <Pie

View File

@ -0,0 +1,90 @@
export type ProtocolStatus =
| 'Criado'
| 'Tramitado'
| 'Recebido'
| 'Finalizado'
| 'Cancelado'
| 'Arquivado'
| 'Enviado'
| 'Aguardando';
export type Protocol = {
id: string;
title: string;
date: string;
sender: string;
location: string;
status: ProtocolStatus;
};
const statuses: ProtocolStatus[] = [
'Criado',
'Tramitado',
'Recebido',
'Finalizado',
'Cancelado',
'Arquivado',
'Enviado',
'Aguardando',
];
// generate mock data
const protocols: Protocol[] = Array.from({ length: 120 }).map((_, i) => {
const status = statuses[i % statuses.length];
return {
id: String(1000 + i),
title: `NOTA FISCAL ${1000 + i}`,
date: new Date(Date.now() - i * 1000 * 60 * 60 * 24)
.toISOString()
.split('T')[0],
sender: `PREFEITURA ${i % 10}`,
location: ['Sala do financeiro', 'Protocolo Central', 'Expediente'][i % 3],
status,
};
});
export type ProtocolsPage = {
items: Protocol[];
total: number;
};
export function getProtocols({
status,
page = 1,
pageSize = 10,
search = '',
}: {
status?: ProtocolStatus | 'Todos';
page?: number;
pageSize?: number;
search?: string;
}): Promise<ProtocolsPage> {
return new Promise((resolve) => {
setTimeout(() => {
let items = protocols.slice();
if (status && status !== 'Todos') {
items = items.filter((p) => p.status === status);
}
if (search) {
const q = search.toLowerCase();
items = items.filter(
(p) =>
p.title.toLowerCase().includes(q) ||
p.sender.toLowerCase().includes(q),
);
}
const total = items.length;
const start = (page - 1) * pageSize;
const paged = items.slice(start, start + pageSize);
resolve({ items: paged, total });
}, 250);
});
}
export function getCounts() {
const counts: Record<string, number> = { Todos: protocols.length };
for (const s of statuses) {
counts[s] = protocols.filter((p) => p.status === s).length;
}
return counts;
}

View File

@ -0,0 +1,120 @@
import { cn } from '@/app/utils';
import { Button, buttonVariants } from '@/views/components/ui/button';
import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react';
import { MoreHorizontalIcon } from 'lucide-react';
import * as React from 'react';
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>;
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}>
<CaretLeftIcon />
<span className="hidden sm:block">Anterior</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}>
<span className="hidden sm:block">Próxima</span>
<CaretRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...props}>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/app/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,17 @@
'use client';
import ProtocolsDialog from './ProtocolsDialog';
const statuses = ['Criado', 'Tramitado', 'Recebido', 'Finalizado', 'Cancelado'];
export function ProtocolsDashboard() {
return (
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-5 lg:grid-cols-5">
{statuses.map((s) => (
<ProtocolsDialog status={s} />
))}
</div>
);
}
export default ProtocolsDashboard;

View File

@ -0,0 +1,260 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/set-state-in-effect */
'use client';
import { getCounts, getProtocols } from '@/services/protocolService';
import { Button } from '@/views/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/views/components/ui/dialog';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/views/components/ui/pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/views/components/ui/table';
import { useEffect, useState } from 'react';
import type { Protocol } from '@/services/protocolService';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '../components/ui/card';
type Props = {
status: string;
};
export function ProtocolsDialog({ status }: Props) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [allItems, setAllItems] = useState<Protocol[]>([]);
const [paginatedItems, setPaginatedItems] = useState<Protocol[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [counts, setCounts] = useState<Record<string, number>>({ Todos: 0 });
const label = `${status} (${counts[status] ?? 0})`;
useEffect(() => {
setCounts(getCounts());
}, []);
useEffect(() => {
if (!open) return;
setLoading(true);
getProtocols({
status: status as any,
page: 1,
pageSize: 1000,
search,
}).then((r) => {
setAllItems(r.items);
setTotal(r.total);
setLoading(false);
setPage(1); // Reset para primeira página quando buscar
});
}, [open, search, status]);
// Efeito para aplicar a paginação local
useEffect(() => {
if (allItems.length === 0) {
setPaginatedItems([]);
return;
}
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const itemsForCurrentPage = allItems.slice(startIndex, endIndex);
setPaginatedItems(itemsForCurrentPage);
}, [allItems, page, pageSize]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
function getPageItems(totalPages: number, current: number) {
const pages: Array<number | 'ellipsis'> = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
return pages;
}
pages.push(1);
const left = Math.max(2, current - 1);
const right = Math.min(totalPages - 1, current + 1);
if (left > 2) pages.push('ellipsis');
for (let i = left; i <= right; i++) pages.push(i);
if (right < totalPages - 1) pages.push('ellipsis');
pages.push(totalPages);
return pages;
}
const handlePreviousPage = () => {
if (page > 1) {
setPage(page - 1);
}
};
const handleNextPage = () => {
if (page < totalPages) {
setPage(page + 1);
}
};
const handlePageClick = (pageNumber: number) => {
setPage(pageNumber);
};
const pageItems = getPageItems(totalPages, page);
return (
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) {
// Resetar estado quando o dialog fechar
setSearch('');
setPage(1);
}
}}>
<DialogTrigger asChild>
<Card className="hover:shadow-md transition-shadow duration-200 cursor-pointer">
<CardHeader>
<CardTitle className="text-sm">{`Protocolos ${status.toLowerCase()}`}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold mb-3">{counts[status] ?? 0}</div>
</CardContent>
</Card>
</DialogTrigger>
<DialogContent className="min-w-[80vw] max-w-[80vw] h-full max-h-[80vh] overflow-auto">
<DialogHeader>
<DialogTitle>{label}</DialogTitle>
</DialogHeader>
<div className="flex gap-2 items-center mb-4">
<input
className="flex-1 rounded-sm border px-3 py-2"
placeholder={`Pesquisar em ${label.toLowerCase()}...`}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
<div className="text-sm text-muted-foreground">
{loading ? 'Carregando...' : `${total} encontrados`}
</div>
</div>
<div className="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Código</TableHead>
<TableHead>Assunto</TableHead>
<TableHead className="w-[120px]">Data</TableHead>
<TableHead>Solicitante</TableHead>
<TableHead>Local</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedItems.map((protocol) => (
<TableRow key={protocol.id}>
<TableCell className="font-medium">{protocol.id}</TableCell>
<TableCell>{protocol.title}</TableCell>
<TableCell>{protocol.date}</TableCell>
<TableCell>{protocol.sender}</TableCell>
<TableCell>{protocol.location}</TableCell>
</TableRow>
))}
{paginatedItems.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
{loading ? 'Carregando...' : 'Nenhum protocolo encontrado.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between gap-2 mt-4">
<div className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</div>
<Pagination className='w-fit m-0'>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={handlePreviousPage}
className={page <= 1 ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
{pageItems.map((pageItem, index) => (
<PaginationItem key={index}>
{pageItem === 'ellipsis' ? (
<PaginationEllipsis />
) : (
<PaginationLink
href="#"
onClick={() => handlePageClick(pageItem)}
isActive={pageItem === page}>
{pageItem}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={handleNextPage}
className={
page >= totalPages ? 'pointer-events-none opacity-50' : ''
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
<DialogFooter>
<Button onClick={() => setOpen(false)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default ProtocolsDialog;