From 443c920453a31915c7bc0a6a41dfb6a49ff4e629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 19 Aug 2025 16:40:27 -0300 Subject: [PATCH 01/11] feat: update product model and repository to support new category and unit fields; enhance product creation validation in frontend --- .../zato-csm-backend/config/init_database.py | 5 +- backend/zato-csm-backend/models/product.py | 77 +++++++----- .../repositories/product_repositories.py | 72 +++++++----- backend/zato-csm-backend/routes/products.py | 4 - .../services/product_service.py | 111 ++++++------------ frontend/src/components/NewProductPage.tsx | 98 ++++++++++------ 6 files changed, 190 insertions(+), 177 deletions(-) diff --git a/backend/zato-csm-backend/config/init_database.py b/backend/zato-csm-backend/config/init_database.py index d0606725..92b00312 100644 --- a/backend/zato-csm-backend/config/init_database.py +++ b/backend/zato-csm-backend/config/init_database.py @@ -28,7 +28,7 @@ def create_tables_sql(): price DECIMAL(10,2) NOT NULL, stock INT NOT NULL, min_stock INT DEFAULT 0, - category_id INT, + category VARCHAR(255) NOT NULL, images JSONB, status VARCHAR(20) DEFAULT 'active', weight DECIMAL(10,2), @@ -39,8 +39,7 @@ def create_tables_sql(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_updated TIMESTAMP DEFAULT NOW(), localization TEXT, - FOREIGN KEY (creator_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT, - FOREIGN KEY (category_id) REFERENCES categories(id) ON UPDATE CASCADE ON DELETE RESTRICT + FOREIGN KEY (creator_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT ); CREATE TABLE IF NOT EXISTS inventory_movements( diff --git a/backend/zato-csm-backend/models/product.py b/backend/zato-csm-backend/models/product.py index c77224f8..f295d346 100644 --- a/backend/zato-csm-backend/models/product.py +++ b/backend/zato-csm-backend/models/product.py @@ -3,62 +3,86 @@ from datetime import datetime from enum import Enum + class ProductType(str, Enum): PHYSICAL_PRODUCT = "Physical Product" SERVICE = "Service" DIGITAL = "Digital" + class ProductStatus(str, Enum): ACTIVE = "active" INACTIVE = "inactive" + class ProductUnity(str, Enum): PER_ITEM = "Per item" PER_KILOGRAM = "Per kilogram" PER_LITER = "Per liter" + PER_METRO = "Per metro" + + +class ProductCategory(str, Enum): + FURNITURE = "Furniture" + TEXTILES = "Textiles" + LIGHTING = "Lighting" + ELECTRONICS = "Electronics" + DECORATION = "Decoration" + OFFICE = "Office" + GAMING = "Gaming" + class CreateProductRequest(BaseModel): - # Default fields - name: str = Field(..., min_length=1, description="Product name") - price: float = Field(..., gt=0, description="Product price") - stock: int = Field(..., gt=0, description="Product stock") + name: str = Field(..., min_length=1) + price: float = Field(..., gt=0) + stock: int = Field(..., ge=0) unit: ProductUnity = Field(...) product_type: ProductType = Field(...) + category: str = Field(...) - # Optional fields - description: Optional[str] = Field(None, description="Product description") - category_id: Optional[str] = Field(None, description="Product category", gt=0) - sku: Optional[str] = Field(None, max_length=255, description="Product SKU") - weight: Optional[float] = Field(None, gt=0, description="Product weight") - localization: Optional[str] = Field(None, description="Product localization") - min_stock: Optional[int] = Field(..., gt=0, description="Product minimum stock") + description: Optional[str] = None + sku: Optional[str] = Field(None, max_length=255) + weight: Optional[float] = Field(None, gt=0) + localization: Optional[str] = None + min_stock: int = Field(0, ge=0) status: ProductStatus = Field(ProductStatus.ACTIVE) - @validator("name") - def validate_name(cls, v): - if not v: - raise ValueError("Name is mandatory") - return v - @validator("sku") - def validate_price(cls, v): - if v and len(v.strip()) == 0: + def _normalize_sku(cls, v): + if v is not None and v.strip() == "": return None return v + @validator("category") + def _validate_category(cls, v): + valid = {c.value for c in ProductCategory} + if v not in valid: + raise ValueError(f"Invalid category. Allowed: {', '.join(sorted(valid))}") + return v + + class UpdateProductRequest(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) - description: Optional[str] = Field(None) + description: Optional[str] = None price: Optional[float] = Field(None, gt=0) stock: Optional[int] = Field(None, ge=0) - category_id: Optional[str] = Field(None, gt=0) + category: Optional[str] = None sku: Optional[str] = Field(None, max_length=255) weight: Optional[float] = Field(None, ge=0) - localization: Optional[str] = Field(None) + localization: Optional[str] = None min_stock: Optional[int] = Field(None, ge=0) - status: Optional[ProductStatus] = Field(None) - product_type: Optional[ProductType] = Field(None) - unit: Optional[ProductUnity] = Field(None) + status: Optional[ProductStatus] = None + product_type: Optional[ProductType] = None + unit: Optional[ProductUnity] = None + + @validator("category") + def _validate_category_update(cls, v): + if v is None: + return v + valid = {c.value for c in ProductCategory} + if v not in valid: + raise ValueError(f"Invalid category. Allowed: {', '.join(sorted(valid))}") + return v class ProductResponse(BaseModel): @@ -68,7 +92,7 @@ class ProductResponse(BaseModel): price: float stock: int min_stock: int - category_id: Optional[int] + category: str images: Optional[List[str]] = [] status: str weight: Optional[float] @@ -82,4 +106,3 @@ class ProductResponse(BaseModel): class Config: from_attributes = True - diff --git a/backend/zato-csm-backend/repositories/product_repositories.py b/backend/zato-csm-backend/repositories/product_repositories.py index e0ad5ee2..72ae0d98 100644 --- a/backend/zato-csm-backend/repositories/product_repositories.py +++ b/backend/zato-csm-backend/repositories/product_repositories.py @@ -5,46 +5,56 @@ from utils.timezone_utils import get_current_time_with_timezone + class ProductRepository(BaseRepository): def create_product( self, name: str, + description: str | None, price: float, stock: int, unit: str, product_type: str, + category: str, + sku: str | None, + min_stock: int, + status: str, + weight: float | None, + localization: str | None, creator_id: int, - description: str = None, - category_id: int = None, - images: list = None, - min_stock: int=0, - sku: str=None, - status: str="active", - weight: float=0.0, - localization: str=None, - user_timezone: str = "UTC", ): - last_updated = get_current_time_with_timezone(user_timezone) - created_at = get_current_time_with_timezone(user_timezone) - - images_json = json.dumps(images) if images else json.dumps([]) - - with self._get_cursor() as cursor: - cursor.execute( - "INSERT INTO products (" - "name, description, price, stock, category_id, images, " - "min_stock, status, weight, sku, creator_id, unit," - "product_type, created_at, last_updated, localization)" - "VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *", - ( - name, description, price, stock, category_id, images_json, - min_stock, status, weight, sku, creator_id, unit, product_type, - created_at, last_updated, localization - ), - ) - self.db.commit() - return cursor.fetchone() + query = """ + INSERT INTO products + (name, description, price, stock, min_stock, category, images, status, weight, sku, creator_id, unit, product_type, localization) + VALUES + (%s, %s, %s, %s, %s, %s, '[]'::jsonb, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, description, price, stock, min_stock, category, images, status, weight, sku, creator_id, unit, product_type, created_at, last_updated, localization + """ + cur = self.db.cursor() + cur.execute( + query, + ( + name, + description, + price, + stock, + min_stock, + category, + status, + weight, + sku, + creator_id, + unit, + product_type, + localization, + ), + ) + row = cur.fetchone() + self.db.commit() + colnames = [desc[0] for desc in cur.description] + cur.close() + return dict(zip(colnames, row)) def update_product(self, product_id, updates: dict, user_timezone: str = "UTC"): # Protecting the created_at and id Update field @@ -83,7 +93,9 @@ def find_by_id(self, product_id: int): def find_by_category(self, category_id: int): with self._get_cursor() as cursor: - cursor.execute("SELECT * FROM products WHERE category_id=%s", (category_id,)) + cursor.execute( + "SELECT * FROM products WHERE category_id=%s", (category_id,) + ) return cursor.fetchall() def find_by_name(self, name: str): diff --git a/backend/zato-csm-backend/routes/products.py b/backend/zato-csm-backend/routes/products.py index 646974b1..6f5c8141 100644 --- a/backend/zato-csm-backend/routes/products.py +++ b/backend/zato-csm-backend/routes/products.py @@ -34,16 +34,12 @@ def _get_product_service(db=Depends(get_db_connection)) -> ProductService: @router.post("/", response_model=ProductResponse) def create_product( product_data: CreateProductRequest, - images: List[UploadFile] = File(None), - # request: Request, current_user=Depends(get_current_user), product_service=Depends(_get_product_service), ): - # user_timezone = get_user_timezone_from_request(request) product = product_service.create_product( product_data, creator_id=current_user['id'], - images=images ) return ProductResponse(**product) diff --git a/backend/zato-csm-backend/services/product_service.py b/backend/zato-csm-backend/services/product_service.py index f5de23cb..28475064 100644 --- a/backend/zato-csm-backend/services/product_service.py +++ b/backend/zato-csm-backend/services/product_service.py @@ -3,105 +3,60 @@ from models.product import CreateProductRequest from repositories.product_repositories import ProductRepository -from PIL import Image -import os -import io + class ProductService: - def __init__(self, product_repo: ProductRepository): - self.product_repo = product_repo - - def create_product( - self, - product_data: CreateProductRequest, - creator_id: int, - images: List = None - ): - # Processar upload de imagens - images_paths = self._process_images(images) if images else [] + def __init__(self, repo: ProductRepository): + self.repo = repo + + def create_product(self, product_data: CreateProductRequest, creator_id: int): + unit_val = getattr(product_data.unit, "value", product_data.unit) + type_val = getattr( + product_data.product_type, "value", product_data.product_type + ) + category_val = getattr(product_data.category, "value", product_data.category) - # Criar produto - product = self.product_repo.create_product( + return self.repo.create_product( name=product_data.name, description=product_data.description, - price=product_data.price, - stock=product_data.stock, - category_id=product_data.category_id, - images=images_paths, + price=float(product_data.price), + stock=int(product_data.stock), + unit=unit_val, + product_type=type_val, + category=category_val, sku=product_data.sku, + min_stock=int(getattr(product_data, "min_stock", 0)), + status=getattr( + getattr(product_data, "status", "active"), + "value", + getattr(product_data, "status", "active"), + ), + weight=getattr(product_data, "weight", None), + localization=getattr(product_data, "localization", None), creator_id=creator_id, - unit=product_data.unit.value, - product_type=product_data.product_type.value, - weight=product_data.weight, - localization=product_data.localization, - min_stock=product_data.min_stock or 0, - status=product_data.status.value ) - return product - - def _process_images(self, images: List): - if not images: - return [] - - ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} - MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB em bytes - - image_paths = [] - upload_dir = "uploads/products/" - os.makedirs(upload_dir, exist_ok=True) - - for image in images: - ext = os.path.splitext(image.filename)[1].lower() - - # validação de formatos - if ext not in ALLOWED_EXTENSIONS: - raise HTTPException(status_code=400, detail=f"Invalid image format. Allowed: {'.'.join(ALLOWED_EXTENSIONS)}") - - image_content = image.file.read() - - # validação de tamanho - file_size = len(image_content) - if file_size > MAX_FILE_SIZE: - size_mb = file_size / (1024 * 1024) - raise HTTPException(status_code=400, detail=f"Image too large: {size_mb:.1f}MB. Maximum allowed: 5MB") - - # processamento da imagem - try: - img = Image.open(io.BytesIO(image_content)) - img.verify() # prevenção de uploads maliciosos - except Exception: - raise HTTPException(status_code=400, detail=f"Invalid image file: {image.filename}") - - filename = f"product-{int(os.times()[4] * 1000)}-{os.getpid()}{ext}" - filepath = os.path.join(upload_dir, filename) - with open(filepath, "wb") as f: - f.write(image.file.read()) - image_paths.append(f"/uploads/products/{filename}") - - # resert file pointer para as próximas operações - # image.file.seek(0) - return image_paths def list_products(self): - # Buscar todos os produtos - return self.product_repo.find_all() + return self.repo.find_all() def search_by_category(self, category: str): - return self.product_repo.find_by_category(category) + return self.repo.find_by_category(category) def search_by_name(self, name: str): - return self.product_repo.find_by_name(name) + return self.repo.find_by_name(name) def get_product(self, product_id): if not product_id or product_id <= 0: raise HTTPException(status_code=400, detail="Invalid product ID") - product = self.product_repo.find_by_id(product_id) + product = self.repo.find_by_id(product_id) if not product: raise HTTPException(status_code=404, detail="Product not found") return product - def update_product(self, product_id: int, updates: dict, user_timezone: str = "UTC"): + def update_product( + self, product_id: int, updates: dict, user_timezone: str = "UTC" + ): # Validation allowed fields allowed_fields = ["name", "description", "price", "stock", "category", "images"] @@ -118,7 +73,7 @@ def update_product(self, product_id: int, updates: dict, user_timezone: str = "U if images and isinstance(images, list): updates["images"] = self._process_images(images) - return self.product_repo.update_product(product_id, updates, user_timezone) + return self.repo.update_product(product_id, updates, user_timezone) def delete_product(self, product_id): - return self.product_repo.delete_product(product_id) + return self.repo.delete_product(product_id) diff --git a/frontend/src/components/NewProductPage.tsx b/frontend/src/components/NewProductPage.tsx index 05bca9d6..793b0310 100644 --- a/frontend/src/components/NewProductPage.tsx +++ b/frontend/src/components/NewProductPage.tsx @@ -32,6 +32,20 @@ const NewProductPage: React.FC = () => { 'Electronics', 'Decoration', 'Office', + 'Gaming', + ]; + + const unitOptions = [ + { label: 'Per item', value: 'Per item' }, + { label: 'Per kilogram', value: 'Per kilogram' }, + { label: 'Per liter', value: 'Per liter' }, + { label: 'Per metro', value: 'Per metro' }, + ]; + + const productTypeOptions = [ + { label: 'Physical Product', value: 'Physical Product' }, + { label: 'Service', value: 'Service' }, + { label: 'Digital', value: 'Digital' }, ]; const handleInputChange = (field: string, value: string | boolean) => { @@ -103,42 +117,37 @@ const NewProductPage: React.FC = () => { setLoading(false); return; } + if (selectedCategories.length === 0) { + setError('Category is required'); + setLoading(false); + return; + } + const priceNum = Number(formData.price); + if (!Number.isFinite(priceNum) || priceNum <= 0) { + setError('Price must be greater than 0'); + setLoading(false); + return; + } + const stockNum = parseInt(formData.inventoryQuantity, 10); + if (!Number.isFinite(stockNum) || Number.isNaN(stockNum) || stockNum < 0) { + setError('Inventory quantity must be 0 or more'); + setLoading(false); + return; + } + try { - const productPayload: any = { + const productPayload = { name: formData.name, description: formData.description || null, - price: parseFloat(formData.price) || 0, - stock: parseInt(formData.inventoryQuantity) || 0, - min_stock: parseInt(formData.lowStockAlert) || 0, - weight: formData.weight ? parseFloat(formData.weight) : null, - sku: formData.sku || null, - unit_name: formData.unit, + price: priceNum, + stock: stockNum, + unit: formData.unit, product_type: formData.productType || 'Physical Product', - localization: formData.location || null, - status: 'active', + category: selectedCategories[0], + sku: formData.sku || null, }; - if (selectedCategories.length > 0) { - productPayload.category = selectedCategories[0]; - } - console.log('DEBUG create payload:', productPayload); - const createResult = await productsAPI.create(productPayload); - if (!createResult || !createResult.success) { - setError( - (createResult && createResult.message) || 'Error creating product' - ); - setLoading(false); - return; - } - const createdProduct = createResult.product; - if (selectedFiles.length > 0 && createdProduct && createdProduct.id) { - const imagesForm = new FormData(); - selectedFiles.forEach((file) => { - imagesForm.append('images', file); - }); - if (productsAPI.uploadImages) { - await productsAPI.uploadImages(createdProduct.id, imagesForm); - } - } + + await productsAPI.create(productPayload as any); navigate('/inventory'); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Error creating product'); @@ -335,10 +344,29 @@ const NewProductPage: React.FC = () => { className='w-full p-3 border rounded-lg border-divider focus:ring-2 focus:ring-complement focus:border-transparent bg-bg-surface text-text-primary' required > - - - - + {unitOptions.map((opt) => ( + + ))} + + +
+ +
From 2a980be5baadbb725d64e07004e3f1b40b379da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?essp=C3=ADndola?= Date: Tue, 19 Aug 2025 17:37:09 -0300 Subject: [PATCH 02/11] feat: Enhance product management with dynamic product type and unit display - Updated NewProductPage to conditionally set product type based on user input. - Modified ProductCard to include unit information for products, defaulting to 'per unit'. - Adjusted API interface to accommodate optional product_type field. --- .../services/product_service.py | 71 +- frontend/src/components/EditProductPage.tsx | 1086 ++++++----------- frontend/src/components/NewProductPage.tsx | 7 +- frontend/src/components/ProductCard.tsx | 77 +- frontend/src/services/api.ts | 1 + 5 files changed, 457 insertions(+), 785 deletions(-) diff --git a/backend/zato-csm-backend/services/product_service.py b/backend/zato-csm-backend/services/product_service.py index 28475064..67224f7a 100644 --- a/backend/zato-csm-backend/services/product_service.py +++ b/backend/zato-csm-backend/services/product_service.py @@ -57,17 +57,76 @@ def get_product(self, product_id): def update_product( self, product_id: int, updates: dict, user_timezone: str = "UTC" ): - # Validation allowed fields - allowed_fields = ["name", "description", "price", "stock", "category", "images"] + allowed_fields = [ + "name", + "description", + "price", + "stock", + "category", + "images", + "sku", + "weight", + "localization", + "min_stock", + "status", + "product_type", + "unit", + ] for field in list(updates.keys()): if field not in allowed_fields: raise HTTPException(status_code=400, detail=f"Invalid field: {field}") - if "price" in updates and updates["price"] <= 0: - raise HTTPException(status_code=400, detail="Price must be positive") - if "stock" in updates and updates["stock"] < 0: - raise HTTPException(status_code=400, detail="Stock cannot be negative") + for fld in ( + "product_type", + "unit", + "category", + "description", + "localization", + "sku", + "status", + ): + val = updates.get(fld) + if val == "" or val is None: + updates.pop(fld, None) + + if "price" in updates: + try: + updates["price"] = float(updates["price"]) + if updates["price"] <= 0: + raise HTTPException( + status_code=400, detail="Price must be positive" + ) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid price value") + + if "stock" in updates: + try: + updates["stock"] = int(updates["stock"]) + if updates["stock"] < 0: + raise HTTPException( + status_code=400, detail="Stock cannot be negative" + ) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid stock value") + + if "min_stock" in updates: + try: + updates["min_stock"] = int(updates["min_stock"]) + if updates["min_stock"] < 0: + raise HTTPException( + status_code=400, detail="min_stock cannot be negative" + ) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid min_stock value") + + if "weight" in updates: + try: + updates["weight"] = float(updates["weight"]) + if updates["weight"] < 0: + raise HTTPException(status_code=400, detail="Invalid weight value") + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid weight value") images = updates.get("images") if images and isinstance(images, list): diff --git a/frontend/src/components/EditProductPage.tsx b/frontend/src/components/EditProductPage.tsx index c980e0dc..8f7fcbbb 100644 --- a/frontend/src/components/EditProductPage.tsx +++ b/frontend/src/components/EditProductPage.tsx @@ -1,36 +1,20 @@ import React, { useState, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { ArrowLeft, Upload, Plus, X } from 'lucide-react'; +import { ArrowLeft, Upload, Plus } from 'lucide-react'; import { productsAPI, Product } from '../services/api'; import { useAuth } from '../contexts/AuthContext'; -interface VariantValue { - id: string; - value: string; - selected: boolean; -} - -interface VariantData { - [key: string]: { - isActive: boolean; - values: VariantValue[]; - newValue: string; - showPanel: boolean; - }; -} - const EditProductPage: React.FC = () => { const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const { isAuthenticated } = useAuth(); const [formData, setFormData] = useState({ - productType: 'Physical Product', + productType: '', name: '', description: '', - location: 'main-warehouse', - createCategory: false, - unit: 'Per item', + location: '', + unit: '', weight: '', price: '', inventoryQuantity: '', @@ -38,7 +22,6 @@ const EditProductPage: React.FC = () => { sku: '', }); const [selectedCategories, setSelectedCategories] = useState([]); - const [showMoreVariantsPanel, setShowMoreVariantsPanel] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -51,113 +34,63 @@ const EditProductPage: React.FC = () => { 'Electronics', 'Decoration', 'Office', + 'Gaming', ]; - const variantTypes = ['Color', 'Size', 'Material', 'Style', 'Finish']; + const unitOptions = [ + { label: 'Per item', value: 'Per item' }, + { label: 'Per kilogram', value: 'Per kilogram' }, + { label: 'Per liter', value: 'Per liter' }, + { label: 'Per metro', value: 'Per metro' }, + ]; - const [variants, setVariants] = useState({ - Color: { - isActive: true, - values: [ - { id: '1', value: 'Red', selected: false }, - { id: '2', value: 'Blue', selected: true }, - { id: '3', value: 'Green', selected: false }, - { id: '4', value: 'Black', selected: true }, - ], - newValue: '', - showPanel: false, - }, - Size: { - isActive: true, - values: [ - { id: '1', value: 'Small', selected: false }, - { id: '2', value: 'Medium', selected: true }, - { id: '3', value: 'Large', selected: false }, - { id: '4', value: 'Extra Large', selected: false }, - ], - newValue: '', - showPanel: false, - }, - Material: { - isActive: false, - values: [ - { id: '1', value: 'Wood', selected: false }, - { id: '2', value: 'Metal', selected: false }, - { id: '3', value: 'Plastic', selected: false }, - { id: '4', value: 'Glass', selected: false }, - ], - newValue: '', - showPanel: false, - }, - Style: { - isActive: false, - values: [ - { id: '1', value: 'Modern', selected: false }, - { id: '2', value: 'Classic', selected: false }, - { id: '3', value: 'Minimalist', selected: false }, - { id: '4', value: 'Industrial', selected: false }, - ], - newValue: '', - showPanel: false, - }, - Finish: { - isActive: false, - values: [ - { id: '1', value: 'Matte', selected: false }, - { id: '2', value: 'Glossy', selected: false }, - { id: '3', value: 'Satin', selected: false }, - { id: '4', value: 'Textured', selected: false }, - ], - newValue: '', - showPanel: false, - }, - }); + const productTypeOptions = [ + { label: 'Physical Product', value: 'Physical Product' }, + { label: 'Service', value: 'Service' }, + { label: 'Digital', value: 'Digital' }, + ]; - // Load product data when component mounts useEffect(() => { const fetchProduct = async () => { - if (!id || !isAuthenticated) { - setError('ID de producto inválido o no autenticado'); + if (!id) { + setError('ID de producto inválido'); setLoading(false); return; } - try { setLoading(true); setError(null); - - const response = await productsAPI.getById(parseInt(id)); - + const response = await productsAPI.getById(parseInt(id, 10)); if (response.success) { - const product = response.product; - setFormData({ - productType: 'Physical Product', - name: product.name, + const product = response.product as Product; + setOriginalProduct(product); + setFormData((prev) => ({ + ...prev, + productType: '', + name: product.name || '', description: product.description || '', - location: 'main-warehouse', - createCategory: false, - unit: 'Per item', - weight: '', - price: product.price.toString(), - inventoryQuantity: product.stock.toString(), - lowStockAlert: '', + location: product.localization || '', + unit: '', + weight: product.weight != null ? String(product.weight) : '', + price: product.price != null ? String(product.price) : '', + inventoryQuantity: + product.stock != null ? String(product.stock) : '', + lowStockAlert: + product.min_stock != null ? String(product.min_stock) : '', sku: product.sku || '', - }); - setSelectedCategories([product.category]); - setOriginalProduct(product); + })); + setSelectedCategories(product.category ? [product.category] : []); } else { setError('Error al cargar el producto'); } } catch (err) { - console.error('Error fetching product:', err); setError('Error al cargar el producto'); } finally { setLoading(false); } }; - fetchProduct(); - }, [id, isAuthenticated]); + }, [id]); const handleInputChange = (field: string, value: string | boolean) => { setFormData((prev) => ({ ...prev, [field]: value })); @@ -171,153 +104,98 @@ const EditProductPage: React.FC = () => { ); }; - const handleVariantToggle = (variantType: string) => { - setVariants((prev) => ({ - ...prev, - [variantType]: { - ...prev[variantType], - isActive: !prev[variantType].isActive, - showPanel: !prev[variantType].isActive ? true : false, - }, - })); - }; - - const handleVariantValueToggle = (variantType: string, valueId: string) => { - setVariants((prev) => ({ - ...prev, - [variantType]: { - ...prev[variantType], - values: prev[variantType].values.map((value) => - value.id === valueId ? { ...value, selected: !value.selected } : value - ), - }, - })); - }; - - const handleAddVariantValue = (variantType: string) => { - const newValue = variants[variantType].newValue.trim(); - if (newValue) { - const newId = Date.now().toString(); - setVariants((prev) => ({ - ...prev, - [variantType]: { - ...prev[variantType], - values: [ - ...prev[variantType].values, - { id: newId, value: newValue, selected: true }, - ], - newValue: '', - }, - })); - } - }; - - const handleNewValueChange = (variantType: string, value: string) => { - setVariants((prev) => ({ - ...prev, - [variantType]: { - ...prev[variantType], - newValue: value, - }, - })); - }; - - const handleSaveVariant = (variantType: string) => { - setVariants((prev) => ({ - ...prev, - [variantType]: { - ...prev[variantType], - showPanel: false, - }, - })); - }; - - const handleCancelVariant = (variantType: string) => { - setVariants((prev) => ({ - ...prev, - [variantType]: { - ...prev[variantType], - showPanel: false, - isActive: false, - values: prev[variantType].values.map((value) => ({ - ...value, - selected: false, - })), - newValue: '', - }, - })); - }; - - const getSelectedValues = (variantType: string) => { - return variants[variantType].values - .filter((value) => value.selected) - .map((value) => value.value) - .join(', '); - }; - - const handleMoreVariantsToggle = (variantType: string) => { - setVariants((prev) => ({ - ...prev, - [variantType]: { - ...prev[variantType], - isActive: !prev[variantType].isActive, - }, - })); - }; - - const handleSaveMoreVariants = () => { - setShowMoreVariantsPanel(false); - }; - const handleSave = async () => { if (!isAuthenticated) { - setError('You must log in to edit products'); + setError('Debes iniciar sesión para editar productos'); return; } - if (!id) { - setError('Invalid product ID or not authenticated'); + setError('ID de producto inválido'); return; } - - if (!formData.name || !formData.price) { - setError('Name and price are required'); + if (!formData.name || formData.name.trim() === '') { + setError('El nombre es requerido'); + return; + } + const priceNum = Number(formData.price); + if ( + formData.price !== '' && + (!Number.isFinite(priceNum) || priceNum <= 0) + ) { + setError('Precio inválido'); + return; + } + const stockNum = + formData.inventoryQuantity === '' + ? null + : parseInt(formData.inventoryQuantity, 10); + if ( + stockNum !== null && + (!Number.isFinite(stockNum) || Number.isNaN(stockNum) || stockNum < 0) + ) { + setError('Cantidad de inventario inválida'); return; } - try { setSaving(true); setError(null); - - const priceNum = parseFloat(formData.price); - const stockNum = parseInt(formData.inventoryQuantity) || 0; - const category = selectedCategories[0] || 'General'; - - const payload: Partial = {}; + const payload: Record = {}; + const category = selectedCategories[0]; if (!originalProduct) { payload.name = formData.name; - payload.description = formData.description || undefined; - payload.price = priceNum; - payload.stock = stockNum; - payload.category = category; - if (formData.sku !== undefined) - payload.sku = formData.sku === '' ? undefined : formData.sku; + payload.description = formData.description || null; + if (formData.price !== '') payload.price = priceNum; + if (stockNum !== null) payload.stock = stockNum; + if (category) payload.category = category; + if (formData.unit !== '') payload.unit = formData.unit; + if (formData.productType !== '') + payload.product_type = formData.productType; + if (formData.location !== '') payload.localization = formData.location; + if (formData.weight !== '') payload.weight = Number(formData.weight); + if (formData.sku !== '') payload.sku = formData.sku; + if (formData.lowStockAlert !== '') + payload.min_stock = Number(formData.lowStockAlert); } else { if (formData.name !== originalProduct.name) payload.name = formData.name; if ( (formData.description || '') !== (originalProduct.description || '') ) - payload.description = formData.description || undefined; - if (!Number.isNaN(priceNum) && priceNum !== originalProduct.price) + payload.description = formData.description || null; + if (formData.price !== '' && priceNum !== originalProduct.price) payload.price = priceNum; - if (stockNum !== originalProduct.stock) payload.stock = stockNum; - if (category !== originalProduct.category) payload.category = category; - if ((formData.sku || '') !== (originalProduct.sku || '')) { - if (formData.sku) payload.sku = formData.sku; - else payload.sku = undefined; - } + if (stockNum !== null && stockNum !== originalProduct.stock) + payload.stock = stockNum; + if (category && category !== originalProduct.category) + payload.category = category; + if ( + formData.unit !== '' && + formData.unit !== (originalProduct.unit || '') + ) + payload.unit = formData.unit; + if ( + formData.productType !== '' && + formData.productType !== (originalProduct.product_type || '') + ) + payload.product_type = formData.productType; + if ( + formData.location !== '' && + formData.location !== (originalProduct.localization || '') + ) + payload.localization = formData.location; + if ( + formData.weight !== '' && + Number(formData.weight) !== (originalProduct.weight || 0) + ) + payload.weight = Number(formData.weight); + if (formData.sku !== '' && formData.sku !== (originalProduct.sku || '')) + payload.sku = formData.sku; + if ( + formData.lowStockAlert !== '' && + Number(formData.lowStockAlert) !== (originalProduct.min_stock || 0) + ) + payload.min_stock = Number(formData.lowStockAlert); } if (Object.keys(payload).length === 0) { @@ -326,7 +204,7 @@ const EditProductPage: React.FC = () => { return; } - const response = await productsAPI.update(parseInt(id), payload); + const response = await productsAPI.update(parseInt(id, 10), payload); if (response.success) { setOriginalProduct(response.product); @@ -335,7 +213,6 @@ const EditProductPage: React.FC = () => { setError('Error updating product'); } } catch (err) { - console.error('Error updating product:', err); setError('Error updating product'); } finally { setSaving(false); @@ -344,44 +221,27 @@ const EditProductPage: React.FC = () => { const handleDelete = async () => { if (!isAuthenticated) { - setError('You must log in to delete products'); + setError('Debes iniciar sesión para eliminar productos'); return; } - if (!id) { - setError('Invalid product ID or not authenticated'); + setError('ID de producto inválido'); return; } - - if ( - !window.confirm( - 'Are you sure you want to delete this product? This action cannot be undone.' - ) - ) { - return; - } - + if (!window.confirm('¿Seguro que querés eliminar este producto?')) return; try { setSaving(true); setError(null); - - const response = await productsAPI.delete(parseInt(id)); - - if (response.success) { - console.log('Product deleted successfully'); - navigate('/inventory'); - } else { - setError('Error deleting product'); - } + const response = await productsAPI.delete(parseInt(id, 10)); + if (response.success) navigate('/inventory'); + else setError('Error deleting product'); } catch (err) { - console.error('Error deleting product:', err); setError('Error deleting product'); } finally { setSaving(false); } }; - // Show loading state if (loading) { return (
@@ -393,7 +253,6 @@ const EditProductPage: React.FC = () => { ); } - // Show error state if (error) { return (
@@ -427,532 +286,277 @@ const EditProductPage: React.FC = () => { return (
- {loading ? ( -
-
-

Loading product...

+
+
+
+
+ +

+ Edit Product +

+
+
+ {error &&
{error}
} + + +
+
- ) : ( - <> - {/* Sub-header */} -
-
-
-
- -

- Edit Product -

-
+
-
- {error &&
{error}
} - - -
+
+
+
+
+
+ + handleInputChange('name', e.target.value)} + placeholder='Product name' + className='w-full p-3 border rounded-lg border-divider focus:ring-2 focus:ring-complement focus:border-transparent bg-bg-surface text-text-primary' + required + /> +
+
+ +