From 01ae4a09ff8370af85bad6e344fbfb79f3a2eb0f Mon Sep 17 00:00:00 2001 From: eroncero Date: Sat, 24 Jan 2026 13:28:54 +0100 Subject: [PATCH] Initial commit: Ballet Production Suite ERP/CRM foundation --- .gitignore | 13 +++ backend/auth.py | 60 +++++++++++++ backend/database.py | 21 +++++ backend/main.py | 167 ++++++++++++++++++++++++++++++++++++ backend/models.py | 93 ++++++++++++++++++++ frontend/api_client.py | 38 ++++++++ frontend/app.py | 141 ++++++++++++++++++++++++++++++ frontend/pages/__init__.py | 0 frontend/pages/logistics.py | 24 ++++++ requirements.txt | 11 +++ 10 files changed, 568 insertions(+) create mode 100644 .gitignore create mode 100644 backend/auth.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 frontend/api_client.py create mode 100644 frontend/app.py create mode 100644 frontend/pages/__init__.py create mode 100644 frontend/pages/logistics.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..156ddd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*$py.class +.venv +venv/ +ENV/ +env/ +.env +data/*.db +*.sqlite3 +.vscode/ +.idea/ +.DS_Store diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..e75d26d --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,60 @@ +import os +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlmodel import Session, select +from .database import get_session +from .models import User +from dotenv import load_dotenv + +load_dotenv() + +SECRET_KEY = os.getenv("SECRET_KEY", "yoursecretkeyhere") +ALGORITHM = os.getenv("ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")) + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +async def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = session.exec(select(User).where(User.username == username)).first() + if user is None: + raise credentials_exception + return user + +async def get_current_active_user(current_user: User = Depends(get_current_user)): + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..a51e810 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,21 @@ +import os +from sqlmodel import create_engine, SQLModel, Session +from dotenv import load_dotenv + +load_dotenv() + +# Default to SQLite for easy development if not specified +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/ballet_app.db") + +# Use check_same_thread=False only for SQLite +connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} + +engine = create_engine(DATABASE_URL, connect_args=connect_args, echo=True) + +def create_db_and_tables(): + from . import models # Ensure models are registered + SQLModel.metadata.create_all(engine) + +def get_session(): + with Session(engine) as session: + yield session diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..febb016 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,167 @@ +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlmodel import Session, select +from .database import create_db_and_tables, get_session +from . import models, auth +from datetime import timedelta + +app = FastAPI(title="Ballet Production Suite API") + +@app.on_event("startup") +def on_startup(): + create_db_and_tables() + +@app.get("/") +def read_root(): + return {"message": "Welcome to the Ballet Production Suite API"} + +# --- Authentication --- +@app.post("/token") +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), session: Session = Depends(get_session)): + user = session.exec(select(models.User).where(models.User.username == form_data.username)).first() + if not user or not auth.verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=auth.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = auth.create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} + +@app.post("/users/", response_model=models.User) +def create_user(user: models.User, session: Session = Depends(get_session)): + user.hashed_password = auth.get_password_hash(user.hashed_password) + session.add(user) + session.commit() + session.refresh(user) + return user + +# --- Materials --- +@app.post("/materials/", response_model=models.Material) +def create_material(material: models.Material, session: Session = Depends(get_session)): + session.add(material) + session.commit() + session.refresh(material) + return material + +@app.get("/materials/", response_model=list[models.Material]) +def read_materials(session: Session = Depends(get_session)): + materials = session.exec(select(models.Material)).all() + return materials + +# --- Products --- +@app.post("/products/", response_model=models.Product) +def create_product(product: models.Product, session: Session = Depends(get_session)): + session.add(product) + session.commit() + session.refresh(product) + return product + +@app.get("/products/", response_model=list[dict]) +def read_products(session: Session = Depends(get_session)): + products = session.exec(select(models.Product)).all() + result = [] + for p in products: + # Calculate cost based on materials + total_cost = p.base_price + for link in p.materials: + total_cost += link.quantity_required * link.material.cost_per_unit + + p_dict = p.dict() + p_dict["total_cost"] = total_cost + result.append(p_dict) + return result + +@app.post("/products/{product_id}/materials/") +def add_material_to_product(product_id: int, material_id: int, quantity: float, session: Session = Depends(get_session)): + link = models.ProductMaterialLink(product_id=product_id, material_id=material_id, quantity_required=quantity) + session.add(link) + session.commit() + return {"message": "Material added to product"} + +# --- Clients --- +@app.post("/clients/", response_model=models.Client) +def create_client(client: models.Client, session: Session = Depends(get_session)): + session.add(client) + session.commit() + session.refresh(client) + return client + +@app.get("/clients/", response_model=list[models.Client]) +def read_clients(session: Session = Depends(get_session)): + clients = session.exec(select(models.Client)).all() + return clients + +# --- Orders --- +@app.post("/orders/", response_model=models.Order) +def create_order(order_data: dict, session: Session = Depends(get_session)): + # This is a simplified version for demonstration. + # In a real app, you'd use a Pydantic schema for the request body. + client_id = order_data.get("client_id") + product_ids = order_data.get("product_ids", []) # List of strings or dicts + + db_order = models.Order(client_id=client_id, status=models.OrderStatus.QUOTATION) + session.add(db_order) + session.commit() + session.refresh(db_order) + + for p_id in product_ids: + link = models.OrderProductLink(order_id=db_order.id, product_id=p_id, quantity=1) + session.add(link) + + session.commit() + session.refresh(db_order) + return db_order + +@app.get("/orders/", response_model=list[models.Order]) +def read_orders(session: Session = Depends(get_session)): + orders = session.exec(select(models.Order)).all() + return orders + +@app.get("/orders/{order_id}", response_model=models.Order) +def read_order(order_id: int, session: Session = Depends(get_session)): + order = session.get(models.Order, order_id) + if not order: + raise HTTPException(status_code=404, detail="Order not found") + return order + +# --- Production --- +@app.get("/production/", response_model=list[models.ProductionStep]) +def read_production_steps(session: Session = Depends(get_session)): + steps = session.exec(select(models.ProductionStep)).all() + return steps + +@app.patch("/production/{step_id}", response_model=models.ProductionStep) +def update_production_step(step_id: int, status: str, session: Session = Depends(get_session)): + step = session.get(models.ProductionStep, step_id) + if not step: + raise HTTPException(status_code=404, detail="Step not found") + step.status = status + if status == "In Progress": + step.started_at = datetime.utcnow() + elif status == "Completed": + step.completed_at = datetime.utcnow() + session.add(step) + session.commit() + session.refresh(step) + return step + +@app.get("/production/cutting-sheet") +def get_cutting_sheet(session: Session = Depends(get_session)): + # Group material requirements for all orders in 'Corte' status + orders_in_corte = session.exec(select(models.Order).where(models.Order.status == "Corte")).all() + + cutting_sheet = {} # material_name -> total_quantity + + for order in orders_in_corte: + for product_link in order.products: + product = product_link.product + for mat_link in product.materials: + mat_name = mat_link.material.name + qty = mat_link.quantity_required * product_link.quantity + cutting_sheet[mat_name] = cutting_sheet.get(mat_name, 0) + qty + + return cutting_sheet diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..39edcb0 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,93 @@ +from datetime import datetime +from typing import List, Optional +from sqlmodel import Field, SQLModel, Relationship + +# --- User & Auth --- +class User(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + username: str = Field(index=True, unique=True) + hashed_password: str + is_active: bool = Field(default=True) + is_admin: bool = Field(default=False) + +# --- Inventory & Materials --- +class Material(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + description: Optional[str] = None + unit: str # e.g., "meters", "units", "grams" + stock_quantity: float = Field(default=0.0) + reorder_level: float = Field(default=0.0) + cost_per_unit: float = Field(default=0.0) + +# --- Products --- +class Product(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + sku: str = Field(index=True, unique=True) + description: Optional[str] = None + base_price: float = Field(default=0.0) + estimated_time_hours: float = Field(default=0.0) + + # Relationships + orders: List["OrderProductLink"] = Relationship(back_populates="product") + materials: List["ProductMaterialLink"] = Relationship(back_populates="product") + +class ProductMaterialLink(SQLModel, table=True): + product_id: Optional[int] = Field(default=None, foreign_key="product.id", primary_key=True) + material_id: Optional[int] = Field(default=None, foreign_key="material.id", primary_key=True) + quantity_required: float + + product: Product = Relationship(back_populates="materials") + material: Material = Relationship() + +# --- CRM --- +class Client(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + full_name: str = Field(index=True) + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + + # Measurements (as a JSON or separate fields, keeping it simple for now) + measurements: Optional[str] = None # Or use a JSON type if supported better + + orders: List["Order"] = Relationship(back_populates="client") + +# --- Orders & Production --- +class OrderStatus(str): + QUOTATION = "Quotation" + PENDING = "Pending" + IN_PRODUCTION = "In Production" + SHIPPED = "Shipped" + DELIVERED = "Delivered" + CANCELLED = "Cancelled" + +class Order(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + client_id: int = Field(foreign_key="client.id") + order_date: datetime = Field(default_factory=datetime.utcnow) + status: str = Field(default=OrderStatus.QUOTATION) + total_amount: float = Field(default=0.0) + notes: Optional[str] = None + + client: Client = Relationship(back_populates="orders") + products: List["OrderProductLink"] = Relationship(back_populates="order") + +class OrderProductLink(SQLModel, table=True): + order_id: Optional[int] = Field(default=None, foreign_key="order.id", primary_key=True) + product_id: Optional[int] = Field(default=None, foreign_key="product.id", primary_key=True) + quantity: int = Field(default=1) + customizations: Optional[str] = None + + order: Order = Relationship(back_populates="products") + product: Product = Relationship(back_populates="orders") + +class ProductionStep(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + order_id: int = Field(foreign_key="order.id") + step_name: str # e.g., "Corte", "Confección", "Terminado" + status: str = Field(default="Pending") # Pending, In Progress, Completed + assigned_to: Optional[int] = Field(default=None, foreign_key="user.id") + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None diff --git a/frontend/api_client.py b/frontend/api_client.py new file mode 100644 index 0000000..7344566 --- /dev/null +++ b/frontend/api_client.py @@ -0,0 +1,38 @@ +import httpx +import os +from dotenv import load_dotenv + +load_dotenv() + +API_URL = os.getenv("API_URL", "http://localhost:8000") + +class APIClient: + def __init__(self): + self.client = httpx.Client(base_url=API_URL) + self.token = None + + def login(self, username, password): + response = self.client.post("/token", data={"username": username, "password": password}) + if response.status_code == 200: + self.token = response.json()["access_token"] + self.client.headers.update({"Authorization": f"Bearer {self.token}"}) + return True + return False + + def get_materials(self): + response = self.client.get("/materials/") + return response.json() + + def create_material(self, name, description, unit, stock_quantity, reorder_level, cost_per_unit): + data = { + "name": name, + "description": description, + "unit": unit, + "stock_quantity": stock_quantity, + "reorder_level": reorder_level, + "cost_per_unit": cost_per_unit + } + response = self.client.post("/materials/", json=data) + return response.json() + +api = APIClient() diff --git a/frontend/app.py b/frontend/app.py new file mode 100644 index 0000000..b2e9e1f --- /dev/null +++ b/frontend/app.py @@ -0,0 +1,141 @@ +from nicegui import ui +import api_client +from pages.logistics import render_logistics_page + +# Define the layout +@ui.page('/') +def main_page(): + with ui.header().classes('items-center justify-between'): + ui.label('Suite de Producción de Ballet').classes('text-2xl font-bold') + with ui.row(): + ui.button('Cerrar Sesión', icon='logout', on_click=lambda: ui.notify('Sesión cerrada')) + + with ui.left_drawer(value=True).classes('bg-slate-100'): + with ui.column().classes('w-full gap-2 p-4'): + ui.button('Panel de Control', icon='dashboard', on_click=lambda: content.set_content('Dashboard')).classes('w-full justify-start') + ui.button('Productos', icon='inventory_2', on_click=lambda: content.set_content('Productos')).classes('w-full justify-start') + ui.button('CRM y Pedidos', icon='people', on_click=lambda: content.set_content('CRM')).classes('w-full justify-start') + ui.button('Producción', icon='factory', on_click=lambda: content.set_content('Producción')).classes('w-full justify-start') + ui.button('Inventario', icon='category', on_click=lambda: content.set_content('Inventario')).classes('w-full justify-start') + ui.button('Logística', icon='local_shipping', on_click=lambda: content.set_content('Logística')).classes('w-full justify-start') + + with ui.column().classes('w-full p-8') as main_content: + ui.label('Bienvenido al Panel de Control de Ballet Atelier').classes('text-3xl') + ui.markdown(''' + Gestione su negocio de ropa de ballet de manera eficiente: + - **Productos**: Seguimiento de sus más de 100 modelos y materiales. + - **CRM**: Gestione clientes y sus medidas únicas. + - **Producción**: Supervise los pedidos a través del flujo del taller. + - **Inventario**: Seguimiento de stock y alertas en tiempo real. + ''') + + class ContentManager: + def set_content(self, page_name): + main_content.clear() + with main_content: + ui.label(f'Página de {page_name}').classes('text-3xl mb-4') + if page_name == 'Dashboard': + ui.label('Aquí aparecerán las estadísticas resumidas.') + elif page_name == 'Inventario': + render_inventory_page() + elif page_name == 'CRM': + render_crm_page() + elif page_name == 'Producción': + render_production_page() + elif page_name == 'Logística': + render_logistics_page() + elif page_name == 'Productos': + render_products_page() + else: + ui.label(f'Contenido para {page_name} próximamente...').classes('text-xl') + + content = ContentManager() + +def render_crm_page(): + ui.label('Gestión de Clientes (CRM)').classes('text-xl mb-4') + + with ui.row().classes('w-full items-start gap-4'): + # Client List + with ui.card().classes('flex-1'): + ui.label('Clientes').classes('font-bold mb-2') + columns = [ + {'name': 'name', 'label': 'Nombre', 'field': 'name', 'align': 'left'}, + {'name': 'phone', 'label': 'Teléfono', 'field': 'phone'}, + ] + rows = [ + {'name': 'Maria Garcia', 'phone': '600-000-000'}, + {'name': 'Elena Rodriguez', 'phone': '611-111-111'}, + ] + ui.table(columns=columns, rows=rows, row_key='name').classes('w-full') + + # New Client Form + with ui.card().classes('w-64'): + ui.label('Nuevo Cliente').classes('font-bold') + ui.input('Nombre Completo') + ui.input('Teléfono') + ui.textarea('Medidas').classes('w-full') + ui.button('Guardar Cliente', on_click=lambda: ui.notify('Cliente guardado')) + +def render_production_page(): + ui.label('Flujo de Producción (Kanban)').classes('text-xl mb-4') + + with ui.row().classes('w-full mb-4'): + ui.button('Generar Hoja de Corte', icon='content_cut', on_click=lambda: ui.notify('Hoja de corte generada para todos los pedidos pendientes')) + + with ui.row().classes('w-full justify-between gap-4'): + for status_label in ['Corte', 'Confección', 'Terminado']: + with ui.column().classes('bg-slate-50 p-4 rounded-lg flex-1 min-h-[400px] border border-slate-200'): + ui.label(status_label).classes('font-bold text-lg mb-4 text-slate-600 uppercase tracking-wider') + + # Sample Card + with ui.card().classes('w-full mb-2 cursor-pointer hover:shadow-md transition-shadow'): + ui.label('Pedido #1001').classes('font-bold') + ui.label('Cliente: Maria Garcia') + ui.label('Estado: En Proceso').classes('text-xs text-blue-500') + with ui.row().classes('justify-end w-full'): + ui.button(icon='arrow_forward', variant='text').classes('p-0') + +def render_products_page(): + ui.label('Catálogo de Productos').classes('text-xl mb-4') + with ui.grid(columns=3).classes('w-full gap-4'): + # In a real app, this would fetch from the API + products = [ + {'name': 'Tutú Profesional', 'sku': 'TUTU-001', 'total_cost': 65.50}, + {'name': 'Maillot Clásico', 'sku': 'LEO-002', 'total_cost': 28.00}, + {'name': 'Falda de Ballet', 'sku': 'SKT-003', 'total_cost': 15.20}, + ] + for p in products: + with ui.card(): + ui.label(p['name']).classes('font-bold') + ui.label(f"SKU: {p['sku']}").classes('text-xs text-slate-500') + ui.label(f"Coste Total: {p['total_cost']}€").classes('text-lg font-semibold text-primary') + ui.button('Editar Materiales', variant='text') + +def render_inventory_page(): + ui.label('Inventario de Materiales').classes('text-xl mb-2') + + # Simple form to add material + with ui.card().classes('w-full mb-4'): + ui.label('Añadir Nuevo Material').classes('font-bold') + with ui.row(): + name = ui.input('Nombre') + unit = ui.input('Unidad (ej. metros)') + stock = ui.number('Stock Inicial', value=0) + cost = ui.number('Coste por Unidad', value=0.0) + ui.button('Añadir Material', on_click=lambda: ui.notify(f'Añadido {name.value}')) + + # Placeholder for table + ui.label('Niveles de Stock').classes('font-bold') + columns = [ + {'name': 'name', 'label': 'Nombre', 'field': 'name', 'required': True, 'align': 'left'}, + {'name': 'unit', 'label': 'Unidad', 'field': 'unit'}, + {'name': 'stock', 'label': 'Stock', 'field': 'stock'}, + {'name': 'cost', 'label': 'Coste', 'field': 'cost'}, + ] + rows = [ + {'name': 'Satén Rosa', 'unit': 'metros', 'stock': 50, 'cost': 12.5}, + {'name': 'Tul Blanco', 'unit': 'metros', 'stock': 120, 'cost': 5.0}, + ] + ui.table(columns=columns, rows=rows, row_key='name') + +ui.run(title="Suite de Producción de Ballet", port=8080) diff --git a/frontend/pages/__init__.py b/frontend/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frontend/pages/logistics.py b/frontend/pages/logistics.py new file mode 100644 index 0000000..6d44126 --- /dev/null +++ b/frontend/pages/logistics.py @@ -0,0 +1,24 @@ +from nicegui import ui + +def render_logistics_page(): + ui.label('Logística y Envíos').classes('text-xl mb-4') + + with ui.row().classes('w-full gap-4'): + # Pending Shipments + with ui.card().classes('flex-1'): + ui.label('Envíos Pendientes').classes('font-bold mb-2') + columns = [ + {'name': 'order', 'label': 'Pedido #', 'field': 'order'}, + {'name': 'client', 'label': 'Cliente', 'field': 'client'}, + {'name': 'status', 'label': 'Estado', 'field': 'status'}, + ] + rows = [ + {'order': '1001', 'client': 'Maria Garcia', 'status': 'Listo'}, + ] + ui.table(columns=columns, rows=rows, row_key='order').classes('w-full') + + # Actions + with ui.column().classes('w-64 gap-2'): + ui.button('Generar Etiqueta de Envío', icon='label', on_click=lambda: ui.notify('Etiqueta generada (simulación PDF)')) + ui.button('Generar Factura', icon='receipt', on_click=lambda: ui.notify('Factura generada')) + ui.button('Marcar como Enviado', icon='check_circle', color='green', on_click=lambda: ui.notify('Estado del pedido actualizado')) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..83ef8eb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi +uvicorn +sqlmodel +psycopg2-binary +python-jose[cryptography] +passlib[bcrypt] +python-multipart +nicegui +httpx +python-dotenv +pytest