Um módulo no SIGA-UNICV é um conjunto de funcionalidades independente com seu próprio roteamento, permissões e componentes de UI. Os módulos são registrados e integrados automaticamente na aplicação.
src/app/(modules)/[nome-do-modulo]/
├── module.config.tsx # Configuração e navegação do módulo
├── page.tsx # Página inicial do módulo
├── components/ # Componentes específicos do módulo (opcional)
│ └── CustomComponent.tsx
└── [funcionalidade]/ # Pastas de funcionalidades individuais
├── action.ts # Chamadas API e server actions
├── actions.ts # Nomenclatura alternativa (ambas aceitáveis)
├── page.tsx # Componente UI da funcionalidade
└── types.ts # Definições de tipos TypeScript
src/app/(modules)/inventory/
├── module.config.tsx
├── page.tsx
├── components/
│ └── InventoryFilters.tsx
├── products/
│ ├── actions.ts
│ ├── page.tsx
│ └── types.ts
├── categories/
│ ├── actions.ts
│ ├── page.tsx
│ └── types.ts
└── suppliers/
├── actions.ts
├── page.tsx
├── types.ts
└── [supplierId]/ # Rota aninhada
├── actions.ts
├── page.tsx
└── types.ts
module.config.tsx (sempre este nome exato)page.tsx (convenção do Next.js)actions.ts (sempre este nome exato)types.ts (sempre este nome exato)PascalCase.tsx (ex: ProductCard.tsx)kebab-case (ex: inventory-management)kebab-case (ex: product-category)[nomeParametro] (ex: [productId])PascalCase (ex: ProductList)camelCase (ex: getProducts)UPPER_SNAKE_CASE (ex: API_BASE_URL)PascalCase com sufixo descritivo (ex: ProductItem, ProductFilter)O backend do SIGA usa um formato de resposta padronizado:
interface BackendResponse<T> {
payload?: T;
error?: {
hasError: boolean;
errorCode?: number;
message?: string;
cause?: string;
};
}
Todas as requisições API requerem autenticação. Use o helper getAuthHeaders:
// filepath: src/app/(modules)/inventory/products/action.ts
import { getSession } from 'next-auth/react';
async function getAuthHeaders(
isMultipart: boolean = false,
): Promise<HeadersInit> {
const session = await getSession();
const token = session?.user?.accessToken;
if (!token) {
throw new Error('Authentication required');
}
const headers: HeadersInit = {
Authorization: `Bearer ${token}`,
};
if (!isMultipart) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
O workspace usa uma função centralizada apiRequest de src/lib/api.ts:
import { apiRequest, createApiUrl } from '@/lib/api';
// Exemplo de requisição GET
const data = await apiRequest<ResponseType>(
createApiUrl('/v1/module/resource'),
{
method: 'GET',
headers: await getAuthHeaders(),
},
);
// Exemplo de requisição POST
const data = await apiRequest<ResponseType>(
createApiUrl('/v1/module/resource'),
{
method: 'POST',
headers: await getAuthHeaders(),
body: JSON.stringify(payload),
},
);
A função apiRequest trata automaticamente:
/signin/forbiddenpayload de BackendResponse)Os endpoints do backend suportam filtragem padronizada:
export interface StandardFilter {
limit?: number; // Itens por página (padrão: 25)
offset?: number; // Offset de paginação (padrão: 0)
// Adicionar filtros específicos da entidade
}
// Exemplo de uso
const params = new URLSearchParams();
if (filters.limit !== undefined)
params.append('limit', filters.limit.toString());
if (filters.offset !== undefined)
params.append('offset', filters.offset.toString());
if (filters.name) params.append('name', filters.name);
const url = createApiUrl(`/v1/module/resource?${params.toString()}`);
Para endpoints que aceitam upload de arquivos:
async function uploadWithFile(
id: number,
data: UpdateData,
file?: File,
): Promise<ResponseType> {
const authHeaders = await getAuthHeaders(true); // Nota: true para multipart
const formData = new FormData();
// Adicionar campos regulares
if (data.field1) formData.append('field1', data.field1);
if (data.field2) formData.append('field2', data.field2.toString());
// Adicionar arquivo se fornecido
if (file) {
formData.append('file', file);
}
const response = await apiRequest<ResponseType>(
createApiUrl(`/v1/module/resource/${id}`),
{
method: 'PUT',
headers: authHeaders,
body: formData,
},
);
return response;
}
Para baixar arquivos do backend:
export async function downloadDocument(
id: number,
): Promise<{ blob: Blob; filename: string }> {
const session = await getSession();
const token = session?.user?.accessToken;
if (!token) {
throw new Error('Authentication required');
}
const response = await fetch(createApiUrl(`/v1/module/resource/${id}/file`), {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to download file');
}
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition
? contentDisposition.split('filename=')[1]?.replace(/"/g, '') ||
'document.pdf'
: 'document.pdf';
return { blob, filename };
}
// Uso no componente
const handleDownload = async (id: number) => {
const { blob, filename } = await downloadDocument(id);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Download iniciado!');
};
Baseado nos padrões do workspace:
// Resposta de lista
export interface ListResponse<T> {
items: T[];
totalCount: number;
}
// Exemplo: Lista de produtos
export interface ProductResponse extends ListResponse<ProductItem> {}
// Resposta de item único
export interface ProductItem {
id: number;
// ... outros campos
created_at?: string;
updated_at?: string;
}
Criar a pasta base do módulo:
mkdir -p src/app/(modules)/inventory
cd src/app/(modules)/inventory
Criar module.config.tsx:
// filepath: src/app/(modules)/inventory/module.config.tsx
import React from 'react';
import { Package } from 'lucide-react';
export const InventoryModuleConfig = {
icon: <Package className="h-6 w-6" />,
name: 'Inventário',
description: 'Gestão de produtos, categorias e fornecedores',
path: '/inventory',
basePath: '/inventory',
// Permissões necessárias para ver este módulo
requiredPermissions: ['/v1/inventory/*'],
// Seções de navegação
sections: [
{
label: 'Gestão',
items: [
{
label: 'Produtos',
href: '/inventory/products',
requiredPermissions: ['/v1/inventory/products/list'],
},
{
label: 'Categorias',
href: '/inventory/categories',
requiredPermissions: ['/v1/inventory/categories/list'],
},
{
label: 'Fornecedores',
href: '/inventory/suppliers',
requiredPermissions: ['/v1/inventory/suppliers/list'],
},
],
},
],
};
Criar page.tsx:
// filepath: src/app/(modules)/inventory/page.tsx
export default function InventoryHome() {
return (
<div className="p-6">
<h1 className="mb-4 text-2xl font-bold">Bem-vindo ao Inventário</h1>
<p className="text-gray-600">
Selecione uma opção no menu lateral para começar.
</p>
</div>
);
}
Adicionar ao autoRegister.ts:
// filepath: src/registry/autoRegister.ts
import { InventoryModuleConfig } from '@/app/(modules)/inventory/module.config';
// Registrar o módulo
moduleRegistry.registerModule('Inventory', InventoryModuleConfig);
mkdir -p src/app/(modules)/inventory/products
Criar types.ts seguindo os padrões do workspace:
// filepath: src/app/(modules)/inventory/products/types.ts
// Interface principal da entidade
export interface ProductItem {
id: number;
name: string;
code: string;
description?: string;
category_id: number;
category_name?: string;
price: number;
stock: number;
active: boolean;
created_at: string;
updated_at: string;
}
// Payload de criação (camelCase ou snake_case baseado no backend)
export interface ProductCreate {
name: string;
code: string;
description?: string;
category_id: number;
price: number;
stock: number;
active?: boolean;
}
// Payload de atualização (parcial do create)
export interface ProductUpdate extends Partial<ProductCreate> {}
// Resposta de lista da API
export interface ProductResponse {
items: ProductItem[];
totalCount: number;
}
// Parâmetros de filtro
export interface ProductFilter {
name?: string;
code?: string;
category_id?: number;
active?: boolean;
min_price?: number;
max_price?: number;
limit?: number;
offset?: number;
}
// Exibição em tabela com número serial
export interface ProductTableItem extends ProductItem {
serial: number;
}
Criar actions.ts seguindo os padrões do workspace:
// filepath: src/app/(modules)/inventory/products/action.ts
import {
ProductResponse,
ProductFilter,
ProductCreate,
ProductUpdate,
ProductItem,
} from './types';
import { apiRequest, createApiUrl } from '@/lib/api';
import { getSession } from 'next-auth/react';
// Helper de autenticação
async function getAuthHeaders(): Promise<HeadersInit> {
const session = await getSession();
const token = session?.user?.accessToken;
if (!token) {
throw new Error('Authentication required');
}
return {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
}
/**
* Obter lista de produtos com filtros
*/
export async function getProducts(
filters: ProductFilter = {},
): Promise<ProductResponse> {
const authHeaders = await getAuthHeaders();
// Construir parâmetros de query
const params = new URLSearchParams();
if (filters.name) params.append('name', filters.name);
if (filters.code) params.append('code', filters.code);
if (filters.category_id !== undefined) {
params.append('category_id', filters.category_id.toString());
}
if (filters.active !== undefined) {
params.append('active', filters.active.toString());
}
if (filters.limit !== undefined) {
params.append('limit', filters.limit.toString());
}
if (filters.offset !== undefined) {
params.append('offset', filters.offset.toString());
}
const url = createApiUrl(
`/v1/inventory/products${params.toString() ? `?${params.toString()}` : ''}`,
);
const data = await apiRequest<ProductResponse>(url, {
method: 'GET',
headers: authHeaders,
});
return data as ProductResponse;
}
/**
* Obter produto único por ID
*/
export async function getProductById(id: number): Promise<ProductItem> {
const authHeaders = await getAuthHeaders();
const data = await apiRequest<ProductItem>(
createApiUrl(`/v1/inventory/products/${id}`),
{
method: 'GET',
headers: authHeaders,
},
);
return data as ProductItem;
}
/**
* Criar novo produto
*/
export async function createProduct(
productData: ProductCreate,
): Promise<ProductItem> {
const authHeaders = await getAuthHeaders();
const data = await apiRequest<ProductItem>(
createApiUrl('/v1/inventory/products'),
{
method: 'POST',
headers: authHeaders,
body: JSON.stringify(productData),
},
);
return data as ProductItem;
}
/**
* Atualizar produto existente
*/
export async function updateProduct(
id: number,
productData: ProductUpdate,
): Promise<ProductItem> {
const authHeaders = await getAuthHeaders();
const data = await apiRequest<ProductItem>(
createApiUrl(`/v1/inventory/products/${id}`),
{
method: 'PUT',
headers: authHeaders,
body: JSON.stringify(productData),
},
);
return data as ProductItem;
}
/**
* Eliminar produto
*/
export async function deleteProduct(id: number): Promise<void> {
const authHeaders = await getAuthHeaders();
await apiRequest<void>(createApiUrl(`/v1/inventory/products/${id}`), {
method: 'DELETE',
headers: authHeaders,
});
}
Criar page.tsx seguindo os padrões do workspace:
// filepath: src/app/(modules)/inventory/products/page.tsx
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { toast } from 'react-toastify';
import { Plus } from 'lucide-react';
// Componentes
import ComponentCard from '@/components/common/ComponentCard';
import GenericTable, { Column } from '@/components/tables/GenericTable';
import Button from '@/components/ui/button/Button';
import TableActions from '@/components/tables/TableActions';
import AdvancedFilterModal, {
FilterFieldConfig,
} from '@/app/(modules)/admin/components/AdvancedFilterModal';
// Hooks
import { useUserPermissions } from '@/hooks/useUserPermissions';
// Actions & Types
import { getProducts, deleteProduct } from './actions';
import type { ProductTableItem, ProductFilter, ProductItem } from './types';
export default function ProductList() {
// Gestão de estado
const [products, setProducts] = useState<ProductItem[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [limit, setLimit] = useState(25);
const [offset, setOffset] = useState(0);
const [globalFilter, setGlobalFilter] = useState('');
const [filters, setFilters] = useState<ProductFilter>({});
const [showAdvancedModal, setShowAdvancedModal] = useState(false);
const [modalFilters, setModalFilters] = useState<ProductFilter>({});
// Permissões
const { permissions, loading: loadingPermissions } = useUserPermissions();
// Configuração de filtros
const filterFieldConfig: FilterFieldConfig[] = [
{ name: 'name', label: 'Nome', type: 'text' },
{ name: 'code', label: 'Código', type: 'text' },
{
name: 'active',
label: 'Ativo',
type: 'select',
options: [
{ value: 'true', label: 'Sim' },
{ value: 'false', label: 'Não' },
],
},
];
// Carregar dados ao montar e nas mudanças de filtro
useEffect(() => {
loadProducts();
}, [filters, limit, offset]);
const loadProducts = async () => {
setLoading(true);
try {
const data = await getProducts({ ...filters, limit, offset });
setProducts(data.items);
setTotalCount(data.totalCount);
} catch (error) {
toast.error('Erro ao carregar produtos');
console.error('Load products error:', error);
} finally {
setLoading(false);
}
};
// Handlers
const handleDelete = async (product: ProductItem) => {
if (!confirm(`Eliminar "${product.name}"?`)) return;
try {
await deleteProduct(product.id);
toast.success('Produto eliminado com sucesso!');
loadProducts();
} catch (error) {
toast.error('Erro ao eliminar produto');
}
};
const handleApplyFilters = (newFilters: ProductFilter) => {
setFilters(newFilters);
setOffset(0); // Resetar paginação
setShowAdvancedModal(false);
};
const handleClearFilters = () => {
setFilters({});
setModalFilters({});
setOffset(0);
setShowAdvancedModal(false);
};
// Configuração de colunas da tabela
const columns: Column<ProductTableItem>[] = [
{ key: 'serial', label: '#', sortable: false },
{ key: 'name', label: 'Nome', sortable: true },
{ key: 'code', label: 'Código', sortable: true },
{ key: 'category_name', label: 'Categoria', sortable: true },
{
key: 'price',
label: 'Preço',
sortable: true,
render: (value) => `${value} CVE`,
},
{ key: 'stock', label: 'Stock', sortable: true },
{
key: 'active',
label: 'Ativo',
sortable: true,
render: (value) => (value ? 'Sim' : 'Não'),
},
{
key: 'actions',
label: 'Ações',
render: (_, row) => (
<TableActions
onView={() => console.log('View', row.id)}
onEdit={() => console.log('Edit', row.id)}
onDelete={() => handleDelete(row)}
viewPermission="/v1/inventory/products/read"
editPermission="/v1/inventory/products/update"
deletePermission="/v1/inventory/products/delete"
permissions={permissions}
loadingPermissions={loadingPermissions}
/>
),
},
];
// Adicionar números seriais
const tableData: ProductTableItem[] = products.map((product, index) => ({
...product,
serial: offset + index + 1,
}));
return (
<ComponentCard
isLoading={loading}
breadcrumbs={[
{ label: 'Inventário', href: '/inventory' },
{ label: 'Produtos', href: '/inventory/products' },
]}
>
<GenericTable
columns={columns}
data={tableData}
totalCount={totalCount}
limit={limit}
offset={offset}
onLimitChange={setLimit}
onOffsetChange={setOffset}
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
onAdvancedFilterClick={() => setShowAdvancedModal(true)}
actionButton={
<Button
permission="/v1/inventory/products/create"
permissions={permissions}
loadingPermissions={loadingPermissions}
onClick={() => console.log('Create new')}
>
<Plus className="mr-2 h-4 w-4" />
Criar Novo
</Button>
}
/>
<AdvancedFilterModal
filters={modalFilters}
onFiltersChange={setModalFilters}
onApply={handleApplyFilters}
onClose={() => setShowAdvancedModal(false)}
isOpen={showAdvancedModal}
filterFieldConfig={filterFieldConfig}
onClear={handleClearFilters}
/>
</ComponentCard>
);
}
Componente wrapper de (src/components/common/ComponentCard.tsx) com breadcrumbs e estados de carregamento.
<ComponentCard
isLoading={loading}
breadcrumbs={[
{ label: 'Início', href: '/' },
{ label: 'Produtos', href: '/products' },
]}
>
{/* Seu conteúdo */}
</ComponentCard>
Tabela de dados reutilizável de (src/components/tables/GenericTable.tsx) com paginação, filtragem e ordenação.
<GenericTable
columns={columns}
data={data}
totalCount={totalCount}
limit={limit}
offset={offset}
onLimitChange={setLimit}
onOffsetChange={setOffset}
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
onAdvancedFilterClick={() => setShowModal(true)}
actionButton={<Button onClick={handleCreate}>Criar Novo</Button>}
/>
Configuração de Colunas:
const columns: Column<DataType>[] = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Nome', sortable: true },
{ key: 'price', label: 'Preço', render: (value) => `${value} CVE` },
];
Componente de botão com controle de permissões de (src/components/ui/button/Button.tsx).
<Button
permission="/v1/resource/action"
permissions={permissions}
loadingPermissions={loadingPermissions}
variant="primary"
size="md"
onClick={handleClick}
disabled={false}
>
Texto do Botão
</Button>
Botões de ação de (src/components/tables/TableActions.tsx) para linhas de tabela.
<TableActions
onView={() => handleView(row)}
onEdit={() => handleEdit(row)}
onDelete={() => handleDelete(row)}
viewPermission="/v1/resource/read"
editPermission="/v1/resource/update"
deletePermission="/v1/resource/delete"
permissions={permissions}
loadingPermissions={loadingPermissions}
/>
Modal de (<src/app/(modules)/admin/components/AdvancedFilterModal.tsx>) para filtragem complexa.
const filterConfig: FilterFieldConfig[] = [
{ name: 'name', label: 'Nome', type: 'text' },
{
name: 'status',
label: 'Estado',
type: 'select',
options: [
{ value: 'active', label: 'Ativo' },
{ value: 'inactive', label: 'Inativo' },
],
},
{ name: 'date', label: 'Data', type: 'date' },
];
<AdvancedFilterModal
filters={modalFilters}
onFiltersChange={setModalFilters}
onApply={handleApplyFilters}
onClose={() => setShowModal(false)}
isOpen={showModal}
filterFieldConfig={filterConfig}
onClear={handleClearFilters}
/>;
As permissões seguem a estrutura de caminho da API REST:
/v1/{modulo}/{recurso}/{acao}
Exemplos:
/v1/inventory/products/list - Ver lista de produtos/v1/inventory/products/read - Ver produto individual/v1/inventory/products/create - Criar produto/v1/inventory/products/update - Atualizar produto/v1/inventory/products/delete - Eliminar produtoImportante: As permissões são criadas e aplicadas pelo backend. O frontend apenas reflete essas permissões para controlar a visibilidade da UI e experiência do utilizador.
/v1/{modulo}/{recurso}/{acao}@require_permissionExemplo de controlador backend com permissão:
@router.get("/", response_model=UniversalResponse)
@require_permission("/v1/inventory/products/list")
async def get_all_products(
limit: int = Query(50),
offset: int = Query(0),
db: AsyncSession = Depends(get_db)
):
# Apenas utilizadores com permissão "/v1/inventory/products/list" podem aceder
results = await product_service.get_all(db, limit, offset)
return UniversalResponse.success_response(payload=results)
useUserPermissionsCompreensão Crítica:
/v1/inventory/products/list nas suas permissões do backend é o que lhe dá acesso aos dadosimport { useUserPermissions } from '@/hooks/useUserPermissions';
const { permissions, loading } = useUserPermissions();
O que isto faz:
const hasPermission = permissions.includes('/v1/inventory/products/create');
if (hasPermission) {
// Mostrar botão criar
}
Nota: Isto apenas controla a visibilidade da UI. O backend ainda aplicará a permissão quando a API for chamada.
<Button
permission="/v1/inventory/products/create"
permissions={permissions}
loadingPermissions={loadingPermissions}
onClick={handleCreate}
>
Criar Produto
</Button>
O que isto faz:
Em module.config.tsx:
export const InventoryModuleConfig = {
// Módulo visível se utilizador tiver QUALQUER destas permissões
requiredPermissions: ['/v1/inventory/products/list'],
sections: [
{
label: 'Gestão',
items: [
{
label: 'Produtos',
href: '/inventory/products',
requiredPermissions: ['/v1/inventory/products/list'],
},
],
},
],
};
O que isto faz:
Todas as permissões devem ser criadas no backend primeiro:
Definir permissão no controlador backend:
@require_permission("/v1/inventory/products/create")
async def create_product(...):
...
Registar rota de permissão no backend usando os endpoints de admin:
/v1/basic/routes/create - Cria a rota de permissãoAtribuir permissão aos grupos:
Usar permissão no frontend:
<Button permission="/v1/inventory/products/create">Criar</Button>
✅ Bom:
/v1/inventory/products/list # Consistente com endpoint
/v1/inventory/products/create # Ação clara
/v1/inventory/products/update # Corresponde ao padrão CRUD
/v1/inventory/products/delete # Nomenclatura padrão
❌ Mau:
/v1/products # Falta módulo
/inventory/products/create # Falta versão
/v1/inventory/product/create # Singular/plural inconsistente
/v1/inventory/products/add # Nome de ação não padrão
Se uma funcionalidade não estiver acessível:
Verificar associação de grupo do utilizador:
SELECT * FROM siga_user_groups WHERE siga_user_id = <user_id>Verificar permissões do grupo:
SELECT * FROM siga_group_permissions WHERE group_id = <group_id>Verificar se rota de permissão existe:
SELECT * FROM siga_routes WHERE route_path = '<permission_path>'Verificar obtenção de permissões no frontend:
Verificar decorator do backend:
@require_permission corresponde exatamente à string de permissãoA função apiRequest em (src/lib/api.ts) trata automaticamente:
Erros de Autenticação (401)
/signinErros de Permissão (403)
/forbiddenErros do Backend
ApiError para tratamentotry {
await createProduct(data);
toast.success('Produto criado com sucesso!');
loadProducts(); // Atualizar lista
} catch (error) {
toast.error('Erro ao criar produto');
console.error('Create product error:', error);
}
const [loading, setLoading] = useState(false);
const loadData = async () => {
setLoading(true);
try {
const data = await getData();
setItems(data.items);
} catch (error) {
console.error('Load error:', error);
} finally {
setLoading(false);
}
};
✅ Bom:
interface Product {
id: number;
name: string;
price: number;
}
const product: Product = { id: 1, name: 'Test', price: 100 };
❌ Mau:
const product: any = { id: 1, name: 'Test' };
✅ Bom:
try {
await createProduct(data);
toast.success('Produto criado com sucesso!');
loadProducts(); // Atualizar lista
} catch (error) {
toast.error('Erro ao criar produto');
console.error('Create product error:', error);
}
❌ Mau:
createProduct(data); // Sem tratamento de erros
✅ Bom:
const [loading, setLoading] = useState(false);
const loadData = async () => {
setLoading(true);
try {
const data = await getData();
setItems(data.items);
} catch (error) {
console.error('Load error:', error);
} finally {
setLoading(false);
}
};
❌ Mau:
const loadData = async () => {
const data = await getData();
setItems(data.items); // Sem estado de carregamento
};
✅ Bom:
useEffect(() => {
loadData();
}, [filters, limit, offset]); // Recarregar nas mudanças de filtro
const handleApplyFilters = (newFilters: Filter) => {
setFilters(newFilters);
setOffset(0); // Resetar paginação
};
❌ Mau:
const handleApplyFilters = (newFilters: Filter) => {
setFilters(newFilters); // Não reseta paginação
};
✅ Bom:
<Button
permission="/v1/resource/create"
permissions={permissions}
loadingPermissions={loadingPermissions}
onClick={handleCreate}
>
Criar
</Button>
❌ Mau:
<button onClick={handleCreate}>Criar</button>
// Sem verificação de permissão
✅ Bom:
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadItems();
}, [filters]);
❌ Mau:
let items = []; // Não reativo
loadItems(); // Efeito secundário no render
✅ Bom:
// action.ts - Todas as chamadas API num só lugar
export async function getItems(filters: Filter): Promise<Response> {}
export async function createItem(data: Create): Promise<Item> {}
export async function updateItem(id: number, data: Update): Promise<Item> {}
export async function deleteItem(id: number): Promise<void> {}
❌ Mau:
// Misturado no arquivo do componente
const getItems = async () => {
/* Chamada API */
};
✅ Bom:
// Tipos
interface ProductItem {}
interface ProductCreate {}
interface ProductFilter {}
interface ProductResponse {}
// Actions
getProducts();
getProductById();
createProduct();
updateProduct();
deleteProduct();
❌ Mau:
interface Product {}
interface NewProduct {}
interface SearchParams {}
fetchProducts();
getById();
add();
edit();
remove();
'use client';
import { useState, useEffect } from 'react';
import { getItems, deleteItem } from './action';
import { ItemFilter, ItemResponse } from './types';
export default function ItemList() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<ItemFilter>({});
const [limit, setLimit] = useState(25);
const [offset, setOffset] = useState(0);
useEffect(() => {
loadItems();
}, [filters, limit, offset]);
const loadItems = async () => {
setLoading(true);
try {
const data = await getItems({ ...filters, limit, offset });
setItems(data.items);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return <div>{/* Componentes de UI */}</div>;
}
const [formData, setFormData] = useState<ItemCreate>(initialData);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
setSubmitting(true);
try {
if (editMode) {
await updateItem(itemId, formData);
toast.success('Atualizado com sucesso!');
} else {
await createItem(formData);
toast.success('Criado com sucesso!');
}
onClose();
loadItems();
} catch (error) {
toast.error('Erro ao guardar');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (item: Item) => {
if (!confirm(`Eliminar "${item.name}"?`)) return;
try {
await deleteItem(item.id);
toast.success('Eliminado com sucesso!');
loadItems();
} catch (error) {
toast.error('Erro ao eliminar');
}
};
401 Unauthorized
getAuthHeaders403 Forbidden
Dados não carregam
Paginação não funciona
offset reseta para 0 na mudança de filtrototalCount está corretolimit e offset são passados para APIFiltros não aplicam
useEffectPrecisa de ajuda? Contacte a equipa de desenvolvimento ou consulte os módulos existentes para implementações de referência.