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 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>
@@ -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,5 @@
1
+ import '@testing-library/jest-dom';
2
+ import { TextEncoder, TextDecoder } from 'util';
3
+
4
+ // jsdom (jest) does not expose TextEncoder/TextDecoder, which @mui/x-data-grid requires.
5
+ Object.assign(globalThis, { TextEncoder, TextDecoder });
@@ -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");
@@ -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
+ }