Initial commit: Ballet Production Suite ERP/CRM foundation
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal 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
60
backend/auth.py
Normal 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
21
backend/database.py
Normal 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
167
backend/main.py
Normal 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
93
backend/models.py
Normal 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
38
frontend/api_client.py
Normal 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
141
frontend/app.py
Normal 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)
|
||||||
0
frontend/pages/__init__.py
Normal file
0
frontend/pages/__init__.py
Normal file
24
frontend/pages/logistics.py
Normal file
24
frontend/pages/logistics.py
Normal 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
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
sqlmodel
|
||||||
|
psycopg2-binary
|
||||||
|
python-jose[cryptography]
|
||||||
|
passlib[bcrypt]
|
||||||
|
python-multipart
|
||||||
|
nicegui
|
||||||
|
httpx
|
||||||
|
python-dotenv
|
||||||
|
pytest
|
||||||
Reference in New Issue
Block a user