feat: implement 2-step product registration with new catalog fields

Backend:
- Add ean_code, manufacturer, category, subcategory, observations to Product model
- Create migration 0006_product_catalog_fields.sql
- Update repository queries (CreateProduct, ListProducts, ListRecords)

Frontend:
- Create ProductCreate.tsx with 2-step wizard form
- Add route /products/new
- Add 'Cadastrar Produto' button to Inventory page

Seeder:
- Update CREATE TABLE products with new columns
- Update generateProducts with EAN codes and manufacturers

Docs:
- Update database-schema.md with new fields
This commit is contained in:
Tiago Yamamoto 2025-12-23 17:09:38 -03:00
parent 091e8093c0
commit 607d942072
8 changed files with 464 additions and 28 deletions

View file

@ -70,16 +70,21 @@ type UserPage struct {
// Product represents a medicine SKU with batch tracking.
type Product struct {
ID uuid.UUID `db:"id" json:"id"`
SellerID uuid.UUID `db:"seller_id" json:"seller_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Batch string `db:"batch" json:"batch"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
PriceCents int64 `db:"price_cents" json:"price_cents"`
Stock int64 `db:"stock" json:"stock"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
SellerID uuid.UUID `db:"seller_id" json:"seller_id"`
EANCode string `db:"ean_code" json:"ean_code"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Manufacturer string `db:"manufacturer" json:"manufacturer"`
Category string `db:"category" json:"category"`
Subcategory string `db:"subcategory" json:"subcategory"`
Batch string `db:"batch" json:"batch"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
PriceCents int64 `db:"price_cents" json:"price_cents"`
Stock int64 `db:"stock" json:"stock"`
Observations string `db:"observations" json:"observations"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// InventoryItem exposes stock tracking tied to product batches.

View file

@ -0,0 +1,14 @@
-- Migration: Add product catalog fields
-- +goose Up
ALTER TABLE products ADD COLUMN IF NOT EXISTS ean_code TEXT NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS manufacturer TEXT NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS subcategory TEXT NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS observations TEXT NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE products DROP COLUMN IF EXISTS ean_code;
ALTER TABLE products DROP COLUMN IF EXISTS manufacturer;
ALTER TABLE products DROP COLUMN IF EXISTS category;
ALTER TABLE products DROP COLUMN IF EXISTS subcategory;
ALTER TABLE products DROP COLUMN IF EXISTS observations;

View file

@ -159,8 +159,8 @@ func (r *Repository) DeleteCompany(ctx context.Context, id uuid.UUID) error {
}
func (r *Repository) CreateProduct(ctx context.Context, product *domain.Product) error {
query := `INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock)
query := `INSERT INTO products (id, seller_id, ean_code, name, description, manufacturer, category, subcategory, batch, expires_at, price_cents, stock, observations)
VALUES (:id, :seller_id, :ean_code, :name, :description, :manufacturer, :category, :subcategory, :batch, :expires_at, :price_cents, :stock, :observations)
RETURNING created_at, updated_at`
rows, err := r.db.NamedQueryContext(ctx, query, product)
@ -205,7 +205,7 @@ func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilt
filter.Limit = 20
}
args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf("SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
listQuery := fmt.Sprintf("SELECT id, seller_id, ean_code, name, description, manufacturer, category, subcategory, batch, expires_at, price_cents, stock, observations, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
var products []domain.Product
if err := r.db.SelectContext(ctx, &products, listQuery, args...); err != nil {
@ -256,7 +256,7 @@ func (r *Repository) ListRecords(ctx context.Context, filter domain.RecordSearch
}
args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf(`SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at,
listQuery := fmt.Sprintf(`SELECT id, seller_id, ean_code, name, description, manufacturer, category, subcategory, batch, expires_at, price_cents, stock, observations, created_at, updated_at,
COUNT(*) OVER() AS total_count
%s%s ORDER BY %s %s LIMIT $%d OFFSET $%d`, baseQuery, where, sortBy, sortOrder, len(args)-1, len(args))

View file

@ -178,12 +178,17 @@ Produtos cadastrados para venda.
|--------|------|-----------|
| `id` | UUID | Chave primária |
| `seller_id` | UUID | FK para companies (vendedor) |
| `ean_code` | TEXT | Código EAN/barras |
| `name` | TEXT | Nome do produto |
| `description` | TEXT | Descrição |
| `manufacturer` | TEXT | Laboratório/Fabricante |
| `category` | TEXT | Categoria (Analgésicos, Antibióticos, etc) |
| `subcategory` | TEXT | Subcategoria (opcional) |
| `batch` | TEXT | Lote |
| `expires_at` | DATE | Data de validade |
| `price_cents` | BIGINT | Preço em centavos |
| `stock` | BIGINT | Quantidade em estoque |
| `observations` | TEXT | Observações adicionais |
| `created_at` | TIMESTAMPTZ | Data de criação |
| `updated_at` | TIMESTAMPTZ | Data de atualização |

View file

@ -3,6 +3,7 @@ import { LoginPage } from './pages/Login'
import { CartPage } from './pages/Cart'
import { OrdersPage as UserOrdersPage } from './pages/Orders'
import { InventoryPage } from './pages/Inventory'
import { ProductCreatePage } from './pages/ProductCreate'
import { CompanyPage } from './pages/Company'
import { SellerDashboardPage } from './pages/SellerDashboard'
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
@ -103,6 +104,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/products/new"
element={
<ProtectedRoute allowedRoles={['owner', 'seller']}>
<ProductCreatePage />
</ProtectedRoute>
}
/>
<Route
path="/company"
element={

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Shell } from '../layouts/Shell'
import { apiClient } from '../services/apiClient'
import { formatCents } from '../utils/format'
@ -63,7 +64,7 @@ export function InventoryPage() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-medicalBlue">Estoque</h1>
<h1 className="text-xl font-semibold text-medicalBlue">Meus Produtos</h1>
<p className="text-sm text-gray-600">Controle de inventário e validades</p>
</div>
<div className="flex items-center gap-3">
@ -79,10 +80,16 @@ export function InventoryPage() {
</select>
<button
onClick={loadInventory}
className="rounded bg-medicalBlue px-4 py-2 text-sm font-semibold text-white"
className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
>
Atualizar
</button>
<Link
to="/products/new"
className="rounded bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700"
>
+ Cadastrar Produto
</Link>
</div>
</div>

View file

@ -0,0 +1,381 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Shell } from '../layouts/Shell'
import { apiClient } from '../services/apiClient'
interface ProductForm {
// Step 1 - Catalog
ean_code: string
name: string
description: string
manufacturer: string
category: string
subcategory: string
// Step 2 - Stock/Sales
stock: string
price: string
expires_at: string
observations: string
}
const categories = [
'Analgésicos',
'Antibióticos',
'Anti-inflamatórios',
'Cardiovasculares',
'Dermatológicos',
'Vitaminas',
'Oftálmicos',
'Respiratórios',
'Gastrointestinais',
'Antidiabéticos',
'Perfumaria',
'Higiene',
'Outros'
]
export function ProductCreatePage() {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState<ProductForm>({
ean_code: '',
name: '',
description: '',
manufacturer: '',
category: '',
subcategory: '',
stock: '',
price: '',
expires_at: '',
observations: ''
})
const updateField = (field: keyof ProductForm, value: string) => {
setForm(prev => ({ ...prev, [field]: value }))
}
const handleClear = () => {
if (step === 1) {
setForm(prev => ({
...prev,
ean_code: '',
name: '',
description: '',
manufacturer: '',
category: '',
subcategory: ''
}))
} else {
setForm(prev => ({
...prev,
stock: '',
price: '',
expires_at: '',
observations: ''
}))
}
}
const handleNext = () => {
// Validate step 1
if (!form.ean_code || !form.name || !form.manufacturer || !form.category) {
setError('Preencha todos os campos obrigatórios')
return
}
setError(null)
setStep(2)
}
const handleBack = () => {
setStep(1)
setError(null)
}
const handleSubmit = async () => {
// Validate step 2
if (!form.stock || !form.price || !form.expires_at) {
setError('Preencha todos os campos obrigatórios')
return
}
setLoading(true)
setError(null)
try {
const priceCents = Math.round(parseFloat(form.price.replace(',', '.')) * 100)
await apiClient.post('/v1/products', {
ean_code: form.ean_code,
name: form.name,
description: form.description,
manufacturer: form.manufacturer,
category: form.category,
subcategory: form.subcategory,
batch: `L${new Date().getFullYear()}${String(Math.floor(Math.random() * 999)).padStart(3, '0')}`,
expires_at: form.expires_at,
price_cents: priceCents,
stock: parseInt(form.stock),
observations: form.observations
})
navigate('/inventory')
} catch (err) {
console.error(err)
setError('Erro ao cadastrar produto')
} finally {
setLoading(false)
}
}
return (
<Shell>
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
<h1 className="text-2xl font-semibold text-gray-800">Novo cadastro de produto</h1>
<p className="text-gray-500 mt-1">
O fluxo é dividido em duas etapas: catálogo, venda/estoque. Informe os dados obrigatórios em cada aba para concluir o processo.
</p>
</div>
{/* Form Card */}
<div className="bg-white rounded-lg shadow-sm p-6">
{/* Steps Indicator */}
<div className="flex justify-center mb-8">
<div className="flex items-center gap-16">
{/* Step 1 */}
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold ${step >= 1 ? 'bg-blue-600' : 'bg-gray-300'
}`}>
1
</div>
<p className={`mt-2 text-sm font-medium ${step >= 1 ? 'text-gray-800' : 'text-gray-400'}`}>
Etapa 1
</p>
<p className="text-xs text-gray-500">Dados do catálogo</p>
</div>
{/* Progress Line */}
<div className={`w-32 h-1 ${step === 2 ? 'bg-blue-600' : 'bg-gray-200'}`} />
{/* Step 2 */}
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold ${step === 2 ? 'bg-blue-600' : 'bg-gray-300'
}`}>
2
</div>
<p className={`mt-2 text-sm font-medium ${step === 2 ? 'text-gray-800' : 'text-gray-400'}`}>
Etapa 2
</p>
<p className="text-xs text-gray-500">Estoque e Venda</p>
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
{error}
</div>
)}
{/* Step 1 Form */}
{step === 1 && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Código EAN/Barras <span className="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Digite o código EAN"
value={form.ean_code}
onChange={(e) => updateField('ean_code', e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nome <span className="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Digite o nome do produto (mín. 3 caracteres para sugestões)"
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Descrição
</label>
<textarea
placeholder="Detalhes do produto"
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
rows={3}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Laboratório/Fabricante <span className="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Digite o nome do laboratório"
value={form.manufacturer}
onChange={(e) => updateField('manufacturer', e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Categoria <span className="text-red-500">*</span>
</label>
<select
value={form.category}
onChange={(e) => updateField('category', e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Selecione uma categoria</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Subcategoria
</label>
<input
type="text"
placeholder="Subcategoria (opcional)"
value={form.subcategory}
onChange={(e) => updateField('subcategory', e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Step 1 Actions */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 rounded-lg text-gray-700 font-medium hover:bg-gray-50"
>
Limpar
</button>
<button
type="button"
onClick={handleNext}
className="px-6 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
>
Avançar
</button>
</div>
</div>
)}
{/* Step 2 Form */}
{step === 2 && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Quantidade em estoque <span className="text-red-500">*</span>
</label>
<input
type="number"
placeholder="Digite a quantidade disponível"
value={form.stock}
onChange={(e) => updateField('stock', e.target.value)}
min="0"
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Preço de venda (R$) <span className="text-red-500">*</span>
</label>
<input
type="text"
placeholder="0.00"
value={form.price}
onChange={(e) => updateField('price', e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Data de validade <span className="text-red-500">*</span>
</label>
<input
type="date"
value={form.expires_at}
onChange={(e) => updateField('expires_at', e.target.value)}
className="w-full max-w-xs px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Observações
</label>
<textarea
placeholder="Informações adicionais sobre o produto"
value={form.observations}
onChange={(e) => updateField('observations', e.target.value)}
rows={4}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Step 2 Actions */}
<div className="flex justify-between pt-4">
<button
type="button"
onClick={handleBack}
className="px-6 py-2.5 border border-gray-300 rounded-lg text-gray-700 font-medium hover:bg-gray-50"
>
Voltar
</button>
<div className="flex gap-3">
<button
type="button"
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 rounded-lg text-gray-700 font-medium hover:bg-gray-50"
>
Reiniciar
</button>
<button
type="button"
onClick={handleSubmit}
disabled={loading}
className="px-6 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50"
>
{loading ? 'Salvando...' : 'Concluir cadastro'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
</Shell>
)
}

View file

@ -135,12 +135,17 @@ func SeedLean(dsn string) (string, error) {
mustExec(db, `CREATE TABLE products (
id UUID PRIMARY KEY,
seller_id UUID NOT NULL REFERENCES companies(id),
ean_code TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
description TEXT,
manufacturer TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
batch TEXT NOT NULL,
expires_at DATE NOT NULL,
price_cents BIGINT NOT NULL,
stock BIGINT NOT NULL,
observations TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
@ -300,8 +305,8 @@ func SeedLean(dsn string) (string, error) {
prods := generateProducts(rng, companyID, numProds)
for _, p := range prods {
_, err := db.NamedExecContext(ctx, `
INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
INSERT INTO products (id, seller_id, ean_code, name, description, manufacturer, category, subcategory, batch, expires_at, price_cents, stock, observations, created_at, updated_at)
VALUES (:id, :seller_id, :ean_code, :name, :description, :manufacturer, :category, :subcategory, :batch, :expires_at, :price_cents, :stock, :observations, :created_at, :updated_at)
ON CONFLICT DO NOTHING`, p)
if err != nil {
log.Printf("insert product lean: %v", err)
@ -480,12 +485,17 @@ func SeedFull(dsn string) (string, error) {
mustExec(db, `CREATE TABLE products (
id UUID PRIMARY KEY,
seller_id UUID NOT NULL REFERENCES companies(id),
ean_code TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
description TEXT,
manufacturer TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
batch TEXT NOT NULL,
expires_at DATE NOT NULL,
price_cents BIGINT NOT NULL,
stock BIGINT NOT NULL,
observations TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
@ -606,16 +616,21 @@ func generateProducts(rng *rand.Rand, sellerID uuid.UUID, count int) []map[strin
price := int64(float64(med.BasePrice) * priceMultiplier)
products = append(products, map[string]interface{}{
"id": id,
"seller_id": sellerID,
"name": med.Name,
"description": fmt.Sprintf("%s - %s", med.Name, med.Category),
"batch": fmt.Sprintf("L%d%03d", now.Year(), rng.Intn(999)),
"expires_at": expiresAt,
"price_cents": price,
"stock": int64(10 + rng.Intn(500)),
"created_at": now,
"updated_at": now,
"id": id,
"seller_id": sellerID,
"ean_code": fmt.Sprintf("789%010d", rng.Int63n(9999999999)),
"name": med.Name,
"description": fmt.Sprintf("%s - %s", med.Name, med.Category),
"manufacturer": []string{"EMS", "Medley", "Neo Química", "Eurofarma", "Aché", "Sanofi"}[rng.Intn(6)],
"category": med.Category,
"subcategory": "",
"batch": fmt.Sprintf("L%d%03d", now.Year(), rng.Intn(999)),
"expires_at": expiresAt,
"price_cents": price,
"stock": int64(10 + rng.Intn(500)),
"observations": "",
"created_at": now,
"updated_at": now,
})
}
return products