Merge branch 'main' into dev
This commit is contained in:
commit
3ca9f50d0c
6 changed files with 99 additions and 218 deletions
110
README.md
110
README.md
|
|
@ -1,104 +1,26 @@
|
||||||
# SaveInMed — Monorepo
|
# SaveInMed
|
||||||
|
|
||||||
> Marketplace B2B para o setor farmacêutico brasileiro.
|
Monorepo do SaveInMed.
|
||||||
> Conecta distribuidoras, farmácias e operadores logísticos em uma plataforma única.
|
|
||||||
|
|
||||||
---
|
## Estrutura
|
||||||
|
|
||||||
## Estrutura do repositório
|
- `backend/`: API principal em Go
|
||||||
|
- `frontend/`: marketplace web
|
||||||
|
- `backoffice/`: API administrativa
|
||||||
|
- `website/`: site institucional
|
||||||
|
- `docs/`: documentacao tecnica
|
||||||
|
|
||||||
```
|
## Desenvolvimento
|
||||||
saveinmed/
|
|
||||||
├── backend/ # API principal (Go · Clean Architecture)
|
|
||||||
├── frontend/ # Marketplace web (React + Vite + Tailwind)
|
|
||||||
├── backoffice/ # API administrativa (NestJS · Prisma)
|
|
||||||
├── seeder-api/ # Seeder de catálogo de produtos
|
|
||||||
├── website/ # Site institucional
|
|
||||||
└── docs/ # Documentação técnica centralizada
|
|
||||||
```
|
|
||||||
|
|
||||||
### `backend/`
|
- `main`: producao
|
||||||
API REST em Go seguindo Clean Architecture:
|
- `hml`: homologacao
|
||||||
- **`internal/domain/`** — modelos, regras e contratos de domínio
|
- `dev`: desenvolvimento
|
||||||
- **`internal/usecase/`** — casos de uso por domínio (`company_usecase.go`, `order_usecase.go`, …)
|
|
||||||
- **`internal/http/handler/`** — handlers HTTP (um arquivo por recurso)
|
|
||||||
- **`internal/repository/postgres/`** — implementações de repositório + migrações SQL
|
|
||||||
- **`internal/infrastructure/`** — integrações externas: `mapbox/`, `payments/`, `notifications/`
|
|
||||||
- **`cmd/api/`** — entrypoint da API REST
|
|
||||||
- **`cmd/seeder/`** — seed de dados
|
|
||||||
|
|
||||||
### `frontend/`
|
## Backend
|
||||||
Marketplace React com Vite. Páginas organizadas por perfil de usuário:
|
|
||||||
- **`src/pages/auth/`** — Login, registro, recuperação de senha
|
|
||||||
- **`src/pages/marketplace/`** — busca, carrinho, checkout, pedidos
|
|
||||||
- **`src/pages/dashboard/admin/`** — painel admin (consome a API do `backoffice`)
|
|
||||||
- **`src/pages/dashboard/seller/`** — painel do vendedor/distribuidora
|
|
||||||
- **`src/pages/dashboard/employee/`** — painel do colaborador
|
|
||||||
- **`src/pages/dashboard/delivery/`** — painel do entregador
|
|
||||||
- **`src/components/`** — componentes reutilizáveis
|
|
||||||
- **`src/stores/`** — Zustand (carrinho, filtros, UI global)
|
|
||||||
- **`src/context/`** — Context API (auth + tema)
|
|
||||||
- **`src/services/`** — clientes HTTP por domínio
|
|
||||||
|
|
||||||
### `backoffice/`
|
Para subir a API localmente:
|
||||||
API NestJS para operações administrativas internas: KYC, auditoria, disputas, relatórios, detecção de fraude.
|
|
||||||
Conecta ao mesmo banco de dados do `backend` via Prisma.
|
|
||||||
O painel React em `frontend/src/pages/dashboard/admin/` é o **frontend desta API**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gerenciamento de estado (frontend)
|
|
||||||
|
|
||||||
| Estado | Onde | Justificativa |
|
|
||||||
|--------|------|---------------|
|
|
||||||
| Autenticação (token JWT, user) | Context API (`AuthContext`) | Semântica de provider; mudanças raras |
|
|
||||||
| Tema claro/escuro | Context API (`ThemeContext`) | Idem |
|
|
||||||
| Carrinho de compras | Zustand (`cartStore`) | Atualizações frequentes; sem re-render do provider raiz |
|
|
||||||
| Filtros de busca persistentes | Zustand / `usePersistentFilters` | Sobrevive a navegações |
|
|
||||||
| Estado de UI local (modais, loaders) | `useState` local | Não precisa ser compartilhado |
|
|
||||||
|
|
||||||
> **Regra:** Use Context apenas para auth/tema. Para estado de domínio ou UI global, use Zustand.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comandos úteis
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend Go (porta 8214 por padrão)
|
cd backend
|
||||||
cd backend && go run ./cmd/api
|
go run ./cmd/api
|
||||||
|
|
||||||
# Frontend marketplace
|
|
||||||
cd frontend && pnpm dev
|
|
||||||
|
|
||||||
# Backoffice API
|
|
||||||
cd backoffice && pnpm start:dev
|
|
||||||
|
|
||||||
# Aplicar migrações Go
|
|
||||||
cd backend && go run ./cmd/apply_migration
|
|
||||||
|
|
||||||
# Seed manual
|
|
||||||
cd backend && go run ./cmd/seeder
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Swagger disponível em `http://localhost:8214/swagger/index.html`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Convenções
|
|
||||||
|
|
||||||
- **Commits semânticos:** `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
|
|
||||||
- **Branches:** `main` = produção · `develop` = staging · `feature/*` = funcionalidades
|
|
||||||
- **PRs:** obrigatório para `main` com ao menos 1 revisor
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentação técnica
|
|
||||||
|
|
||||||
| Arquivo | Conteúdo |
|
|
||||||
|---------|----------|
|
|
||||||
| [docs/architecture.md](docs/architecture.md) | Visão geral da arquitetura e decisões de design |
|
|
||||||
| [docs/BACKEND.md](docs/BACKEND.md) | Backend Go — detalhes, handlers, domínios |
|
|
||||||
| [docs/BACKOFFICE.md](docs/BACKOFFICE.md) | NestJS backoffice — módulos e responsabilidades |
|
|
||||||
| [docs/DATABASE.md](docs/DATABASE.md) | Modelo de dados e migrações |
|
|
||||||
| [docs/ROADMAP.md](docs/ROADMAP.md) | Roadmap de produto |
|
|
||||||
| [STATUS_REPORT_SAVEINMED.md](STATUS_REPORT_SAVEINMED.md) | Status detalhado de cada funcionalidade |
|
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,5 @@ SWAGGER_SCHEMES=http,https
|
||||||
# Testing (Optional)
|
# Testing (Optional)
|
||||||
# SKIP_DB_TEST=1
|
# SKIP_DB_TEST=1
|
||||||
|
|
||||||
# API Configuration
|
# API Configuration (production - commented for dev)
|
||||||
BACKEND_HOST=https://api.saveinmed.com.br
|
# BACKEND_HOST=https://api.saveinmed.com.br
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,8 @@ func New(cfg config.Config) (*Server, error) {
|
||||||
mux.Handle("GET /api/v1/marketplace/records", chain(http.HandlerFunc(h.ListMarketplaceRecords), middleware.Logger, middleware.Gzip))
|
mux.Handle("GET /api/v1/marketplace/records", chain(http.HandlerFunc(h.ListMarketplaceRecords), middleware.Logger, middleware.Gzip))
|
||||||
mux.Handle("GET /api/v1/laboratorios", chain(http.HandlerFunc(h.ListManufacturers), middleware.Logger, middleware.Gzip))
|
mux.Handle("GET /api/v1/laboratorios", chain(http.HandlerFunc(h.ListManufacturers), middleware.Logger, middleware.Gzip))
|
||||||
mux.Handle("GET /api/v1/categorias", chain(http.HandlerFunc(h.ListCategories), middleware.Logger, middleware.Gzip))
|
mux.Handle("GET /api/v1/categorias", chain(http.HandlerFunc(h.ListCategories), middleware.Logger, middleware.Gzip))
|
||||||
mux.Handle("GET /api/v1/produtos-catalogo", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip)) // Alias
|
mux.Handle("GET /api/v1/produtos-catalogo", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip)) // Alias
|
||||||
|
mux.Handle("POST /api/v1/produtos-catalogo", chain(http.HandlerFunc(h.CreateProduct), middleware.Logger, middleware.Gzip, productManagers)) // Alias for frontend
|
||||||
mux.Handle("GET /api/v1/produtos-catalogo/codigo-ean/{ean}", chain(http.HandlerFunc(h.GetProductByEAN), middleware.Logger, middleware.Gzip))
|
mux.Handle("GET /api/v1/produtos-catalogo/codigo-ean/{ean}", chain(http.HandlerFunc(h.GetProductByEAN), middleware.Logger, middleware.Gzip))
|
||||||
|
|
||||||
mux.Handle("GET /api/v1/inventory", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth))
|
mux.Handle("GET /api/v1/inventory", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth))
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ services:
|
||||||
POSTGRES_PASSWORD: "123"
|
POSTGRES_PASSWORD: "123"
|
||||||
POSTGRES_DB: saveinmed
|
POSTGRES_DB: saveinmed
|
||||||
ports:
|
ports:
|
||||||
# Porta 55432 no host → 5432 no container (alinhado com backend/.env)
|
|
||||||
- "55432:5432"
|
- "55432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- saveinmed_pgdata:/var/lib/postgresql/data
|
- saveinmed_pgdata:/var/lib/postgresql/data
|
||||||
|
|
|
||||||
|
|
@ -679,16 +679,17 @@ const CadastroProdutoWizard: React.FC = () => {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
|
||||||
// A API pode retornar { items: [...] } ou direto um array
|
// A API retorna { products: [...], total, page, page_size }
|
||||||
let produtos = data.items || data.data || data || [];
|
let produtos = data.products || data.items || data.data || data || [];
|
||||||
|
|
||||||
|
|
||||||
// Se for array, filtrar localmente por nome
|
// Se for array, filtrar localmente por nome
|
||||||
if (Array.isArray(produtos)) {
|
if (Array.isArray(produtos)) {
|
||||||
const nomeMinusculo = nome.toLowerCase();
|
const nomeMinusculo = nome.toLowerCase();
|
||||||
const produtosFiltrados = produtos.filter(produto =>
|
const produtosFiltrados = produtos.filter((produto: any) => {
|
||||||
produto.nome && produto.nome.toLowerCase().includes(nomeMinusculo)
|
const prodNome = produto.nome || produto.name || "";
|
||||||
);
|
return prodNome.toLowerCase().includes(nomeMinusculo);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
if (produtosFiltrados.length > 0) {
|
if (produtosFiltrados.length > 0) {
|
||||||
|
|
@ -812,55 +813,22 @@ const CadastroProdutoWizard: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCriandoLaboratorio(true);
|
// Laboratórios no backend são apenas strings na coluna manufacturer
|
||||||
|
// Não é necessário criar via POST - basta usar o texto digitado
|
||||||
|
const novoLab = {
|
||||||
|
id: laboratorioNome.trim(),
|
||||||
|
nome: laboratorioNome.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
// Adicionar à lista local de laboratórios para exibição
|
||||||
const token = localStorage.getItem('access_token');
|
setLaboratorios((prev) => [...prev, novoLab]);
|
||||||
if (!token) {
|
|
||||||
toast.error("Token de acesso não encontrado");
|
|
||||||
setCriandoLaboratorio(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Selecionar o novo laboratório
|
||||||
|
setStepOne((prev) => ({ ...prev, laboratorio: novoLab.id }));
|
||||||
|
setShowLaboratorioSuggestions(false);
|
||||||
|
setLaboratoriosSugeridos([]);
|
||||||
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BFF_API_URL}/laboratorios`, {
|
toast.success("Laboratório adicionado!");
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
nome: laboratorioNome.trim()
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
toast.error(errorData.message || "Erro ao criar laboratório");
|
|
||||||
setCriandoLaboratorio(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const novoLab = await response.json();
|
|
||||||
|
|
||||||
// Adicionar à lista de laboratórios
|
|
||||||
setLaboratorios((prev) => [...prev, novoLab]);
|
|
||||||
|
|
||||||
// Selecionar o novo laboratório
|
|
||||||
const labId = novoLab.$id || novoLab.id;
|
|
||||||
setStepOne((prev) => ({ ...prev, laboratorio: labId }));
|
|
||||||
setShowLaboratorioSuggestions(false);
|
|
||||||
setLaboratoriosSugeridos([]);
|
|
||||||
|
|
||||||
toast.success("Laboratório criado com sucesso!");
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Erro ao criar laboratório:", error);
|
|
||||||
toast.error("Erro ao criar laboratório");
|
|
||||||
} finally {
|
|
||||||
setCriandoLaboratorio(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitStepOne = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmitStepOne = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
|
@ -887,79 +855,66 @@ const CadastroProdutoWizard: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primeiro, verificar se precisamos criar um novo laboratório
|
// Resolver nome do laboratório (manufacturer) - labs são strings, não entidades separadas
|
||||||
let laboratorioId = stepOne.laboratorio;
|
|
||||||
let labNome = "";
|
let labNome = "";
|
||||||
|
if (laboratorioNome.trim()) {
|
||||||
if (!laboratorioId && laboratorioNome.trim()) {
|
// Usuário digitou um nome de laboratório (novo ou existente)
|
||||||
// Criar novo laboratório
|
labNome = laboratorioNome.trim();
|
||||||
|
} else if (stepOne.laboratorio) {
|
||||||
try {
|
// Laboratório selecionado da lista
|
||||||
const labResponse = await fetch(`${process.env.NEXT_PUBLIC_BFF_API_URL}/laboratorios`, {
|
const labSelecionado = laboratorios.find(lab => (lab.$id || lab.id || lab.nome) === stepOne.laboratorio);
|
||||||
method: 'POST',
|
labNome = labSelecionado?.nome || labSelecionado?.name || stepOne.laboratorio;
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
nome: laboratorioNome.trim()
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!labResponse.ok) {
|
|
||||||
const errorData = await labResponse.json().catch(() => ({}));
|
|
||||||
toast.error(errorData.message || "Erro ao criar laboratório");
|
|
||||||
setSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const novoLab = await labResponse.json();
|
|
||||||
|
|
||||||
// Adicionar à lista de laboratórios
|
|
||||||
setLaboratorios((prev) => [...prev, novoLab]);
|
|
||||||
|
|
||||||
laboratorioId = novoLab.$id || novoLab.id;
|
|
||||||
labNome = novoLab.nome || novoLab.name || laboratorioNome.trim();
|
|
||||||
|
|
||||||
// Atualizar estado do stepOne
|
|
||||||
setStepOne((prev) => ({ ...prev, laboratorio: laboratorioId }));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Erro ao criar laboratório:", error);
|
|
||||||
toast.error("Erro ao criar laboratório");
|
|
||||||
setSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Buscar nome do laboratório existente
|
|
||||||
const labSelecionado = laboratorios.find(lab => (lab.$id || lab.id) === laboratorioId);
|
|
||||||
labNome = labSelecionado?.nome || labSelecionado?.name || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar os nomes de categoria
|
// Resolver nome da categoria
|
||||||
const catSelecionada = categorias.find(cat => (cat.$id || cat.id) === stepOne.categoria);
|
let catNome = "";
|
||||||
const catNome = catSelecionada?.nome || catSelecionada?.name || '';
|
if (stepOne.categoria) {
|
||||||
|
const catSelecionada = categorias.find(cat => (cat.$id || cat.id || cat.nome) === stepOne.categoria);
|
||||||
|
catNome = catSelecionada?.nome || catSelecionada?.name || stepOne.categoria;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
// Buscar empresa_id do usuário logado
|
||||||
documentId: "unique()",
|
let sellerIdStr = await getCurrentUserEmpresaId();
|
||||||
data: {
|
if (!sellerIdStr) {
|
||||||
codigo_ean: stepOne.codigo_ean.trim(),
|
sellerIdStr = empresaId || localStorage.getItem('empresaId');
|
||||||
codigo_interno: stepOne.codigo_interno.trim() || stepOne.codigo_ean.trim(),
|
if (!sellerIdStr) {
|
||||||
nome: stepOne.nome.trim(),
|
const userStr = localStorage.getItem('user');
|
||||||
descricao: stepOne.descricao.trim() || "Descrição do produto",
|
if (userStr) {
|
||||||
// Usar valores padrão para campos ocultos
|
const user = JSON.parse(userStr);
|
||||||
preco_base: Number(stepOne.preco_base) || 0,
|
sellerIdStr = user.company_id || user.empresa_id;
|
||||||
preco_fabrica: Number(stepOne.preco_fabrica) || 0,
|
}
|
||||||
pmc: Number(stepOne.pmc) || 0,
|
|
||||||
desconto_comercial: Number(stepOne.desconto_comercial) || 10,
|
|
||||||
valor_substituicao_tributaria: Number(stepOne.valor_substituicao_tributaria) || 5.00,
|
|
||||||
preco_nf: Number(stepOne.preco_nf) || 0,
|
|
||||||
lab_nome: labNome,
|
|
||||||
cat_nome: catNome,
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sellerIdStr) {
|
||||||
|
toast.error("Erro ao obter dados da empresa. Faça login novamente.");
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converter valores monetários para centavos (Backend Go espera int64 cents)
|
||||||
|
const toCents = (val: string | number): number => {
|
||||||
|
const num = typeof val === 'string' ? parseFloat(val) : val;
|
||||||
|
return Number.isFinite(num) ? Math.round(num * 100) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Payload no formato registerProductRequest do Backend Go
|
||||||
|
const payload = {
|
||||||
|
seller_id: sellerIdStr,
|
||||||
|
ean_code: stepOne.codigo_ean.trim(),
|
||||||
|
name: stepOne.nome.trim(),
|
||||||
|
description: stepOne.descricao.trim() || "Descrição do produto",
|
||||||
|
manufacturer: labNome,
|
||||||
|
category: catNome,
|
||||||
|
subcategory: stepOne.subcategoria.trim() || "",
|
||||||
|
price_cents: toCents(stepOne.preco_base),
|
||||||
|
internal_code: stepOne.codigo_interno.trim() || stepOne.codigo_ean.trim(),
|
||||||
|
factory_price_cents: toCents(stepOne.preco_fabrica),
|
||||||
|
pmc_cents: toCents(stepOne.pmc),
|
||||||
|
commercial_discount_cents: toCents(stepOne.desconto_comercial),
|
||||||
|
tax_substitution_cents: toCents(stepOne.valor_substituicao_tributaria),
|
||||||
|
invoice_price_cents: toCents(stepOne.preco_nf),
|
||||||
|
};
|
||||||
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BFF_API_URL}/produtos-catalogo`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_BFF_API_URL}/produtos-catalogo`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -975,13 +930,13 @@ const CadastroProdutoWizard: React.FC = () => {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
toast.error(errorData.message || "Erro ao criar produto no catálogo");
|
toast.error(errorData.error || errorData.message || "Erro ao criar produto no catálogo");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
const catalogoId = data.documentId || data.$id || data.id;
|
const catalogoId = data.id || data.documentId || data.$id;
|
||||||
|
|
||||||
if (catalogoId) {
|
if (catalogoId) {
|
||||||
setReferenciaCatalogoId(catalogoId);
|
setReferenciaCatalogoId(catalogoId);
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,11 @@ export function extractEmpresaId(userData: UserMeResponse | null): string | null
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: verificar company_id direto (Backend Go retorna isso)
|
||||||
|
const anyData = userData as any;
|
||||||
|
if (anyData.company_id && typeof anyData.company_id === 'string') {
|
||||||
|
return anyData.company_id;
|
||||||
|
}
|
||||||
|
|
||||||
// Verifica se há empresas associadas
|
// Verifica se há empresas associadas
|
||||||
if (!userData.empresasDados) {
|
if (!userData.empresasDados) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue