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:
parent
091e8093c0
commit
607d942072
8 changed files with 464 additions and 28 deletions
|
|
@ -70,16 +70,21 @@ type UserPage struct {
|
||||||
|
|
||||||
// Product represents a medicine SKU with batch tracking.
|
// Product represents a medicine SKU with batch tracking.
|
||||||
type Product struct {
|
type Product struct {
|
||||||
ID uuid.UUID `db:"id" json:"id"`
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
SellerID uuid.UUID `db:"seller_id" json:"seller_id"`
|
SellerID uuid.UUID `db:"seller_id" json:"seller_id"`
|
||||||
Name string `db:"name" json:"name"`
|
EANCode string `db:"ean_code" json:"ean_code"`
|
||||||
Description string `db:"description" json:"description"`
|
Name string `db:"name" json:"name"`
|
||||||
Batch string `db:"batch" json:"batch"`
|
Description string `db:"description" json:"description"`
|
||||||
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
Manufacturer string `db:"manufacturer" json:"manufacturer"`
|
||||||
PriceCents int64 `db:"price_cents" json:"price_cents"`
|
Category string `db:"category" json:"category"`
|
||||||
Stock int64 `db:"stock" json:"stock"`
|
Subcategory string `db:"subcategory" json:"subcategory"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
Batch string `db:"batch" json:"batch"`
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
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.
|
// InventoryItem exposes stock tracking tied to product batches.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 {
|
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)
|
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, :name, :description, :batch, :expires_at, :price_cents, :stock)
|
VALUES (:id, :seller_id, :ean_code, :name, :description, :manufacturer, :category, :subcategory, :batch, :expires_at, :price_cents, :stock, :observations)
|
||||||
RETURNING created_at, updated_at`
|
RETURNING created_at, updated_at`
|
||||||
|
|
||||||
rows, err := r.db.NamedQueryContext(ctx, query, product)
|
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
|
filter.Limit = 20
|
||||||
}
|
}
|
||||||
args = append(args, filter.Limit, filter.Offset)
|
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
|
var products []domain.Product
|
||||||
if err := r.db.SelectContext(ctx, &products, listQuery, args...); err != nil {
|
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)
|
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
|
COUNT(*) OVER() AS total_count
|
||||||
%s%s ORDER BY %s %s LIMIT $%d OFFSET $%d`, baseQuery, where, sortBy, sortOrder, len(args)-1, len(args))
|
%s%s ORDER BY %s %s LIMIT $%d OFFSET $%d`, baseQuery, where, sortBy, sortOrder, len(args)-1, len(args))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,12 +178,17 @@ Produtos cadastrados para venda.
|
||||||
|--------|------|-----------|
|
|--------|------|-----------|
|
||||||
| `id` | UUID | Chave primária |
|
| `id` | UUID | Chave primária |
|
||||||
| `seller_id` | UUID | FK para companies (vendedor) |
|
| `seller_id` | UUID | FK para companies (vendedor) |
|
||||||
|
| `ean_code` | TEXT | Código EAN/barras |
|
||||||
| `name` | TEXT | Nome do produto |
|
| `name` | TEXT | Nome do produto |
|
||||||
| `description` | TEXT | Descrição |
|
| `description` | TEXT | Descrição |
|
||||||
|
| `manufacturer` | TEXT | Laboratório/Fabricante |
|
||||||
|
| `category` | TEXT | Categoria (Analgésicos, Antibióticos, etc) |
|
||||||
|
| `subcategory` | TEXT | Subcategoria (opcional) |
|
||||||
| `batch` | TEXT | Lote |
|
| `batch` | TEXT | Lote |
|
||||||
| `expires_at` | DATE | Data de validade |
|
| `expires_at` | DATE | Data de validade |
|
||||||
| `price_cents` | BIGINT | Preço em centavos |
|
| `price_cents` | BIGINT | Preço em centavos |
|
||||||
| `stock` | BIGINT | Quantidade em estoque |
|
| `stock` | BIGINT | Quantidade em estoque |
|
||||||
|
| `observations` | TEXT | Observações adicionais |
|
||||||
| `created_at` | TIMESTAMPTZ | Data de criação |
|
| `created_at` | TIMESTAMPTZ | Data de criação |
|
||||||
| `updated_at` | TIMESTAMPTZ | Data de atualização |
|
| `updated_at` | TIMESTAMPTZ | Data de atualização |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { LoginPage } from './pages/Login'
|
||||||
import { CartPage } from './pages/Cart'
|
import { CartPage } from './pages/Cart'
|
||||||
import { OrdersPage as UserOrdersPage } from './pages/Orders'
|
import { OrdersPage as UserOrdersPage } from './pages/Orders'
|
||||||
import { InventoryPage } from './pages/Inventory'
|
import { InventoryPage } from './pages/Inventory'
|
||||||
|
import { ProductCreatePage } from './pages/ProductCreate'
|
||||||
import { CompanyPage } from './pages/Company'
|
import { CompanyPage } from './pages/Company'
|
||||||
import { SellerDashboardPage } from './pages/SellerDashboard'
|
import { SellerDashboardPage } from './pages/SellerDashboard'
|
||||||
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
||||||
|
|
@ -103,6 +104,14 @@ function App() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/products/new"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
||||||
|
<ProductCreatePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/company"
|
path="/company"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { Shell } from '../layouts/Shell'
|
import { Shell } from '../layouts/Shell'
|
||||||
import { apiClient } from '../services/apiClient'
|
import { apiClient } from '../services/apiClient'
|
||||||
import { formatCents } from '../utils/format'
|
import { formatCents } from '../utils/format'
|
||||||
|
|
@ -63,7 +64,7 @@ export function InventoryPage() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p className="text-sm text-gray-600">Controle de inventário e validades</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -79,10 +80,16 @@ export function InventoryPage() {
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={loadInventory}
|
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
|
Atualizar
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
381
marketplace/src/pages/ProductCreate.tsx
Normal file
381
marketplace/src/pages/ProductCreate.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -135,12 +135,17 @@ func SeedLean(dsn string) (string, error) {
|
||||||
mustExec(db, `CREATE TABLE products (
|
mustExec(db, `CREATE TABLE products (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
seller_id UUID NOT NULL REFERENCES companies(id),
|
seller_id UUID NOT NULL REFERENCES companies(id),
|
||||||
|
ean_code TEXT NOT NULL DEFAULT '',
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
manufacturer TEXT NOT NULL DEFAULT '',
|
||||||
|
category TEXT NOT NULL DEFAULT '',
|
||||||
|
subcategory TEXT NOT NULL DEFAULT '',
|
||||||
batch TEXT NOT NULL,
|
batch TEXT NOT NULL,
|
||||||
expires_at DATE NOT NULL,
|
expires_at DATE NOT NULL,
|
||||||
price_cents BIGINT NOT NULL,
|
price_cents BIGINT NOT NULL,
|
||||||
stock BIGINT NOT NULL,
|
stock BIGINT NOT NULL,
|
||||||
|
observations TEXT NOT NULL DEFAULT '',
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
)`)
|
)`)
|
||||||
|
|
@ -300,8 +305,8 @@ func SeedLean(dsn string) (string, error) {
|
||||||
prods := generateProducts(rng, companyID, numProds)
|
prods := generateProducts(rng, companyID, numProds)
|
||||||
for _, p := range prods {
|
for _, p := range prods {
|
||||||
_, err := db.NamedExecContext(ctx, `
|
_, err := db.NamedExecContext(ctx, `
|
||||||
INSERT INTO products (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, :name, :description, :batch, :expires_at, :price_cents, :stock, :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)
|
ON CONFLICT DO NOTHING`, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("insert product lean: %v", err)
|
log.Printf("insert product lean: %v", err)
|
||||||
|
|
@ -480,12 +485,17 @@ func SeedFull(dsn string) (string, error) {
|
||||||
mustExec(db, `CREATE TABLE products (
|
mustExec(db, `CREATE TABLE products (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
seller_id UUID NOT NULL REFERENCES companies(id),
|
seller_id UUID NOT NULL REFERENCES companies(id),
|
||||||
|
ean_code TEXT NOT NULL DEFAULT '',
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
manufacturer TEXT NOT NULL DEFAULT '',
|
||||||
|
category TEXT NOT NULL DEFAULT '',
|
||||||
|
subcategory TEXT NOT NULL DEFAULT '',
|
||||||
batch TEXT NOT NULL,
|
batch TEXT NOT NULL,
|
||||||
expires_at DATE NOT NULL,
|
expires_at DATE NOT NULL,
|
||||||
price_cents BIGINT NOT NULL,
|
price_cents BIGINT NOT NULL,
|
||||||
stock BIGINT NOT NULL,
|
stock BIGINT NOT NULL,
|
||||||
|
observations TEXT NOT NULL DEFAULT '',
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
updated_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)
|
price := int64(float64(med.BasePrice) * priceMultiplier)
|
||||||
|
|
||||||
products = append(products, map[string]interface{}{
|
products = append(products, map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"seller_id": sellerID,
|
"seller_id": sellerID,
|
||||||
"name": med.Name,
|
"ean_code": fmt.Sprintf("789%010d", rng.Int63n(9999999999)),
|
||||||
"description": fmt.Sprintf("%s - %s", med.Name, med.Category),
|
"name": med.Name,
|
||||||
"batch": fmt.Sprintf("L%d%03d", now.Year(), rng.Intn(999)),
|
"description": fmt.Sprintf("%s - %s", med.Name, med.Category),
|
||||||
"expires_at": expiresAt,
|
"manufacturer": []string{"EMS", "Medley", "Neo Química", "Eurofarma", "Aché", "Sanofi"}[rng.Intn(6)],
|
||||||
"price_cents": price,
|
"category": med.Category,
|
||||||
"stock": int64(10 + rng.Intn(500)),
|
"subcategory": "",
|
||||||
"created_at": now,
|
"batch": fmt.Sprintf("L%d%03d", now.Year(), rng.Intn(999)),
|
||||||
"updated_at": now,
|
"expires_at": expiresAt,
|
||||||
|
"price_cents": price,
|
||||||
|
"stock": int64(10 + rng.Intn(500)),
|
||||||
|
"observations": "",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return products
|
return products
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue