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

main
guilherme 2025-11-26 09:28:29 -03:00
parent 6b997c4c8c
commit a6fe2ccc85
9 changed files with 378 additions and 322 deletions

58
package-lock.json generated
View File

@ -25,6 +25,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
@ -3207,6 +3208,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.0.tgz",
"integrity": "sha512-vXiThu1/rlos7EGu8TuNZQEg2e9TvhH9dmS4T4ZVzB7Ao1agEZ6EG3sn5n+hZRYUgduISd1HpngFzAZiDGm5vQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4721,6 +4735,44 @@
} }
} }
}, },
"node_modules/react-router": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
"integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
"integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.6"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-smooth": { "node_modules/react-smooth": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@ -4873,6 +4925,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -27,6 +27,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",

View File

@ -17,27 +17,24 @@ 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'; import { ProtocolsDashboard } from './views/protocols/protocols-dashboard';
export const description = 'A stacked bar chart with a legend'; export const description = 'A stacked bar chart with a legend';
export function App() { const bestPricesConfig = {
// dashboard now uses ProtocolsDashboard for protocol status cards
const bestPricesConfig = {
price: { price: {
label: 'Preço', label: 'Preço',
color: '#007cb8', color: '#007cb8',
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
const bestPricesData = [ const bestPricesData = [
{ station: 'Shell', price: 5.42 }, { station: 'Shell', price: 5.42 },
{ station: 'Ipiranga', price: 5.35 }, { station: 'Ipiranga', price: 5.35 },
{ station: 'BR', price: 5.38 }, { station: 'BR', price: 5.38 },
{ station: 'Ale', price: 5.3 }, { station: 'Ale', price: 5.3 },
]; ];
const vehicleStatusConfig = { const vehicleStatusConfig = {
active: { active: {
label: 'Ativos', label: 'Ativos',
color: '#007cb8', color: '#007cb8',
@ -54,15 +51,16 @@ export function App() {
label: 'Reservados', label: 'Reservados',
color: '#7cd5fd', color: '#7cd5fd',
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
const vehicleStatusData = [ const vehicleStatusData = [
{ status: 'active', count: 45, fill: '#007cb8' }, { status: 'active', count: 45, fill: '#007cb8' },
{ status: 'maintenance', count: 12, fill: '#0ca9eb' }, { status: 'maintenance', count: 12, fill: '#0ca9eb' },
{ status: 'inactive', count: 8, fill: '#36c1fa' }, { status: 'inactive', count: 8, fill: '#36c1fa' },
{ status: 'reserved', count: 15, fill: '#7cd5fd' }, { status: 'reserved', count: 15, fill: '#7cd5fd' },
]; ];
export function App() {
return ( return (
<div className="p-[18px] flex flex-col gap-6"> <div className="p-[18px] flex flex-col gap-6">
<Header /> <Header />

View File

@ -1,12 +1,16 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './styles/index.css'; import './styles/index.css';
import { App } from './App.tsx'; import { App } from './App.tsx';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter>
<App /> <App />
</StrictMode> </BrowserRouter>
</StrictMode>,
); );

View File

@ -0,0 +1,171 @@
/* eslint-disable react-hooks/set-state-in-effect */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Badge } from '@/views/components/ui/badge';
import { Button } from '@/views/components/ui/button';
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/views/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/views/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/views/components/ui/table';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
interface ProtocolsTableProps {
tasks: any[];
title: string;
description: string;
}
export function ProtocolsTable({
tasks,
title,
description,
}: ProtocolsTableProps) {
const [page, setPage] = useState(0);
const [size, setSize] = useState(10);
const [loading, setLoading] = useState(false);
const total = tasks.length;
const totalPages = Math.ceil(total / size);
const startIndex = page * size;
const endIndex = Math.min(startIndex + size, total);
useEffect(() => {
setLoading(true);
const timer = setTimeout(() => {
setLoading(false);
}, 500);
return () => clearTimeout(timer);
}, [page, size]);
const protocols = useMemo(() => {
return tasks.slice(startIndex, endIndex);
}, [tasks, startIndex, endIndex]);
function handlePreviousPage() {
setPage((prev) => Math.max(prev - 1, 0));
}
function handleNextPage() {
setPage((prev) => Math.min(prev + 1, totalPages - 1));
}
return (
<DialogContent className="min-w-7xl max-w-7xl h-full max-h-[90vh] flex flex-col overflow-y-auto">
<DialogHeader>
<div className="mb-4">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</div>
</DialogHeader>
<div className="mt-4 h-full flex flex-col">
<div className="flex justify-between items-center mb-4">
<h2 className="font-bold">Detalhes dos Protocolos</h2>
<Badge variant="outline">Total: {total}</Badge>
</div>
{protocols.length > 0 ? (
<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>
{protocols.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>
))}
{protocols.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
{loading ? 'Carregando...' : 'Nenhum protocolo encontrado.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
) : (
<div className="text-center py-4 my-auto text-muted-foreground">
Nenhuma tarefa encontrada
</div>
)}
<div className="flex items-center justify-between px-2 mt-auto pb-4">
<div className="flex-1 text-sm text-muted-foreground">
Mostrando {startIndex + 1} - {endIndex} de {total} tarefas
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Tarefas por página</p>
<Select
value={`${size}`}
onValueChange={(value) => {
const newSize = Number(value);
setSize(newSize);
const newPage = Math.floor(startIndex / newSize);
setPage(newPage);
}}>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={size} />
</SelectTrigger>
<SelectContent side="top">
{[5, 10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={handlePreviousPage}
disabled={page === 0}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm">
Página {page + 1} de {totalPages || 1}
</div>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={handleNextPage}
disabled={page >= totalPages - 1 || totalPages === 0}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
);
}

View File

@ -0,0 +1,45 @@
/* eslint-disable react-refresh/only-export-components */
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/app/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border border-gray-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2 dark:border-gray-800 dark:focus:ring-gray-300',
{
variants: {
variant: {
default:
'border-transparent bg-agprimary text-gray-50 shadow hover:bg-agprimary/80 dark:bg-gray-50 dark:text-agprimary dark:hover:bg-gray-50/80',
secondary:
'border-transparent bg-gray-100 text-agprimary hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80',
destructive:
'border-transparent bg-red-500 text-gray-50 shadow hover:bg-red-500/80 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/80',
outline: 'text-gray-950 dark:text-gray-50',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -1,17 +0,0 @@
'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

@ -1,260 +0,0 @@
/* 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;

View File

@ -0,0 +1,56 @@
import { Dialog, DialogTrigger } from '@/views/components/ui/dialog';
import { useState } from 'react';
import type { Protocol } from '@/services/protocolService';
import { InfoIcon } from '@phosphor-icons/react';
import { ProtocolsTable } from '../components/protocols-table';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '../components/ui/card';
const statuses = ['Criado', 'Tramitado', 'Recebido', 'Finalizado', 'Cancelado'];
export function ProtocolsDashboard() {
const [open, setOpen] = useState(false);
const [paginatedItems] = useState<Protocol[]>([]);
const [counts] = useState<Record<string, number>>({ Todos: 0 });
return (
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-5 lg:grid-cols-5">
{statuses.map((status) => (
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
}}>
<DialogTrigger asChild>
<Card className="cursor-pointer hover:shadow-md transition-shadow bg-muted/50">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-1">
{`Protocolos ${status.toLowerCase()}`}
<span title="Informações sobre os protocolos">
<InfoIcon size={14} />
</span>
</CardDescription>
<CardTitle className="text-3xl">
{counts[status] ?? 0}
</CardTitle>
</CardHeader>
<CardContent></CardContent>
</Card>
</DialogTrigger>
<ProtocolsTable
tasks={paginatedItems}
title={`Protocolos ${status.toLowerCase()}`}
description={`Lista de protocolos com status ${status.toLowerCase()}.`}
/>
</Dialog>
))}
</div>
);
}