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..9d221704 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): @@ -101,3 +113,8 @@ def delete_product(self, product_id: int): if not product: raise HTTPException(status_code=404, detail="Product not found") return product + + def find_by_creator(self, creator_id: int): + with self._get_cursor() as cursor: + cursor.execute("SELECT * FROM products WHERE creator_id=%s", (creator_id,)) + return cursor.fetchall() diff --git a/backend/zato-csm-backend/routes/auth.py b/backend/zato-csm-backend/routes/auth.py index e3d1b3f8..f25835d7 100644 --- a/backend/zato-csm-backend/routes/auth.py +++ b/backend/zato-csm-backend/routes/auth.py @@ -147,3 +147,11 @@ def update_profile( if not current_user.get("admin"): raise HTTPException(status_code=403, detail="Acess denied") return auth_service.update_profile(user_id, updates) + + +@router.get("/check-email") +def check_email(email: str, db=Depends(get_db_connection)): + with db.cursor() as cursor: + cursor.execute("SELECT id FROM users WHERE email=%s", (email,)) + row = cursor.fetchone() + return {"exists": bool(row)} diff --git a/backend/zato-csm-backend/routes/inventory.py b/backend/zato-csm-backend/routes/inventory.py index 5c0a12be..b9e612ae 100644 --- a/backend/zato-csm-backend/routes/inventory.py +++ b/backend/zato-csm-backend/routes/inventory.py @@ -21,6 +21,14 @@ def get_inventory( return {"success": True, "inventory": inventory} +@router.get("/user") +def get_user_inventory( + user=Depends(get_current_user), inventory_service=Depends(_get_inventory_service) +): + inventory = inventory_service.get_inventory_by_user(user["id"]) + return {"success": True, "inventory": inventory} + + @router.put("/{product_id}") def update_inventory( product_id: int, 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/inventory_service.py b/backend/zato-csm-backend/services/inventory_service.py index e3cfc273..790fed14 100644 --- a/backend/zato-csm-backend/services/inventory_service.py +++ b/backend/zato-csm-backend/services/inventory_service.py @@ -10,15 +10,28 @@ def __init__(self, product_repo: ProductRepository): def get_inventory(self): products = self.product_repo.find_all() - inventory = [] + return [ + { + "id": p["id"], + "product_id": p["id"], + "quantity": p.get("stock", 0), + "location": p.get("location"), + "updated_at": p.get("last_updated", datetime.now().isoformat() + "Z"), + "product": p, + } + for p in products + ] + def get_inventory_by_user(self, user_id: int): + products = self.product_repo.find_by_creator(user_id) return [ { "id": p["id"], - "productId": p["id"], - "quantity": p["stock"], - "minStock": p.get("min_stock", 0), - "lastUpdated": p.get("last_updated", datetime.now().isoformat() + "Z"), + "product_id": p["id"], + "quantity": p.get("stock", 0), + "location": p.get("location"), + "updated_at": p.get("last_updated", datetime.now().isoformat() + "Z"), + "product": p, } for p in products ] @@ -36,36 +49,32 @@ def update_stock(self, product_id: int, quantity: int, user_timezone: str = "UTC return { "id": updated_product["id"], - "productId": updated_product["id"], - "quantity": updated_product["stock"], - "lastUpdated": updated_product.get( - "last_updated", datetime.now().isoformat() + "Z" - ), + "product_id": updated_product["id"], + "quantity": updated_product.get("stock", 0), + "updated_at": updated_product.get("last_updated", datetime.now().isoformat() + "Z"), + "product": updated_product, } def check_low_stock(self, min_threshold: int = 0): - """Function to check low stock products""" products = self.product_repo.find_all() - return [ { "id": p["id"], - "currentStock": p["stock"], - "minStock": min_threshold, - "needRestock": True, + "product_id": p["id"], + "quantity": p.get("stock", 0), + "min_threshold": min_threshold, + "need_restock": True, + "product": p, } for p in products - if p["stock"] <= min_threshold + if p.get("stock", 0) <= min_threshold ] def get_inventory_summary(self): - """Inventory summary functionality""" products = self.product_repo.find_all() - total_products = len(products) - total_stock = sum(p["stock"] for p in products) - low_stock_count = len([p for p in products if p["stock"] <= 0]) - + total_stock = sum(p.get("stock", 0) for p in products) + low_stock_count = len([p for p in products if p.get("stock", 0) <= 0]) return { "totalProducts": total_products, "totalStock": total_stock, diff --git a/backend/zato-csm-backend/services/product_service.py b/backend/zato-csm-backend/services/product_service.py index f5de23cb..67224f7a 100644 --- a/backend/zato-csm-backend/services/product_service.py +++ b/backend/zato-csm-backend/services/product_service.py @@ -3,122 +3,136 @@ 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"): - # Validation allowed fields - allowed_fields = ["name", "description", "price", "stock", "category", "images"] + def update_product( + self, product_id: int, updates: dict, user_timezone: str = "UTC" + ): + 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): 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/index.html b/frontend/index.html index 4a403743..0912411a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - + - FrontPOSw - Inventory Management System + ZatoBox
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8e16ff9..cd4012a2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6051,7 +6051,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 76a14122..1504e84d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { Navigate, Outlet, } from 'react-router-dom'; -import { AuthProvider } from './contexts/AuthContext'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; import { PluginProvider } from './contexts/PluginContext'; import ProtectedRoute from './components/ProtectedRoute'; import SideMenu from './components/SideMenu'; @@ -34,6 +34,18 @@ function AppLayout() { ); } +function RootElement() { + const { isAuthenticated } = useAuth(); + if (isAuthenticated) { + return ( + + + + ); + } + return ; +} + function App() { return ( @@ -48,11 +60,7 @@ function App() { - - - } + element={} > } /> } /> @@ -66,7 +74,7 @@ function App() {

POS Integration

- POS system integration module is active and ready to use. + POS system integration module is currently under development. This feature will allow you to connect ZatoBox with your existing POS system for seamless inventory management and sales tracking.

} diff --git a/frontend/src/components/EditProductPage.tsx b/frontend/src/components/EditProductPage.tsx index c980e0dc..a6b48df6 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, Check } 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,11 +22,12 @@ 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); const [originalProduct, setOriginalProduct] = useState(null); + const [status, setStatus] = useState<'active' | 'inactive' | ''>(''); + const [togglingStatus, setTogglingStatus] = useState(false); const existingCategories = [ 'Furniture', @@ -51,113 +36,64 @@ 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); + setStatus(product.status ?? 'inactive'); + 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 +107,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 as any).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,16 +207,16 @@ 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); + setStatus(response.product.status ?? 'inactive'); navigate('/inventory'); } else { setError('Error updating product'); } } catch (err) { - console.error('Error updating product:', err); setError('Error updating product'); } finally { setSaving(false); @@ -344,44 +225,56 @@ 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('¿Seguro que querés eliminar este producto?')) return; + try { + setSaving(true); + setError(null); + const response = await productsAPI.delete(parseInt(id, 10)); + if (response.success) navigate('/inventory'); + else setError('Error deleting product'); + } catch (err) { + setError('Error deleting product'); + } finally { + setSaving(false); + } + }; - if ( - !window.confirm( - 'Are you sure you want to delete this product? This action cannot be undone.' - ) - ) { + const handleToggleStatus = async () => { + if (!isAuthenticated) { + setError('Debes iniciar sesión para cambiar el estado'); return; } - + if (!id) { + setError('ID de producto inválido'); + return; + } + const newStatus = status === 'active' ? 'inactive' : 'active'; try { - setSaving(true); + setTogglingStatus(true); setError(null); - - const response = await productsAPI.delete(parseInt(id)); - + const response = await productsAPI.update(parseInt(id, 10), { + status: newStatus, + }); if (response.success) { - console.log('Product deleted successfully'); - navigate('/inventory'); + setStatus(newStatus); + setOriginalProduct(response.product); } else { - setError('Error deleting product'); + setError('Error al cambiar estado'); } } catch (err) { - console.error('Error deleting product:', err); - setError('Error deleting product'); + setError('Error al cambiar estado'); } finally { - setSaving(false); + setTogglingStatus(false); } }; - // Show loading state if (loading) { return (
@@ -393,7 +286,6 @@ const EditProductPage: React.FC = () => { ); } - // Show error state if (error) { return (
@@ -427,532 +319,299 @@ 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 + /> +
+
+ +