diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 08895ca..05bd327 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -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. diff --git a/backend/internal/repository/postgres/migrations/0006_product_catalog_fields.sql b/backend/internal/repository/postgres/migrations/0006_product_catalog_fields.sql new file mode 100644 index 0000000..addebc2 --- /dev/null +++ b/backend/internal/repository/postgres/migrations/0006_product_catalog_fields.sql @@ -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; diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 9d3f927..d8d4b5b 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -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)) diff --git a/docs/database-schema.md b/docs/database-schema.md index ebcf09d..06b4fd0 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -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 | diff --git a/marketplace/src/App.tsx b/marketplace/src/App.tsx index 4461d0a..8c76e89 100644 --- a/marketplace/src/App.tsx +++ b/marketplace/src/App.tsx @@ -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() { } /> + + + + } + />
-

Estoque

+

Meus Produtos

Controle de inventário e validades

@@ -79,10 +80,16 @@ export function InventoryPage() { + + + Cadastrar Produto +
diff --git a/marketplace/src/pages/ProductCreate.tsx b/marketplace/src/pages/ProductCreate.tsx new file mode 100644 index 0000000..77114e5 --- /dev/null +++ b/marketplace/src/pages/ProductCreate.tsx @@ -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(null) + + const [form, setForm] = useState({ + 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 ( + +
+ {/* Header */} +
+

Novo cadastro de produto

+

+ O fluxo é dividido em duas etapas: catálogo, venda/estoque. Informe os dados obrigatórios em cada aba para concluir o processo. +

+
+ + {/* Form Card */} +
+ {/* Steps Indicator */} +
+
+ {/* Step 1 */} +
+
= 1 ? 'bg-blue-600' : 'bg-gray-300' + }`}> + 1 +
+

= 1 ? 'text-gray-800' : 'text-gray-400'}`}> + Etapa 1 +

+

Dados do catálogo

+
+ + {/* Progress Line */} +
+ + {/* Step 2 */} +
+
+ 2 +
+

+ Etapa 2 +

+

Estoque e Venda

+
+
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Step 1 Form */} + {step === 1 && ( +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+
+ +