Initial commit: Ballet Production Suite ERP/CRM foundation

This commit is contained in:
2026-01-24 13:28:54 +01:00
commit 01ae4a09ff
10 changed files with 568 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*$py.class
.venv
venv/
ENV/
env/
.env
data/*.db
*.sqlite3
.vscode/
.idea/
.DS_Store

60
backend/auth.py Normal file
View File

@@ -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

21
backend/database.py Normal file
View File

@@ -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

167
backend/main.py Normal file
View File

@@ -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

93
backend/models.py Normal file
View File

@@ -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

38
frontend/api_client.py Normal file
View File

@@ -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()

141
frontend/app.py Normal file
View File

@@ -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)

View File

View File

@@ -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'))

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
fastapi
uvicorn
sqlmodel
psycopg2-binary
python-jose[cryptography]
passlib[bcrypt]
python-multipart
nicegui
httpx
python-dotenv
pytest