chore(release): snapshot 1.4.2
This commit is contained in:
123
front-end-dash.aggios.app/components/layout/ConfirmDialog.tsx
Normal file
123
front-end-dash.aggios.app/components/layout/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirmar',
|
||||
cancelText = 'Cancelar',
|
||||
variant = 'danger'
|
||||
}: ConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
danger: {
|
||||
icon: 'bg-red-100 dark:bg-red-900/20',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
button: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800'
|
||||
},
|
||||
warning: {
|
||||
icon: 'bg-yellow-100 dark:bg-yellow-900/20',
|
||||
iconColor: 'text-yellow-600 dark:text-yellow-400',
|
||||
button: 'bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-800'
|
||||
},
|
||||
info: {
|
||||
icon: 'bg-blue-100 dark:bg-blue-900/20',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
button: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800'
|
||||
}
|
||||
};
|
||||
|
||||
const style = variantStyles[variant];
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${style.icon}`}>
|
||||
<ExclamationTriangleIcon className={`h-6 w-6 ${style.iconColor}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Dialog.Title className="text-lg font-semibold text-zinc-900 dark:text-white">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className={`flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-colors ${style.button}`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
33
front-end-dash.aggios.app/components/layout/EmptyState.tsx
Normal file
33
front-end-dash.aggios.app/components/layout/EmptyState.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
|
||||
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
|
||||
{description}
|
||||
</p>
|
||||
{actionLabel && onAction && (
|
||||
<button
|
||||
onClick={onAction}
|
||||
className="mt-4 text-sm text-[var(--brand-color)] hover:underline font-medium"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function LoadingState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
front-end-dash.aggios.app/components/layout/PageHeader.tsx
Normal file
31
front-end-dash.aggios.app/components/layout/PageHeader.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, description, actionLabel, onAction }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAction}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
front-end-dash.aggios.app/components/layout/Pagination.tsx
Normal file
108
front-end-dash.aggios.app/components/layout/Pagination.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
onPageChange
|
||||
}: PaginationProps) {
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
const pages = [];
|
||||
const maxVisiblePages = 5;
|
||||
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
||||
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||
|
||||
if (endPage - startPage < maxVisiblePages - 1) {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/50 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Mostrando <span className="font-medium">{startItem}</span> a{' '}
|
||||
<span className="font-medium">{endItem}</span> de{' '}
|
||||
<span className="font-medium">{totalItems}</span> resultados
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
Anterior
|
||||
</button>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
{startPage > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
1
|
||||
</button>
|
||||
{startPage > 2 && (
|
||||
<span className="px-2 text-zinc-400">...</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pages.map(page => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${page === currentPage
|
||||
? 'text-white shadow-sm'
|
||||
: 'bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
style={page === currentPage ? { background: 'var(--gradient)' } : {}}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{endPage < totalPages && (
|
||||
<>
|
||||
{endPage < totalPages - 1 && (
|
||||
<span className="px-2 text-zinc-400">...</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Próxima
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
front-end-dash.aggios.app/components/layout/README.md
Normal file
294
front-end-dash.aggios.app/components/layout/README.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# Componentes de Layout Padrão
|
||||
|
||||
Este diretório contém componentes reutilizáveis para manter um design system consistente em todas as páginas de listagem do dashboard.
|
||||
|
||||
## Componentes Disponíveis
|
||||
|
||||
### 1. **PageHeader**
|
||||
Header padrão com título, descrição e botão de ação.
|
||||
|
||||
```tsx
|
||||
import PageHeader from '@/components/layout/PageHeader';
|
||||
|
||||
<PageHeader
|
||||
title="Agências"
|
||||
description="Gerencie seus parceiros e acompanhe o desempenho."
|
||||
actionLabel="Nova Agência"
|
||||
onAction={() => setModalOpen(true)}
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. **SearchBar**
|
||||
Barra de busca padrão com ícone de lupa.
|
||||
|
||||
```tsx
|
||||
import SearchBar from '@/components/layout/SearchBar';
|
||||
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Buscar por nome, email..."
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. **StatusFilter**
|
||||
Dropdown de filtro de status com Headless UI.
|
||||
|
||||
```tsx
|
||||
import StatusFilter from '@/components/layout/StatusFilter';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ id: 'all', name: 'Todos os Status' },
|
||||
{ id: 'active', name: 'Ativos' },
|
||||
{ id: 'inactive', name: 'Inativos' },
|
||||
];
|
||||
|
||||
<StatusFilter
|
||||
options={STATUS_OPTIONS}
|
||||
selected={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
/>
|
||||
```
|
||||
|
||||
### 4. **EmptyState**
|
||||
Estado vazio padrão com ícone, título e descrição.
|
||||
|
||||
```tsx
|
||||
import EmptyState from '@/components/layout/EmptyState';
|
||||
import { BuildingOfficeIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
<EmptyState
|
||||
icon={<BuildingOfficeIcon className="w-8 h-8 text-zinc-400" />}
|
||||
title="Nenhuma agência encontrada"
|
||||
description="Não encontramos resultados para os filtros selecionados."
|
||||
actionLabel="Limpar todos os filtros"
|
||||
onAction={clearFilters}
|
||||
/>
|
||||
```
|
||||
|
||||
### 5. **LoadingState**
|
||||
Estado de carregamento com spinner.
|
||||
|
||||
```tsx
|
||||
import LoadingState from '@/components/layout/LoadingState';
|
||||
|
||||
{loading && <LoadingState />}
|
||||
```
|
||||
|
||||
### 6. **StatusBadge**
|
||||
Badge de status ativo/inativo com toggle opcional.
|
||||
|
||||
```tsx
|
||||
import StatusBadge from '@/components/layout/StatusBadge';
|
||||
|
||||
<StatusBadge
|
||||
active={item.is_active}
|
||||
onClick={() => toggleStatus(item.id, item.is_active)}
|
||||
activeLabel="Ativo"
|
||||
inactiveLabel="Inativo"
|
||||
/>
|
||||
```
|
||||
|
||||
### 7. **Pagination** ⭐ NEW
|
||||
Paginação funcional com navegação e indicador de páginas.
|
||||
|
||||
```tsx
|
||||
import Pagination from '@/components/layout/Pagination';
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
const totalItems = filteredItems.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
const paginatedItems = filteredItems.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
```
|
||||
|
||||
### 8. **ConfirmDialog** ⭐ NEW
|
||||
Modal de confirmação profissional (substitui `confirm()`).
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import ConfirmDialog from '@/components/layout/ConfirmDialog';
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setItemToDelete(id);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (itemToDelete) {
|
||||
// Executar exclusão
|
||||
deleteItem(itemToDelete);
|
||||
}
|
||||
};
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Excluir Item"
|
||||
message="Tem certeza que deseja excluir este item? Esta ação não pode ser desfeita."
|
||||
confirmText="Excluir"
|
||||
cancelText="Cancelar"
|
||||
variant="danger"
|
||||
/>
|
||||
```
|
||||
|
||||
### 9. **ToastContext & useToast** ⭐ NEW
|
||||
Sistema de notificações toast (substitui `alert()`).
|
||||
|
||||
**Setup no layout:**
|
||||
```tsx
|
||||
import { ToastProvider } from '@/components/layout/ToastContext';
|
||||
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
```
|
||||
|
||||
**Uso nas páginas:**
|
||||
```tsx
|
||||
import { useToast } from '@/components/layout/ToastContext';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// Sucesso
|
||||
toast.success('Item criado!', 'O item foi criado com sucesso.');
|
||||
|
||||
// Erro
|
||||
toast.error('Erro ao excluir', 'Não foi possível excluir o item.');
|
||||
|
||||
// Info
|
||||
toast.info('Informação', 'Ação concluída.');
|
||||
```
|
||||
|
||||
## Exemplo de Uso Completo
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import PageHeader from '@/components/layout/PageHeader';
|
||||
import SearchBar from '@/components/layout/SearchBar';
|
||||
import StatusFilter from '@/components/layout/StatusFilter';
|
||||
import LoadingState from '@/components/layout/LoadingState';
|
||||
import EmptyState from '@/components/layout/EmptyState';
|
||||
import StatusBadge from '@/components/layout/StatusBadge';
|
||||
import TableFooter from '@/components/layout/TableFooter';
|
||||
import { BuildingOfficeIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ id: 'all', name: 'Todos os Status' },
|
||||
{ id: 'active', name: 'Ativos' },
|
||||
{ id: 'inactive', name: 'Inativos' },
|
||||
];
|
||||
|
||||
export default function MyListPage() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState(STATUS_OPTIONS[0]);
|
||||
|
||||
const filteredItems = items.filter(item => {
|
||||
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus =
|
||||
selectedStatus.id === 'all' ? true :
|
||||
selectedStatus.id === 'active' ? item.is_active :
|
||||
!item.is_active;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
|
||||
<PageHeader
|
||||
title="Minha Lista"
|
||||
description="Descrição da página"
|
||||
actionLabel="Novo Item"
|
||||
onAction={() => {}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-center justify-between">
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Buscar..."
|
||||
/>
|
||||
<StatusFilter
|
||||
options={STATUS_OPTIONS}
|
||||
selected={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : filteredItems.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<BuildingOfficeIcon className="w-8 h-8 text-zinc-400" />}
|
||||
title="Nenhum item encontrado"
|
||||
description="Tente ajustar os filtros."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||
<table className="w-full">
|
||||
{/* Sua tabela aqui */}
|
||||
</table>
|
||||
<TableFooter count={filteredItems.length} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Design System
|
||||
|
||||
### Cores
|
||||
```css
|
||||
--gradient: linear-gradient(135deg, #ff3a05, #ff0080)
|
||||
--brand-color: #ff0080
|
||||
```
|
||||
|
||||
### Classes Tailwind Padrão
|
||||
|
||||
**Container principal:**
|
||||
```
|
||||
p-6 max-w-[1600px] mx-auto space-y-6
|
||||
```
|
||||
|
||||
**Tabela:**
|
||||
```
|
||||
bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800
|
||||
```
|
||||
|
||||
**Header da tabela:**
|
||||
```
|
||||
bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800
|
||||
```
|
||||
|
||||
**Linha hover:**
|
||||
```
|
||||
hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors
|
||||
```
|
||||
|
||||
## Benefícios
|
||||
|
||||
✅ **Consistência visual** - Todas as páginas seguem o mesmo padrão
|
||||
✅ **Manutenção fácil** - Altere um componente, atualiza em todas as páginas
|
||||
✅ **Desenvolvimento rápido** - Reutilize componentes prontos
|
||||
✅ **Design system** - Cores e estilos centralizados
|
||||
✅ **Acessibilidade** - Componentes já otimizados
|
||||
24
front-end-dash.aggios.app/components/layout/SearchBar.tsx
Normal file
24
front-end-dash.aggios.app/components/layout/SearchBar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function SearchBar({ value, onChange, placeholder = "Buscar..." }: SearchBarProps) {
|
||||
return (
|
||||
<div className="relative w-full lg:w-96">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
front-end-dash.aggios.app/components/layout/StatusBadge.tsx
Normal file
28
front-end-dash.aggios.app/components/layout/StatusBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
interface StatusBadgeProps {
|
||||
active: boolean;
|
||||
onClick?: () => void;
|
||||
activeLabel?: string;
|
||||
inactiveLabel?: string;
|
||||
}
|
||||
|
||||
export default function StatusBadge({
|
||||
active,
|
||||
onClick,
|
||||
activeLabel = 'Ativo',
|
||||
inactiveLabel = 'Inativo'
|
||||
}: StatusBadgeProps) {
|
||||
const Component = onClick ? 'button' : 'span';
|
||||
|
||||
return (
|
||||
<Component
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border transition-all ${active
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-900/30'
|
||||
: 'bg-zinc-100 text-zinc-600 border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700'
|
||||
} ${onClick ? 'cursor-pointer hover:opacity-80' : ''}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${active ? 'bg-emerald-500' : 'bg-zinc-400'}`} />
|
||||
{active ? activeLabel : inactiveLabel}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
61
front-end-dash.aggios.app/components/layout/StatusFilter.tsx
Normal file
61
front-end-dash.aggios.app/components/layout/StatusFilter.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface StatusOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface StatusFilterProps {
|
||||
options: StatusOption[];
|
||||
selected: StatusOption;
|
||||
onChange: (option: StatusOption) => void;
|
||||
}
|
||||
|
||||
export default function StatusFilter({ options, selected, onChange }: StatusFilterProps) {
|
||||
return (
|
||||
<Listbox value={selected} onChange={onChange}>
|
||||
<div className="relative w-full sm:w-[180px]">
|
||||
<Listbox.Button className="relative w-full cursor-pointer rounded-lg bg-white dark:bg-zinc-900 py-2 pl-3 pr-10 text-left text-sm border border-zinc-200 dark:border-zinc-700 focus:outline-none focus:border-[var(--brand-color)] focus:ring-1 focus:ring-[var(--brand-color)] text-zinc-700 dark:text-zinc-300">
|
||||
<span className="block truncate">{selected.name}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-zinc-400" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-zinc-800 py-1 text-base ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm border border-zinc-200 dark:border-zinc-700">
|
||||
{options.map((option, idx) => (
|
||||
<Listbox.Option
|
||||
key={idx}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${active ? 'bg-zinc-100 dark:bg-zinc-700 text-zinc-900 dark:text-white' : 'text-zinc-900 dark:text-zinc-100'
|
||||
}`
|
||||
}
|
||||
value={option}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{option.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-[var(--brand-color)]">
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
57
front-end-dash.aggios.app/components/layout/ToastContext.tsx
Normal file
57
front-end-dash.aggios.app/components/layout/ToastContext.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from 'react';
|
||||
import ToastNotification, { Toast } from './ToastNotification';
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (type: Toast['type'], title: string, message?: string) => void;
|
||||
success: (title: string, message?: string) => void;
|
||||
error: (title: string, message?: string) => void;
|
||||
info: (title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((type: Toast['type'], title: string, message?: string) => {
|
||||
const id = Date.now().toString();
|
||||
setToasts(prev => [...prev, { id, type, title, message }]);
|
||||
}, []);
|
||||
|
||||
const success = useCallback((title: string, message?: string) => {
|
||||
showToast('success', title, message);
|
||||
}, [showToast]);
|
||||
|
||||
const error = useCallback((title: string, message?: string) => {
|
||||
showToast('error', title, message);
|
||||
}, [showToast]);
|
||||
|
||||
const info = useCallback((title: string, message?: string) => {
|
||||
showToast('info', title, message);
|
||||
}, [showToast]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, success, error, info }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-3 w-96">
|
||||
{toasts.map(toast => (
|
||||
<ToastNotification key={toast.id} toast={toast} onClose={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Fragment, useEffect } from 'react';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
InformationCircleIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ToastNotificationProps {
|
||||
toast: Toast;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ToastNotification({ toast, onClose }: ToastNotificationProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose(toast.id);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, onClose]);
|
||||
|
||||
const styles = {
|
||||
success: {
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
border: 'border-emerald-200 dark:border-emerald-900/30',
|
||||
icon: 'text-emerald-600 dark:text-emerald-400',
|
||||
title: 'text-emerald-900 dark:text-emerald-300',
|
||||
IconComponent: CheckCircleIcon
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||
border: 'border-red-200 dark:border-red-900/30',
|
||||
icon: 'text-red-600 dark:text-red-400',
|
||||
title: 'text-red-900 dark:text-red-300',
|
||||
IconComponent: XCircleIcon
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
border: 'border-blue-200 dark:border-blue-900/30',
|
||||
icon: 'text-blue-600 dark:text-blue-400',
|
||||
title: 'text-blue-900 dark:text-blue-300',
|
||||
IconComponent: InformationCircleIcon
|
||||
}
|
||||
};
|
||||
|
||||
const style = styles[toast.type];
|
||||
const Icon = style.IconComponent;
|
||||
|
||||
return (
|
||||
<Transition
|
||||
show={true}
|
||||
as={Fragment}
|
||||
enter="transform ease-out duration-300 transition"
|
||||
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className={`pointer-events-auto w-full rounded-lg border shadow-lg ${style.bg} ${style.border}`}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className={`h-6 w-6 ${style.icon}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-semibold ${style.title}`}>
|
||||
{toast.title}
|
||||
</p>
|
||||
{toast.message && (
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{toast.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose(toast.id)}
|
||||
className="inline-flex rounded-md text-zinc-400 hover:text-zinc-500 dark:hover:text-zinc-300 focus:outline-none"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user