chave-mfe-supplier 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -0
- package/.github/workflows/ci.yml +38 -0
- package/Dockerfile +13 -0
- package/README.md +94 -0
- package/index.html +12 -0
- package/jest.config.cjs +34 -0
- package/package.json +37 -0
- package/src/__tests__/SupplierDetailPage.test.tsx +75 -0
- package/src/__tests__/SupplierFormPage.test.tsx +53 -0
- package/src/__tests__/SupplierListPage.test.tsx +48 -0
- package/src/__tests__/client.test.tsx +22 -0
- package/src/__tests__/dialogs.test.tsx +42 -0
- package/src/__tests__/setup.ts +5 -0
- package/src/api/client.ts +63 -0
- package/src/api/createApi.ts +7 -0
- package/src/api/types.ts +94 -0
- package/src/components/LinkProductDialog.tsx +55 -0
- package/src/components/ReplenishmentDialog.tsx +88 -0
- package/src/main.tsx +9 -0
- package/src/pages/SupplierApp.tsx +50 -0
- package/src/pages/SupplierDetailPage.tsx +210 -0
- package/src/pages/SupplierFormPage.tsx +214 -0
- package/src/pages/SupplierListPage.tsx +162 -0
- package/src/theme.ts +14 -0
- package/src/vite-env.d.ts +9 -0
- package/tsconfig.json +27 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +26 -0
package/.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VITE_MS_SUPPLIER_URL=http://localhost:3002
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ['v*']
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-node@v4
|
|
15
|
+
with:
|
|
16
|
+
node-version: 20
|
|
17
|
+
cache: npm
|
|
18
|
+
- run: npm ci
|
|
19
|
+
- run: npm run lint
|
|
20
|
+
- run: npm test
|
|
21
|
+
- run: npm run build
|
|
22
|
+
|
|
23
|
+
release:
|
|
24
|
+
needs: test
|
|
25
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- uses: actions/setup-node@v4
|
|
30
|
+
with:
|
|
31
|
+
node-version: 20
|
|
32
|
+
cache: npm
|
|
33
|
+
registry-url: 'https://registry.npmjs.org'
|
|
34
|
+
- run: npm ci
|
|
35
|
+
- run: npm run build
|
|
36
|
+
- run: npm publish --access public
|
|
37
|
+
env:
|
|
38
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
FROM node:20-alpine AS build
|
|
2
|
+
WORKDIR /app
|
|
3
|
+
COPY package.json package-lock.json* ./
|
|
4
|
+
RUN npm ci
|
|
5
|
+
COPY . .
|
|
6
|
+
RUN npm run build
|
|
7
|
+
|
|
8
|
+
FROM node:20-alpine
|
|
9
|
+
WORKDIR /app
|
|
10
|
+
RUN npm install -g vite
|
|
11
|
+
COPY --from=build /app/dist ./dist
|
|
12
|
+
EXPOSE 4002
|
|
13
|
+
CMD ["vite", "preview", "--port", "4002", "--host"]
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# chave-mfe-supplier
|
|
2
|
+
|
|
3
|
+
Microfrontend de **Gestão de Fornecedores** do sistema Chave (loja plus size) —
|
|
4
|
+
Engenharia de Software II, PUCRS 2026-1, Turma 30, Grupo It Girls. Consome o
|
|
5
|
+
microsserviço `chave-ms-supplier` e é montado no Shell via Module Federation.
|
|
6
|
+
|
|
7
|
+
## Stack
|
|
8
|
+
|
|
9
|
+
- **React 18 + TypeScript + Vite 5**
|
|
10
|
+
- **MUI v9** (`@mui/material`, `@mui/icons-material`, `@emotion`) + **`@mui/x-data-grid` v9** (lista paginada)
|
|
11
|
+
- **`@originjs/vite-plugin-federation`** — remote `mfe_supplier`
|
|
12
|
+
- **Jest + Testing Library** (jsdom)
|
|
13
|
+
|
|
14
|
+
## Como rodar (standalone)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install
|
|
18
|
+
cp .env.example .env # VITE_MS_SUPPLIER_URL aponta para o MS (default http://localhost:3002)
|
|
19
|
+
npm run dev # http://localhost:4002
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
O token de autenticação é lido de `localStorage` (chave `token`) e enviado no header
|
|
23
|
+
`Authorization: Bearer <token>` — faça login pelo MFE de Auth / Shell para populá-lo.
|
|
24
|
+
|
|
25
|
+
Outros comandos:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm test # testes (Jest + Testing Library)
|
|
29
|
+
npm run test:coverage # com cobertura (thresholds 50% em jest.config.cjs)
|
|
30
|
+
npm run lint # tsc --noEmit (strict)
|
|
31
|
+
npm run build # vite build → dist/assets/remoteEntry.js
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Integração via Module Federation
|
|
35
|
+
|
|
36
|
+
O `vite.config.ts` expõe o remote `mfe_supplier` (`filename: remoteEntry.js`) com os módulos:
|
|
37
|
+
|
|
38
|
+
| Exposto | Componente |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `./SupplierApp` | App completo (mount único do shell, com roteamento interno por view-state) |
|
|
41
|
+
| `./SupplierListPage` | Lista de fornecedores |
|
|
42
|
+
| `./SupplierFormPage` | Formulário criar/editar |
|
|
43
|
+
| `./SupplierDetailPage` | Detalhe com abas |
|
|
44
|
+
|
|
45
|
+
O Shell consome via `lazy(() => import("mfe_supplier/SupplierApp"))` na rota `/suppliers`.
|
|
46
|
+
`react` e `react-dom` são compartilhados (`shared`). A URL do remote é
|
|
47
|
+
`http://localhost:4002/assets/remoteEntry.js` (sobrescrevível por `MFE_SUPPLIER_URL` no shell).
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Manual de UI
|
|
52
|
+
|
|
53
|
+
### 1. Lista de fornecedores (tela inicial)
|
|
54
|
+
|
|
55
|
+
- **DataGrid paginado** (`@mui/x-data-grid`) com paginação **server-side**: as páginas são
|
|
56
|
+
buscadas no MS conforme você navega (`page`, `pageSize`).
|
|
57
|
+
- **Filtros** no topo: campo **Buscar** (razão social / nome fantasia / documento),
|
|
58
|
+
**Status** (Todos / Ativo / Inativo), **Cidade** e **Estado**. Clique em **Buscar** para
|
|
59
|
+
aplicar — a lista volta à primeira página e re-consulta o MS.
|
|
60
|
+
- **Colunas**: Razão Social, Nome Fantasia, Documento, Status (chip colorido), Cidade e
|
|
61
|
+
**Ações** por linha:
|
|
62
|
+
- **Abrir** → tela de detalhe;
|
|
63
|
+
- **Editar** → formulário em modo edição;
|
|
64
|
+
- **Inativar** → soft delete (desabilitado se já inativo); a lista recarrega.
|
|
65
|
+
- Botão **Novo fornecedor** (canto superior direito) → formulário em modo criação.
|
|
66
|
+
- **Estados**: spinner de carregamento no grid; **Alert** vermelho em caso de erro.
|
|
67
|
+
|
|
68
|
+
### 2. Formulário (criar / editar)
|
|
69
|
+
|
|
70
|
+
- Campos do fornecedor (Razão Social, Nome Fantasia, **Documento CNPJ/CPF**, E-mail,
|
|
71
|
+
Telefone, Pessoa de contato) e bloco de **Endereço** (logradouro, número, complemento,
|
|
72
|
+
bairro, cidade, estado, CEP, país).
|
|
73
|
+
- **Validação client-side**: Razão Social e Documento obrigatórios, e-mail válido —
|
|
74
|
+
mensagens de erro aparecem como `helperText` abaixo do campo e o envio é bloqueado.
|
|
75
|
+
- Em **modo edição** os dados são carregados do MS e o **Documento fica bloqueado**
|
|
76
|
+
(imutável). O envio bruto vai ao MS, que normaliza/valida o documento.
|
|
77
|
+
- Botões **Salvar** (mostra "Salvando..." durante o request) e **Cancelar** (volta à lista).
|
|
78
|
+
- **Alert** de erro no topo se o MS rejeitar (ex.: `409` documento duplicado).
|
|
79
|
+
|
|
80
|
+
### 3. Detalhe do fornecedor
|
|
81
|
+
|
|
82
|
+
Carrega fornecedor, produtos e reposições em paralelo. Cabeçalho com razão social, chip de
|
|
83
|
+
status e botões **Voltar** / **Editar**. Três **abas**:
|
|
84
|
+
|
|
85
|
+
- **Dados** — nome fantasia, documento + tipo, e-mail, telefone, contato, cidade/UF.
|
|
86
|
+
- **Produtos vinculados** — tabela (produto, preço, lead time, SKU) + botão
|
|
87
|
+
**Vincular produto** que abre o **diálogo de vínculo** (ID do produto obrigatório, preço,
|
|
88
|
+
lead time e SKU opcionais). Cada linha tem **Desvincular**. A tabela recarrega após cada ação.
|
|
89
|
+
- **Reposições** — tabela (pedido, status, total, nº de itens, data) + botão
|
|
90
|
+
**Registrar reposição** que abre o **diálogo de reposição**: seleção de **Status** e uma
|
|
91
|
+
**lista dinâmica de itens** (ID do produto, quantidade, custo unitário) com **Adicionar
|
|
92
|
+
item** / remover linha. O MS calcula o total quando todos os itens têm custo.
|
|
93
|
+
|
|
94
|
+
Todas as telas tratam **loading** (spinner) e **erros** (Alert).
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="pt-BR">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Chave Fornecedores</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/jest.config.cjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
preset: 'ts-jest',
|
|
3
|
+
testEnvironment: 'jsdom',
|
|
4
|
+
roots: ['<rootDir>/src'],
|
|
5
|
+
testMatch: ['**/__tests__/**/*.tsx', '**/?(*.)+(spec|test).tsx'],
|
|
6
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
|
7
|
+
transform: {
|
|
8
|
+
'^.+\\.tsx?$': ['ts-jest', {
|
|
9
|
+
tsconfig: {
|
|
10
|
+
jsx: 'react-jsx',
|
|
11
|
+
esModuleInterop: true,
|
|
12
|
+
allowSyntheticDefaultImports: true,
|
|
13
|
+
},
|
|
14
|
+
}],
|
|
15
|
+
},
|
|
16
|
+
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
|
|
17
|
+
moduleNameMapper: {
|
|
18
|
+
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
|
19
|
+
},
|
|
20
|
+
collectCoverageFrom: [
|
|
21
|
+
'src/**/*.{ts,tsx}',
|
|
22
|
+
'!src/**/*.d.ts',
|
|
23
|
+
'!src/main.tsx',
|
|
24
|
+
'!src/vite-env.d.ts',
|
|
25
|
+
],
|
|
26
|
+
coverageThreshold: {
|
|
27
|
+
global: {
|
|
28
|
+
branches: 50,
|
|
29
|
+
functions: 50,
|
|
30
|
+
lines: 50,
|
|
31
|
+
statements: 50,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chave-mfe-supplier",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"dev": "vite --port 4002",
|
|
6
|
+
"build": "vite build",
|
|
7
|
+
"preview": "vite preview --port 4002",
|
|
8
|
+
"lint": "tsc --noEmit",
|
|
9
|
+
"test": "jest",
|
|
10
|
+
"test:coverage": "jest --coverage"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@emotion/react": "^11.14.0",
|
|
14
|
+
"@emotion/styled": "^11.14.1",
|
|
15
|
+
"@mui/icons-material": "^9.0.1",
|
|
16
|
+
"@mui/material": "^9.0.1",
|
|
17
|
+
"@mui/x-data-grid": "^9.0.0",
|
|
18
|
+
"react": "^18.2.0",
|
|
19
|
+
"react-dom": "^18.2.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@originjs/vite-plugin-federation": "^1.3.5",
|
|
23
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
24
|
+
"@testing-library/react": "^16.3.2",
|
|
25
|
+
"@testing-library/user-event": "^14.5.2",
|
|
26
|
+
"@types/jest": "^30.0.0",
|
|
27
|
+
"@types/react": "^18.2.0",
|
|
28
|
+
"@types/react-dom": "^18.2.0",
|
|
29
|
+
"@vitejs/plugin-react": "^4.2.1",
|
|
30
|
+
"identity-obj-proxy": "^3.0.0",
|
|
31
|
+
"jest": "^30.4.2",
|
|
32
|
+
"jest-environment-jsdom": "^30.4.1",
|
|
33
|
+
"ts-jest": "^29.4.9",
|
|
34
|
+
"typescript": "^5.3.0",
|
|
35
|
+
"vite": "^5.1.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import SupplierDetailPage from '../pages/SupplierDetailPage';
|
|
4
|
+
import { SupplierApi } from '../api/client';
|
|
5
|
+
|
|
6
|
+
const supplier = {
|
|
7
|
+
id: 'id1', legalName: 'Plus Fashion', tradeName: 'Plus', document: '11222333000181',
|
|
8
|
+
documentType: 'cnpj' as const, email: 'a@b.com', phone: null, contactPerson: null,
|
|
9
|
+
status: 'active' as const, address: { city: 'POA', state: 'RS' }, createdAt: '', updatedAt: '',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const product = {
|
|
13
|
+
id: 'sp1', supplierId: 'id1', productId: 'prod-1', supplyPrice: 10,
|
|
14
|
+
leadTimeDays: 5, supplierSku: 'SKU1', createdAt: '',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function makeApi(overrides: Partial<Record<string, jest.Mock>> = {}) {
|
|
18
|
+
return {
|
|
19
|
+
getSupplier: jest.fn().mockResolvedValue(supplier),
|
|
20
|
+
listProducts: jest.fn().mockResolvedValue([]),
|
|
21
|
+
listReplenishments: jest.fn().mockResolvedValue([]),
|
|
22
|
+
linkProduct: jest.fn().mockResolvedValue(product),
|
|
23
|
+
unlinkProduct: jest.fn().mockResolvedValue(undefined),
|
|
24
|
+
createReplenishment: jest.fn().mockResolvedValue({}),
|
|
25
|
+
...overrides,
|
|
26
|
+
} as unknown as SupplierApi;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
it('loads and shows supplier detail with tabs', async () => {
|
|
30
|
+
const api = makeApi();
|
|
31
|
+
render(<SupplierDetailPage api={api} supplierId="id1" onBack={() => {}} onEdit={() => {}} />);
|
|
32
|
+
await waitFor(() => expect(screen.getByText('Plus Fashion')).toBeInTheDocument());
|
|
33
|
+
expect(screen.getByRole('tab', { name: /produtos/i })).toBeInTheDocument();
|
|
34
|
+
expect(screen.getByRole('tab', { name: /reposiç/i })).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('shows error on load failure', async () => {
|
|
38
|
+
const api = makeApi({ getSupplier: jest.fn().mockRejectedValue(new Error('Boom')) });
|
|
39
|
+
render(<SupplierDetailPage api={api} supplierId="id1" onBack={() => {}} onEdit={() => {}} />);
|
|
40
|
+
await waitFor(() => expect(screen.getByText(/Boom/)).toBeInTheDocument());
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('links a product via dialog and reloads', async () => {
|
|
44
|
+
const api = makeApi();
|
|
45
|
+
render(<SupplierDetailPage api={api} supplierId="id1" onBack={() => {}} onEdit={() => {}} />);
|
|
46
|
+
await waitFor(() => screen.getByText('Plus Fashion'));
|
|
47
|
+
await userEvent.click(screen.getByRole('tab', { name: /produtos/i }));
|
|
48
|
+
await userEvent.click(screen.getByRole('button', { name: /vincular produto/i }));
|
|
49
|
+
const dialog = screen.getByRole('dialog');
|
|
50
|
+
await userEvent.type(within(dialog).getByLabelText(/ID do produto/i), 'prod-1');
|
|
51
|
+
await userEvent.click(within(dialog).getByRole('button', { name: /vincular/i }));
|
|
52
|
+
await waitFor(() => expect(api.linkProduct).toHaveBeenCalled());
|
|
53
|
+
expect(api.getSupplier).toHaveBeenCalledTimes(2); // initial + reload
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('unlinks a product', async () => {
|
|
57
|
+
const api = makeApi({ listProducts: jest.fn().mockResolvedValue([product]) });
|
|
58
|
+
render(<SupplierDetailPage api={api} supplierId="id1" onBack={() => {}} onEdit={() => {}} />);
|
|
59
|
+
await waitFor(() => screen.getByText('Plus Fashion'));
|
|
60
|
+
await userEvent.click(screen.getByRole('tab', { name: /produtos/i }));
|
|
61
|
+
await userEvent.click(screen.getByRole('button', { name: /desvincular/i }));
|
|
62
|
+
await waitFor(() => expect(api.unlinkProduct).toHaveBeenCalledWith('id1', 'prod-1'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('registers a replenishment via dialog', async () => {
|
|
66
|
+
const api = makeApi();
|
|
67
|
+
render(<SupplierDetailPage api={api} supplierId="id1" onBack={() => {}} onEdit={() => {}} />);
|
|
68
|
+
await waitFor(() => screen.getByText('Plus Fashion'));
|
|
69
|
+
await userEvent.click(screen.getByRole('tab', { name: /reposiç/i }));
|
|
70
|
+
await userEvent.click(screen.getByRole('button', { name: /registrar reposição/i }));
|
|
71
|
+
const dialog = screen.getByRole('dialog');
|
|
72
|
+
await userEvent.type(within(dialog).getAllByLabelText(/ID do produto/i)[0], 'prod-1');
|
|
73
|
+
await userEvent.click(within(dialog).getByRole('button', { name: /^registrar$/i }));
|
|
74
|
+
await waitFor(() => expect(api.createReplenishment).toHaveBeenCalled());
|
|
75
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import SupplierFormPage from '../pages/SupplierFormPage';
|
|
4
|
+
import { SupplierApi } from '../api/client';
|
|
5
|
+
|
|
6
|
+
const supplier = {
|
|
7
|
+
id: 'id1', legalName: 'Plus Fashion LTDA', tradeName: 'Plus', document: '11222333000181',
|
|
8
|
+
documentType: 'cnpj' as const, email: 'a@b.com', phone: null, contactPerson: null,
|
|
9
|
+
status: 'active' as const, address: { city: 'POA', state: 'RS' }, createdAt: '', updatedAt: '',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
it('validates required fields before submit', async () => {
|
|
13
|
+
const api = { createSupplier: jest.fn() } as unknown as SupplierApi;
|
|
14
|
+
render(<SupplierFormPage api={api} onDone={() => {}} onCancel={() => {}} />);
|
|
15
|
+
await userEvent.click(screen.getByRole('button', { name: /salvar/i }));
|
|
16
|
+
expect(api.createSupplier).not.toHaveBeenCalled();
|
|
17
|
+
// Validation error helperText is shown (unique message, not the field label/legend).
|
|
18
|
+
expect(screen.getByText(/obrigatória/i)).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('submits a valid new supplier', async () => {
|
|
22
|
+
const api = { createSupplier: jest.fn().mockResolvedValue({ id: 'id1' }) } as unknown as SupplierApi;
|
|
23
|
+
const onDone = jest.fn();
|
|
24
|
+
render(<SupplierFormPage api={api} onDone={onDone} onCancel={() => {}} />);
|
|
25
|
+
await userEvent.type(screen.getByLabelText(/razão social/i), 'Plus Fashion LTDA');
|
|
26
|
+
await userEvent.type(screen.getByLabelText(/documento/i), '11.222.333/0001-81');
|
|
27
|
+
await userEvent.type(screen.getByLabelText(/e-mail/i), 'contato@plus.com');
|
|
28
|
+
await userEvent.click(screen.getByRole('button', { name: /salvar/i }));
|
|
29
|
+
await waitFor(() => expect(api.createSupplier).toHaveBeenCalled());
|
|
30
|
+
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('loads an existing supplier in edit mode and updates it', async () => {
|
|
34
|
+
const api = {
|
|
35
|
+
getSupplier: jest.fn().mockResolvedValue(supplier),
|
|
36
|
+
updateSupplier: jest.fn().mockResolvedValue(supplier),
|
|
37
|
+
} as unknown as SupplierApi;
|
|
38
|
+
const onDone = jest.fn();
|
|
39
|
+
render(<SupplierFormPage api={api} supplierId="id1" onDone={onDone} onCancel={() => {}} />);
|
|
40
|
+
await waitFor(() => expect(screen.getByLabelText(/razão social/i)).toHaveValue('Plus Fashion LTDA'));
|
|
41
|
+
expect(api.getSupplier).toHaveBeenCalledWith('id1');
|
|
42
|
+
await userEvent.click(screen.getByRole('button', { name: /salvar/i }));
|
|
43
|
+
await waitFor(() => expect(api.updateSupplier).toHaveBeenCalled());
|
|
44
|
+
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('cancels via the cancel button', async () => {
|
|
48
|
+
const api = { createSupplier: jest.fn() } as unknown as SupplierApi;
|
|
49
|
+
const onCancel = jest.fn();
|
|
50
|
+
render(<SupplierFormPage api={api} onDone={() => {}} onCancel={onCancel} />);
|
|
51
|
+
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }));
|
|
52
|
+
expect(onCancel).toHaveBeenCalled();
|
|
53
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import SupplierListPage from '../pages/SupplierListPage';
|
|
4
|
+
import { SupplierApi } from '../api/client';
|
|
5
|
+
|
|
6
|
+
const supplier = {
|
|
7
|
+
id: 'id1', legalName: 'Plus Fashion', tradeName: 'Plus', document: '11222333000181',
|
|
8
|
+
documentType: 'cnpj' as const, email: 'a@b.com', phone: null, contactPerson: null,
|
|
9
|
+
status: 'active' as const, address: { city: 'POA', state: 'RS' }, createdAt: '', updatedAt: '',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const page = { data: [supplier], page: 1, pageSize: 20, total: 1 };
|
|
13
|
+
|
|
14
|
+
it('renders suppliers from the API', async () => {
|
|
15
|
+
const api = { listSuppliers: jest.fn().mockResolvedValue(page) } as unknown as SupplierApi;
|
|
16
|
+
render(<SupplierListPage api={api} onCreate={() => {}} onOpen={() => {}} onEdit={() => {}} />);
|
|
17
|
+
await waitFor(() => expect(screen.getByText('Plus Fashion')).toBeInTheDocument());
|
|
18
|
+
expect(api.listSuppliers).toHaveBeenCalled();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('shows error alert on failure', async () => {
|
|
22
|
+
const api = { listSuppliers: jest.fn().mockRejectedValue(new Error('Falha de rede')) } as unknown as SupplierApi;
|
|
23
|
+
render(<SupplierListPage api={api} onCreate={() => {}} onOpen={() => {}} onEdit={() => {}} />);
|
|
24
|
+
await waitFor(() => expect(screen.getByText(/Falha de rede/)).toBeInTheDocument());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('fires onCreate and re-queries on search', async () => {
|
|
28
|
+
const api = { listSuppliers: jest.fn().mockResolvedValue(page) } as unknown as SupplierApi;
|
|
29
|
+
const onCreate = jest.fn();
|
|
30
|
+
render(<SupplierListPage api={api} onCreate={onCreate} onOpen={() => {}} onEdit={() => {}} />);
|
|
31
|
+
await waitFor(() => screen.getByText('Plus Fashion'));
|
|
32
|
+
await userEvent.click(screen.getByRole('button', { name: /novo fornecedor/i }));
|
|
33
|
+
expect(onCreate).toHaveBeenCalled();
|
|
34
|
+
await userEvent.type(screen.getByLabelText(/buscar/i), 'plus');
|
|
35
|
+
await userEvent.click(screen.getByRole('button', { name: /^buscar$/i }));
|
|
36
|
+
await waitFor(() => expect((api.listSuppliers as jest.Mock).mock.calls.length).toBeGreaterThan(1));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('inactivates a supplier from the row action', async () => {
|
|
40
|
+
const api = {
|
|
41
|
+
listSuppliers: jest.fn().mockResolvedValue(page),
|
|
42
|
+
inactivateSupplier: jest.fn().mockResolvedValue(supplier),
|
|
43
|
+
} as unknown as SupplierApi;
|
|
44
|
+
render(<SupplierListPage api={api} onCreate={() => {}} onOpen={() => {}} onEdit={() => {}} />);
|
|
45
|
+
await waitFor(() => screen.getByText('Plus Fashion'));
|
|
46
|
+
await userEvent.click(screen.getByRole('button', { name: /inativar/i }));
|
|
47
|
+
await waitFor(() => expect(api.inactivateSupplier).toHaveBeenCalledWith('id1'));
|
|
48
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SupplierApi } from '../api/client';
|
|
2
|
+
|
|
3
|
+
describe('SupplierApi', () => {
|
|
4
|
+
const fetchMock = jest.fn();
|
|
5
|
+
beforeEach(() => { fetchMock.mockReset(); (global as { fetch: unknown }).fetch = fetchMock; localStorage.setItem('token', 'tkn'); });
|
|
6
|
+
|
|
7
|
+
it('sends Authorization header and parses list', async () => {
|
|
8
|
+
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: [], page: 1, pageSize: 20, total: 0 }) });
|
|
9
|
+
const api = new SupplierApi('http://api');
|
|
10
|
+
const res = await api.listSuppliers({ page: 1, pageSize: 20 });
|
|
11
|
+
expect(res.total).toBe(0);
|
|
12
|
+
const [, opts] = fetchMock.mock.calls[0];
|
|
13
|
+
expect(opts.headers.Authorization).toBe('Bearer tkn');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('throws with server error message on non-ok', async () => {
|
|
17
|
+
fetchMock.mockResolvedValue({ ok: false, status: 409, json: async () => ({ error: 'Documento duplicado' }) });
|
|
18
|
+
const api = new SupplierApi('http://api');
|
|
19
|
+
await expect(api.createSupplier({ legalName: 'x', document: '1', email: 'a@b.com' }))
|
|
20
|
+
.rejects.toThrow('Documento duplicado');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import LinkProductDialog from '../components/LinkProductDialog';
|
|
4
|
+
import ReplenishmentDialog from '../components/ReplenishmentDialog';
|
|
5
|
+
|
|
6
|
+
describe('LinkProductDialog', () => {
|
|
7
|
+
it('submits a typed payload', async () => {
|
|
8
|
+
const onSubmit = jest.fn();
|
|
9
|
+
render(<LinkProductDialog open onClose={() => {}} onSubmit={onSubmit} />);
|
|
10
|
+
await userEvent.type(screen.getByLabelText(/ID do produto/i), 'prod-1');
|
|
11
|
+
await userEvent.type(screen.getByLabelText(/Preço de fornecimento/i), '49.9');
|
|
12
|
+
await userEvent.type(screen.getByLabelText(/Lead time/i), '7');
|
|
13
|
+
await userEvent.click(screen.getByRole('button', { name: /vincular/i }));
|
|
14
|
+
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
15
|
+
productId: 'prod-1', supplyPrice: 49.9, leadTimeDays: 7, supplierSku: null,
|
|
16
|
+
}));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('does not submit without productId', async () => {
|
|
20
|
+
const onSubmit = jest.fn();
|
|
21
|
+
render(<LinkProductDialog open onClose={() => {}} onSubmit={onSubmit} />);
|
|
22
|
+
expect(screen.getByRole('button', { name: /vincular/i })).toBeDisabled();
|
|
23
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('ReplenishmentDialog', () => {
|
|
28
|
+
it('adds/removes item rows and submits', async () => {
|
|
29
|
+
const onSubmit = jest.fn();
|
|
30
|
+
render(<ReplenishmentDialog open onClose={() => {}} onSubmit={onSubmit} />);
|
|
31
|
+
await userEvent.type(screen.getAllByLabelText(/ID do produto/i)[0], 'p1');
|
|
32
|
+
await userEvent.click(screen.getByRole('button', { name: /adicionar item/i }));
|
|
33
|
+
expect(screen.getAllByLabelText(/ID do produto/i)).toHaveLength(2);
|
|
34
|
+
await userEvent.click(screen.getAllByLabelText(/remover item/i)[1]);
|
|
35
|
+
expect(screen.getAllByLabelText(/ID do produto/i)).toHaveLength(1);
|
|
36
|
+
await userEvent.click(screen.getByRole('button', { name: /registrar/i }));
|
|
37
|
+
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
|
38
|
+
status: 'requested',
|
|
39
|
+
items: [expect.objectContaining({ productId: 'p1', quantity: 1, unitCost: null })],
|
|
40
|
+
}));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateReplenishmentPayload, CreateSupplierPayload, LinkProductPayload, Paginated,
|
|
3
|
+
ReplenishmentOrder, Supplier, SupplierProduct,
|
|
4
|
+
} from './types';
|
|
5
|
+
|
|
6
|
+
export interface ListParams {
|
|
7
|
+
page: number; pageSize: number;
|
|
8
|
+
status?: string; city?: string; state?: string; productId?: string; q?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SupplierApi {
|
|
12
|
+
constructor(private baseUrl: string) {}
|
|
13
|
+
|
|
14
|
+
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
15
|
+
const token = localStorage.getItem('token');
|
|
16
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
17
|
+
...init,
|
|
18
|
+
headers: {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
21
|
+
...(init?.headers ?? {}),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
if (res.status === 204) return undefined as T;
|
|
25
|
+
const body = await res.json().catch(() => ({}));
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const message = (body as { error?: string }).error ?? `Erro ${res.status}`;
|
|
28
|
+
throw new Error(message);
|
|
29
|
+
}
|
|
30
|
+
return body as T;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
listSuppliers(params: ListParams): Promise<Paginated<Supplier>> {
|
|
34
|
+
const qs = new URLSearchParams();
|
|
35
|
+
Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== '') qs.append(k, String(v)); });
|
|
36
|
+
return this.request<Paginated<Supplier>>(`/suppliers?${qs.toString()}`);
|
|
37
|
+
}
|
|
38
|
+
getSupplier(id: string): Promise<Supplier> { return this.request(`/suppliers/${id}`); }
|
|
39
|
+
createSupplier(payload: CreateSupplierPayload): Promise<Supplier> {
|
|
40
|
+
return this.request('/suppliers', { method: 'POST', body: JSON.stringify(payload) });
|
|
41
|
+
}
|
|
42
|
+
updateSupplier(id: string, payload: Partial<CreateSupplierPayload> & { status?: string }): Promise<Supplier> {
|
|
43
|
+
return this.request(`/suppliers/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
|
44
|
+
}
|
|
45
|
+
inactivateSupplier(id: string): Promise<Supplier> {
|
|
46
|
+
return this.request(`/suppliers/${id}`, { method: 'DELETE' });
|
|
47
|
+
}
|
|
48
|
+
listProducts(id: string): Promise<SupplierProduct[]> { return this.request(`/suppliers/${id}/products`); }
|
|
49
|
+
linkProduct(id: string, payload: LinkProductPayload): Promise<SupplierProduct> {
|
|
50
|
+
return this.request(`/suppliers/${id}/products`, { method: 'POST', body: JSON.stringify(payload) });
|
|
51
|
+
}
|
|
52
|
+
unlinkProduct(id: string, productId: string): Promise<void> {
|
|
53
|
+
return this.request(`/suppliers/${id}/products/${productId}`, { method: 'DELETE' });
|
|
54
|
+
}
|
|
55
|
+
listReplenishments(id: string, params?: { status?: string; from?: string; to?: string }): Promise<ReplenishmentOrder[]> {
|
|
56
|
+
const qs = new URLSearchParams();
|
|
57
|
+
if (params) Object.entries(params).forEach(([k, v]) => { if (v) qs.append(k, v); });
|
|
58
|
+
return this.request(`/suppliers/${id}/replenishments?${qs.toString()}`);
|
|
59
|
+
}
|
|
60
|
+
createReplenishment(id: string, payload: CreateReplenishmentPayload): Promise<ReplenishmentOrder> {
|
|
61
|
+
return this.request(`/suppliers/${id}/replenishments`, { method: 'POST', body: JSON.stringify(payload) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { SupplierApi } from "./client";
|
|
2
|
+
|
|
3
|
+
// Kept in its own module: `import.meta.env` is a Vite-only construct and cannot be
|
|
4
|
+
// parsed inside a CommonJS module (ts-jest), so it must not live in client.ts, which
|
|
5
|
+
// the unit tests load directly.
|
|
6
|
+
export const createApi = (): SupplierApi =>
|
|
7
|
+
new SupplierApi(import.meta.env.VITE_MS_SUPPLIER_URL || "http://localhost:3002");
|
package/src/api/types.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export type SupplierStatus = "active" | "inactive";
|
|
2
|
+
export type DocumentType = "cnpj" | "cpf";
|
|
3
|
+
export type ReplenishmentStatus = "requested" | "sent" | "received" | "cancelled";
|
|
4
|
+
|
|
5
|
+
export interface Address {
|
|
6
|
+
street?: string | null;
|
|
7
|
+
number?: string | null;
|
|
8
|
+
complement?: string | null;
|
|
9
|
+
district?: string | null;
|
|
10
|
+
city?: string | null;
|
|
11
|
+
state?: string | null;
|
|
12
|
+
zipCode?: string | null;
|
|
13
|
+
country?: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Supplier {
|
|
17
|
+
id: string;
|
|
18
|
+
legalName: string;
|
|
19
|
+
tradeName: string | null;
|
|
20
|
+
document: string;
|
|
21
|
+
documentType: DocumentType;
|
|
22
|
+
email: string;
|
|
23
|
+
phone: string | null;
|
|
24
|
+
contactPerson: string | null;
|
|
25
|
+
status: SupplierStatus;
|
|
26
|
+
address: Address;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SupplierProduct {
|
|
32
|
+
id: string;
|
|
33
|
+
supplierId: string;
|
|
34
|
+
productId: string;
|
|
35
|
+
supplyPrice: number | null;
|
|
36
|
+
leadTimeDays: number | null;
|
|
37
|
+
supplierSku: string | null;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReplenishmentItem {
|
|
42
|
+
id: string;
|
|
43
|
+
orderId: string;
|
|
44
|
+
productId: string;
|
|
45
|
+
quantity: number;
|
|
46
|
+
unitCost: number | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ReplenishmentOrder {
|
|
50
|
+
id: string;
|
|
51
|
+
supplierId: string;
|
|
52
|
+
status: ReplenishmentStatus;
|
|
53
|
+
totalCost: number | null;
|
|
54
|
+
orderedAt: string;
|
|
55
|
+
items: ReplenishmentItem[];
|
|
56
|
+
createdAt: string;
|
|
57
|
+
updatedAt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Paginated<T> {
|
|
61
|
+
data: T[];
|
|
62
|
+
page: number;
|
|
63
|
+
pageSize: number;
|
|
64
|
+
total: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CreateSupplierPayload {
|
|
68
|
+
legalName: string;
|
|
69
|
+
tradeName?: string | null;
|
|
70
|
+
document: string;
|
|
71
|
+
email: string;
|
|
72
|
+
phone?: string | null;
|
|
73
|
+
contactPerson?: string | null;
|
|
74
|
+
address?: Address;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface LinkProductPayload {
|
|
78
|
+
productId: string;
|
|
79
|
+
supplyPrice?: number | null;
|
|
80
|
+
leadTimeDays?: number | null;
|
|
81
|
+
supplierSku?: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface CreateReplenishmentItemPayload {
|
|
85
|
+
productId: string;
|
|
86
|
+
quantity: number;
|
|
87
|
+
unitCost?: number | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface CreateReplenishmentPayload {
|
|
91
|
+
status?: ReplenishmentStatus;
|
|
92
|
+
orderedAt?: string;
|
|
93
|
+
items: CreateReplenishmentItemPayload[];
|
|
94
|
+
}
|