Compare commits

..

No commits in common. "ai-dev" and "master" have entirely different histories.

30 changed files with 214 additions and 1834 deletions

View File

@ -10,7 +10,6 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
from datetime import timedelta
from pathlib import Path
import os
from dotenv import load_dotenv
@ -24,7 +23,6 @@ DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
ALLOWED_HOSTS = [
"127.0.0.1",
"localhost",
"testserver",
os.getenv("HOST_FQDN", ""),
]
@ -39,11 +37,17 @@ CSRF_TRUSTED_ORIGINS = [
for host in CSRF_TRUSTED_ORIGINS
]
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SAMESITE = "None"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
@ -51,7 +55,6 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'core',
]
@ -62,6 +65,8 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
X_FRAME_OPTIONS = 'ALLOWALL'
@ -78,6 +83,7 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# IMPORTANT: do not remove injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
'core.context_processors.project_context',
],
},
@ -86,6 +92,10 @@ TEMPLATES = [
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
@ -100,6 +110,10 @@ DATABASES = {
},
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -115,23 +129,33 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
LANGUAGE_CODE = 'es-mx'
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
directory
for directory in [
BASE_DIR / 'static',
BASE_DIR / 'assets',
BASE_DIR / 'node_modules',
]
if directory.exists()
BASE_DIR / 'static',
BASE_DIR / 'assets',
BASE_DIR / 'node_modules',
]
# Email
EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend"
@ -149,29 +173,10 @@ CONTACT_EMAIL_TO = [
if item.strip()
]
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL:
EMAIL_USE_TLS = False
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/login/'
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': False,
'UPDATE_LAST_LOGIN': True,
}
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

Binary file not shown.

View File

@ -1,37 +1,3 @@
from django.contrib import admin
from .models import Order, OrderItem, Product, Table, UserRole
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
@admin.register(UserRole)
class UserRoleAdmin(admin.ModelAdmin):
list_display = ("user", "role", "updated_at")
list_filter = ("role",)
search_fields = ("user__username", "user__email", "user__first_name", "user__last_name")
@admin.register(Table)
class TableAdmin(admin.ModelAdmin):
list_display = ("name", "status", "seats", "area", "updated_at")
list_filter = ("status", "area")
search_fields = ("name",)
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "category", "price", "station", "is_available")
list_filter = ("category", "station", "is_available")
search_fields = ("name",)
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "table", "status", "created_at", "paid_at")
list_filter = ("status", "created_at")
search_fields = ("table__name", "guest_name")
inlines = [OrderItemInline]
# Register your models here.

View File

@ -1,22 +0,0 @@
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import UserRole
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def current_user_api(request):
return Response(
{
"id": request.user.id,
"username": request.user.username,
"email": request.user.email,
"first_name": request.user.first_name,
"last_name": request.user.last_name,
"role": UserRole.resolve_for(request.user),
"role_label": UserRole.label_for(request.user),
"is_superuser": request.user.is_superuser,
}
)

View File

@ -1,46 +0,0 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from .models import Order, OrderItem, Product
class LoginForm(AuthenticationForm):
username = forms.CharField(
label="Usuario",
widget=forms.TextInput(attrs={"class": "form-control form-control-lg", "placeholder": "Tu usuario"}),
)
password = forms.CharField(
label="Contraseña",
strip=False,
widget=forms.PasswordInput(attrs={"class": "form-control form-control-lg", "placeholder": "••••••••"}),
)
class AddOrderItemForm(forms.ModelForm):
class Meta:
model = OrderItem
fields = ["product", "quantity", "note"]
widgets = {
"product": forms.Select(attrs={"class": "form-select form-select-lg"}),
"quantity": forms.NumberInput(attrs={"class": "form-control form-control-lg", "min": 1}),
"note": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Ej. sin cebolla, extra salsa, término medio"}),
}
labels = {
"product": "Producto",
"quantity": "Cantidad",
"note": "Nota para cocina",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["product"].queryset = Product.objects.filter(is_available=True)
self.fields["quantity"].initial = 1
class OrderStatusForm(forms.Form):
status = forms.ChoiceField(widget=forms.HiddenInput())
def __init__(self, *args, order=None, **kwargs):
super().__init__(*args, **kwargs)
choices = [(status, dict(Order.Status.choices)[status]) for status in (order.allowed_transitions() if order else [])]
self.fields["status"].choices = choices

View File

@ -1,78 +0,0 @@
# Generated by Django 5.2.7 on 2026-04-26 16:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('open', 'Abierta'), ('preparing', 'En preparación'), ('ready', 'Lista'), ('paid', 'Pagada')], default='open', max_length=20)),
('guest_name', models.CharField(blank=True, max_length=120)),
('server_note', models.CharField(blank=True, max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('sent_to_kitchen_at', models.DateTimeField(blank=True, null=True)),
('paid_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120)),
('category', models.CharField(max_length=80)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('is_available', models.BooleanField(default=True)),
('station', models.CharField(default='Cocina', max_length=80)),
],
options={
'ordering': ['category', 'name'],
},
),
migrations.CreateModel(
name='Table',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=40, unique=True)),
('seats', models.PositiveSmallIntegerField(default=4)),
('status', models.CharField(choices=[('free', 'Libre'), ('occupied', 'Ocupada')], default='free', max_length=12)),
('area', models.CharField(blank=True, max_length=50)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='OrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('note', models.CharField(blank=True, max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.order')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='core.product')),
],
options={
'ordering': ['created_at', 'id'],
},
),
migrations.AddField(
model_name='order',
name='table',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='core.table'),
),
]

View File

@ -1,60 +0,0 @@
from decimal import Decimal
from django.db import migrations
def seed_demo_pos(apps, schema_editor):
Table = apps.get_model("core", "Table")
Product = apps.get_model("core", "Product")
tables = [
("Mesa 1", 2, "Terraza"),
("Mesa 2", 4, "Salón"),
("Mesa 3", 4, "Salón"),
("Mesa 4", 6, "Ventana"),
("Mesa 5", 2, "Barra"),
("Mesa 6", 4, "Terraza"),
("Mesa 7", 4, "Privado"),
("Mesa 8", 6, "Salón"),
]
for name, seats, area in tables:
Table.objects.get_or_create(name=name, defaults={"seats": seats, "area": area})
products = [
("Burger clásica", "Platos", Decimal("12.50"), "Cocina"),
("Pasta de trufa", "Platos", Decimal("15.00"), "Cocina"),
("Ensalada César", "Entradas", Decimal("9.50"), "Cocina"),
("Papas bravas", "Entradas", Decimal("7.00"), "Cocina"),
("Limonada artesanal", "Bebidas", Decimal("4.50"), "Bar"),
("Té helado", "Bebidas", Decimal("3.80"), "Bar"),
]
for name, category, price, station in products:
Product.objects.get_or_create(
name=name,
defaults={"category": category, "price": price, "station": station, "is_available": True},
)
def remove_demo_pos(apps, schema_editor):
Table = apps.get_model("core", "Table")
Product = apps.get_model("core", "Product")
Table.objects.filter(name__in=[f"Mesa {idx}" for idx in range(1, 9)]).delete()
Product.objects.filter(name__in=[
"Burger clásica",
"Pasta de trufa",
"Ensalada César",
"Papas bravas",
"Limonada artesanal",
"Té helado",
]).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.RunPython(seed_demo_pos, remove_demo_pos),
]

View File

@ -1,30 +0,0 @@
# Generated by Django 5.2.7 on 2026-04-26 16:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_seed_demo_pos'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('admin', 'Admin'), ('waiter', 'Mesero'), ('kitchen', 'Cocina')], default='waiter', max_length=20)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='role_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Rol de usuario',
'verbose_name_plural': 'Roles de usuario',
'ordering': ['user__username'],
},
),
]

View File

@ -1,162 +1,3 @@
from decimal import Decimal
from django.conf import settings
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
class UserRole(models.Model):
class Role(models.TextChoices):
ADMIN = "admin", "Admin"
WAITER = "waiter", "Mesero"
KITCHEN = "kitchen", "Cocina"
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="role_profile")
role = models.CharField(max_length=20, choices=Role.choices, default=Role.WAITER)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["user__username"]
verbose_name = "Rol de usuario"
verbose_name_plural = "Roles de usuario"
def __str__(self):
return f"{self.user.username} · {self.get_role_display()}"
@classmethod
def resolve_for(cls, user):
if not user or not user.is_authenticated:
return None
if user.is_superuser:
return cls.Role.ADMIN
profile, _ = cls.objects.get_or_create(user=user, defaults={"role": cls.Role.WAITER})
return profile.role
@classmethod
def label_for(cls, user):
role = cls.resolve_for(user)
return dict(cls.Role.choices).get(role, "Sin rol") if role else "Invitado"
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def ensure_user_role(sender, instance, created, **kwargs):
if created and not instance.is_superuser:
UserRole.objects.get_or_create(user=instance, defaults={"role": UserRole.Role.WAITER})
class Table(models.Model):
class Status(models.TextChoices):
FREE = "free", "Libre"
OCCUPIED = "occupied", "Ocupada"
name = models.CharField(max_length=40, unique=True)
seats = models.PositiveSmallIntegerField(default=4)
status = models.CharField(max_length=12, choices=Status.choices, default=Status.FREE)
area = models.CharField(max_length=50, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
@property
def current_order(self):
cached_orders = getattr(self, "open_orders_cache", None)
if cached_orders is not None:
return cached_orders[0] if cached_orders else None
return self.orders.exclude(status=Order.Status.PAID).order_by("-created_at").first()
class Product(models.Model):
name = models.CharField(max_length=120)
category = models.CharField(max_length=80)
price = models.DecimalField(max_digits=10, decimal_places=2)
is_available = models.BooleanField(default=True)
station = models.CharField(max_length=80, default="Cocina")
class Meta:
ordering = ["category", "name"]
def __str__(self):
return self.name
class Order(models.Model):
class Status(models.TextChoices):
OPEN = "open", "Abierta"
PREPARING = "preparing", "En preparación"
READY = "ready", "Lista"
PAID = "paid", "Pagada"
table = models.ForeignKey(Table, on_delete=models.PROTECT, related_name="orders")
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN)
guest_name = models.CharField(max_length=120, blank=True)
server_note = models.CharField(max_length=200, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
sent_to_kitchen_at = models.DateTimeField(null=True, blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"Orden #{self.pk} · {self.table.name}"
@property
def subtotal(self):
if hasattr(self, "subtotal_value") and self.subtotal_value is not None:
return self.subtotal_value
total = sum((item.line_total for item in self.items.select_related("product").all()), Decimal("0.00"))
return total.quantize(Decimal("0.01"))
@property
def total_items(self):
if hasattr(self, "items_count") and self.items_count is not None:
return self.items_count
return sum(item.quantity for item in self.items.all())
def allowed_transitions(self):
transitions = {
self.Status.OPEN: [self.Status.PREPARING],
self.Status.PREPARING: [self.Status.READY],
self.Status.READY: [self.Status.PAID],
self.Status.PAID: [],
}
return transitions.get(self.status, [])
def advance_to(self, new_status):
if new_status not in self.allowed_transitions():
raise ValueError("Invalid status transition")
now = timezone.now()
self.status = new_status
if new_status == self.Status.PREPARING and not self.sent_to_kitchen_at:
self.sent_to_kitchen_at = now
if new_status == self.Status.PAID:
self.paid_at = now
self.table.status = Table.Status.FREE
self.table.save(update_fields=["status", "updated_at"])
self.save(update_fields=["status", "sent_to_kitchen_at", "paid_at", "updated_at"])
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="order_items")
quantity = models.PositiveIntegerField(default=1)
note = models.CharField(max_length=200, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at", "id"]
def __str__(self):
return f"{self.quantity} x {self.product.name}"
@property
def line_total(self):
return (self.product.price * self.quantity).quantize(Decimal("0.01"))
# Create your models here.

View File

@ -1,72 +1,25 @@
{% load static %}
<!DOCTYPE html>
<html lang="es">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ meta_title|default:"Restaurante POS" }}{% endblock %}</title>
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'Gestión visual de mesas, comandas y cocina para restaurantes.' }}{% endblock %}">
<title>{% block title %}Knowledge Base{% endblock %}</title>
{% if project_description %}
<meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
{% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}">
{% endif %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
{% load static %}
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %}
</head>
<body class="pos-app">
<div class="page-shell">
<header class="site-header sticky-top">
<nav class="navbar navbar-expand-lg">
<div class="container-xxl align-items-center">
<a class="navbar-brand" href="{% url 'home' %}">
<span class="brand-mark">RP</span>
<span>
<strong>Restaurante POS</strong>
<small>Operación en tiempo real</small>
</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Abrir navegación">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
{% if request.user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'kitchen_board' %}">KDS Cocina</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
<li class="nav-item">
<span class="nav-link disabled">{{ request.user.username }}{% if resolved_user_role %} · {{ resolved_user_role }}{% endif %}</span>
</li>
<li class="nav-item">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-dark rounded-pill px-3">Salir</button>
</form>
</li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Iniciar sesión</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
</header>
{% if messages %}
<div class="container-xxl mt-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} pos-alert" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<body>
{% block content %}{% endblock %}
</body>
</html>

View File

@ -1,190 +1,145 @@
{% extends "base.html" %}
{% block title %}{{ meta_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block title %}{{ project_name }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block content %}
<main>
<section class="hero-section">
<div class="container-xxl hero-grid">
<div class="hero-copy">
<span class="eyebrow">Primera iteración operativa</span>
<h1>Controla mesas, comandas y cocina con un dashboard táctil y claro.</h1>
<p class="hero-text">Un punto de partida usable para meseros y cocina: abre una mesa, agrega productos con notas, sigue el estado de la orden y cobra cuando esté lista.</p>
<div class="hero-actions">
<a href="#tables-grid" class="btn btn-pos-primary btn-lg">Ver mesas</a>
<a href="{% url 'kitchen_board' %}" class="btn btn-pos-soft btn-lg">Abrir KDS</a>
</div>
<div class="hero-kpis row g-3">
<div class="col-sm-4">
<div class="metric-card">
<span>Mesas ocupadas</span>
<strong>{{ occupied_tables }}</strong>
</div>
</div>
<div class="col-sm-4">
<div class="metric-card">
<span>Productos activos</span>
<strong>{{ available_products }}</strong>
</div>
</div>
<div class="col-sm-4">
<div class="metric-card">
<span>Ventas de hoy</span>
<strong>${{ daily_revenue|floatformat:2 }}</strong>
</div>
</div>
</div>
</div>
<div class="hero-visual">
<div class="floating-card primary-card">
<span class="floating-label">Orden activa</span>
<strong>Mesa 4</strong>
<p>2x Pasta trufa · 1x Limonada</p>
<div class="status-pill status-ready">Lista para cobrar</div>
</div>
<div class="floating-card secondary-card">
<span class="floating-label">Cocina</span>
<strong>{{ kitchen_orders|length }} órdenes</strong>
<p>Vista rápida para preparación</p>
</div>
<div class="shape shape-sphere"></div>
<div class="shape shape-cube"></div>
</div>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
</section>
<section class="dashboard-section">
<div class="container-xxl">
<div class="section-heading">
<div>
<span class="eyebrow">Flujo principal</span>
<h2 id="tables-grid">Dashboard de mesas</h2>
</div>
<p>Toca una mesa para registrar productos, notas para cocina y revisar la comanda activa.</p>
</div>
<div class="row g-4">
<div class="col-xl-8">
<div class="table-grid">
{% for table in tables %}
<a href="{% url 'table_detail' table.id %}" class="table-card {% if table.status == 'occupied' %}is-occupied{% else %}is-free{% endif %}">
<div class="table-card-top">
<div>
<span class="table-label">{{ table.name }}</span>
<h3>{{ table.seats }} puestos</h3>
</div>
<span class="status-pill {% if table.status == 'occupied' %}status-busy{% else %}status-free{% endif %}">{{ table.get_status_display }}</span>
</div>
{% if table.current_order %}
<div class="table-order-preview">
<strong>Orden #{{ table.current_order.id }}</strong>
<p>{{ table.current_order.total_items }} ítems · ${{ table.current_order.subtotal|floatformat:2 }}</p>
</div>
{% else %}
<div class="empty-mini">Lista para recibir una nueva comanda.</div>
{% endif %}
</a>
{% empty %}
<div class="empty-state-card">
<h3>No hay mesas configuradas</h3>
<p>Agrega mesas desde el admin para empezar a tomar comandas.</p>
<a href="/admin/" class="btn btn-pos-primary">Ir al admin</a>
</div>
{% endfor %}
</div>
</div>
<div class="col-xl-4">
<div class="side-panel-card">
<div class="section-heading compact">
<div>
<span class="eyebrow">Ventas rápidas</span>
<h2>Top productos</h2>
</div>
</div>
{% if top_products %}
<div class="stack-list">
{% for product in top_products %}
<div class="stack-item">
<div>
<strong>{{ product.name }}</strong>
<p>{{ product.category }}</p>
</div>
<span>{{ product.sold }} vendidos</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-mini">Todavía no hay ventas pagadas para mostrar tendencias.</div>
{% endif %}
</div>
</div>
</div>
</div>
</section>
<section class="dashboard-section section-muted">
<div class="container-xxl">
<div class="row g-4">
<div class="col-lg-7">
<div class="side-panel-card h-100">
<div class="section-heading compact">
<div>
<span class="eyebrow">Seguimiento</span>
<h2>Órdenes recientes</h2>
</div>
</div>
{% if recent_orders %}
<div class="stack-list">
{% for order in recent_orders %}
<a href="{% url 'order_detail' order.id %}" class="stack-item stack-item-link">
<div>
<strong>Orden #{{ order.id }} · {{ order.table.name }}</strong>
<p>{{ order.total_items }} ítems · {{ order.get_status_display }}</p>
</div>
<span>${{ order.subtotal|floatformat:2 }}</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-mini">Aún no hay órdenes. Abre una mesa y agrega el primer producto.</div>
{% endif %}
</div>
</div>
<div class="col-lg-5">
<div class="side-panel-card h-100">
<div class="section-heading compact">
<div>
<span class="eyebrow">Cocina</span>
<h2>Vista previa KDS</h2>
</div>
<a href="{% url 'kitchen_board' %}" class="text-link">Pantalla completa</a>
</div>
{% if kitchen_orders %}
<div class="kitchen-preview-list">
{% for order in kitchen_orders %}
<div class="kitchen-ticket">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<strong>#{{ order.id }} · {{ order.table.name }}</strong>
<p>{{ order.get_status_display }}</p>
</div>
<span class="status-pill {% if order.status == 'open' %}status-free{% else %}status-busy{% endif %}">{{ order.get_status_display }}</span>
</div>
<ul>
{% for item in order.items.all %}
<li>{{ item.quantity }} × {{ item.product.name }}{% if item.note %} — {{ item.note }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-mini">La cocina está al día. No hay comandas pendientes.</div>
{% endif %}
</div>
</div>
</div>
</div>
</section>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div>
</main>
{% endblock %}
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}

View File

@ -1,52 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ meta_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<main class="content-section">
<div class="container-xxl">
<div class="page-intro">
<div>
<span class="eyebrow">KDS</span>
<h1>Pantalla de cocina</h1>
<p>Órdenes pendientes y en preparación, listas para seguir en prioridad visual.</p>
</div>
<div class="page-actions">
<a href="{% url 'home' %}" class="btn btn-pos-soft">Volver al dashboard</a>
</div>
</div>
{% if orders %}
<div class="kds-grid">
{% for order in orders %}
<article class="kds-card">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<span class="eyebrow">{{ order.table.name }}</span>
<h2>Orden #{{ order.id }}</h2>
</div>
<span class="status-pill {% if order.status == 'preparing' %}status-busy{% else %}status-free{% endif %}">{{ order.get_status_display }}</span>
</div>
<ul class="kds-items">
{% for item in order.items.all %}
<li>
<strong>{{ item.quantity }} × {{ item.product.name }}</strong>
<p>{% if item.note %}{{ item.note }}{% else %}Sin nota adicional{% endif %}</p>
</li>
{% endfor %}
</ul>
<a href="{% url 'order_detail' order.id %}" class="btn btn-pos-primary w-100">Abrir detalle</a>
</article>
{% endfor %}
</div>
{% else %}
<div class="empty-state-card centered">
<h3>No hay órdenes pendientes</h3>
<p>Cuando un mesero envíe una comanda, aparecerá aquí automáticamente.</p>
<a href="{% url 'home' %}" class="btn btn-pos-primary">Volver al dashboard</a>
</div>
{% endif %}
</div>
</main>
{% endblock %}

View File

@ -1,91 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ meta_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<main class="content-section">
<div class="container-xxl">
<div class="page-intro">
<div>
<span class="eyebrow">Orden / Facturación</span>
<h1>Orden #{{ order.id }}</h1>
<p>{{ order.table.name }} · {{ order.get_status_display }} · {{ order.created_at|date:"d M Y · H:i" }}</p>
</div>
<div class="page-actions">
<a href="{% url 'table_detail' order.table.id %}" class="btn btn-pos-soft">Volver a la mesa</a>
</div>
</div>
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<section class="panel-card">
<div class="section-heading compact">
<div>
<span class="eyebrow">Detalle</span>
<h2>Recibo en vivo</h2>
</div>
<span class="status-pill {% if order.status == 'paid' %}status-ready{% elif order.status == 'ready' %}status-ready{% elif order.status == 'preparing' %}status-busy{% else %}status-free{% endif %}">{{ order.get_status_display }}</span>
</div>
<div class="receipt-lines">
{% for item in order.items.all %}
<div class="receipt-line">
<div>
<strong>{{ item.quantity }} × {{ item.product.name }}</strong>
<p>{% if item.note %}{{ item.note }}{% else %}Preparación estándar{% endif %}</p>
</div>
<span>${{ item.line_total|floatformat:2 }}</span>
</div>
{% endfor %}
</div>
<div class="receipt-footer large">
<div>
<span>Total</span>
<strong>${{ order.subtotal|floatformat:2 }}</strong>
</div>
<div>
<span>Ítems</span>
<strong>{{ order.total_items }}</strong>
</div>
</div>
</section>
</div>
<div class="col-lg-5">
<section class="panel-card">
<div class="section-heading compact">
<div>
<span class="eyebrow">Siguiente acción</span>
<h2>Flujo operativo</h2>
</div>
</div>
<div class="workflow-steps">
<div class="workflow-step {% if order.status == 'open' %}active{% endif %}">Abierta</div>
<div class="workflow-step {% if order.status == 'preparing' %}active{% endif %}">En preparación</div>
<div class="workflow-step {% if order.status == 'ready' %}active{% endif %}">Lista</div>
<div class="workflow-step {% if order.status == 'paid' %}active{% endif %}">Pagada</div>
</div>
{% if status_forms %}
<div class="action-stack">
{% for status, form in status_forms.items %}
<form method="post">
{% csrf_token %}
{{ form.status }}
<button type="submit" class="btn btn-pos-primary btn-lg w-100">
{% if status == 'preparing' %}Enviar a cocina{% elif status == 'ready' %}Marcar lista{% elif status == 'paid' %}Cobrar y cerrar mesa{% else %}Actualizar{% endif %}
</button>
</form>
{% endfor %}
</div>
{% else %}
<div class="empty-state-card small paid-state">
<h3>Cuenta cerrada</h3>
<p>La orden ya fue cobrada y la mesa quedó disponible para un nuevo servicio.</p>
<a href="{% url 'home' %}" class="btn btn-pos-soft">Volver al dashboard</a>
</div>
{% endif %}
</section>
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -1,88 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ meta_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<main class="content-section">
<div class="container-xxl">
<div class="page-intro">
<div>
<span class="eyebrow">Mesero / Comanda</span>
<h1>{{ table.name }}</h1>
<p>{{ table.seats }} puestos · Estado actual: {{ table.get_status_display }}</p>
</div>
<div class="page-actions">
<a href="{% url 'home' %}" class="btn btn-pos-soft">Volver al dashboard</a>
{% if current_order %}
<a href="{% url 'order_detail' current_order.id %}" class="btn btn-pos-primary">Ver recibo actual</a>
{% endif %}
</div>
</div>
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<section class="panel-card">
<div class="section-heading compact">
<div>
<span class="eyebrow">Agregar ítems</span>
<h2>Nueva línea de comanda</h2>
</div>
</div>
<form method="post" novalidate class="pos-form">
{% csrf_token %}
{% for field in form %}
<div class="mb-3">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="invalid-feedback d-block">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-pos-primary btn-lg w-100">Agregar a la mesa</button>
</form>
</section>
</div>
<div class="col-lg-7">
<section class="panel-card">
<div class="section-heading compact">
<div>
<span class="eyebrow">Orden activa</span>
<h2>{% if current_order %}Comanda #{{ current_order.id }}{% else %}Sin comanda todavía{% endif %}</h2>
</div>
{% if current_order %}
<span class="status-pill {% if current_order.status == 'ready' %}status-ready{% elif current_order.status == 'preparing' %}status-busy{% else %}status-free{% endif %}">{{ current_order.get_status_display }}</span>
{% endif %}
</div>
{% if current_order %}
<div class="receipt-lines">
{% for item in current_order.items.all %}
<div class="receipt-line">
<div>
<strong>{{ item.quantity }} × {{ item.product.name }}</strong>
<p>{% if item.note %}{{ item.note }}{% else %}Sin nota adicional{% endif %}</p>
</div>
<span>${{ item.line_total|floatformat:2 }}</span>
</div>
{% endfor %}
</div>
<div class="receipt-footer">
<div>
<span>Total actual</span>
<strong>${{ current_order.subtotal|floatformat:2 }}</strong>
</div>
<a href="{% url 'order_detail' current_order.id %}" class="btn btn-pos-soft">Gestionar estado</a>
</div>
{% else %}
<div class="empty-state-card small">
<h3>Mesa lista para abrir</h3>
<p>Agrega el primer producto para crear automáticamente la comanda activa.</p>
</div>
{% endif %}
</section>
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -1,42 +0,0 @@
{% extends "base.html" %}
{% block title %}Acceso POS | Restaurante{% endblock %}
{% block meta_description %}Acceso seguro para Admin, Mesero y Cocina dentro del POS del restaurante.{% endblock %}
{% block content %}
<main class="container-xxl py-5">
<div class="row justify-content-center">
<div class="col-lg-5 col-xl-4">
<section class="card border-0 shadow-lg rounded-4 p-4 p-md-5 bg-white">
<span class="text-uppercase fw-semibold small text-muted">Acceso seguro</span>
<h1 class="mt-2 mb-3" style="font-family: Manrope, sans-serif;">Iniciar sesión</h1>
<p class="text-secondary mb-4">Usa tu cuenta para entrar como Admin, Mesero o Cocina.</p>
<form method="post" novalidate>
{% csrf_token %}
<div class="mb-3">
<label class="form-label" for="id_username">{{ form.username.label }}</label>
{{ form.username }}
</div>
<div class="mb-3">
<label class="form-label" for="id_password">{{ form.password.label }}</label>
{{ form.password }}
</div>
{% if form.errors %}
<div class="alert alert-danger">Usuario o contraseña incorrectos.</div>
{% endif %}
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}
<button type="submit" class="btn btn-dark w-100 btn-lg rounded-4">Entrar al POS</button>
</form>
<div class="mt-4 pt-3 border-top text-secondary small">
<div><strong>JWT API:</strong> <code>/api/auth/token/</code></div>
<div><strong>Perfil actual:</strong> <code>/api/auth/me/</code></div>
</div>
</section>
</div>
</div>
</main>
{% endblock %}

View File

@ -1,82 +1,3 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from .models import Order, Product, Table, UserRole
class PosWorkflowTests(TestCase):
def setUp(self):
self.table = Table.objects.create(name="Mesa 1", seats=4)
self.product = Product.objects.create(name="Burger clásica", category="Cocina", price="12.50")
self.waiter = User.objects.create_user(username="mesero", password="clave12345")
UserRole.objects.filter(user=self.waiter).update(role=UserRole.Role.WAITER)
self.kitchen = User.objects.create_user(username="cocina", password="clave12345")
UserRole.objects.filter(user=self.kitchen).update(role=UserRole.Role.KITCHEN)
def test_home_requires_login(self):
response = self.client.get(reverse("home"))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("login"), response.url)
def test_add_item_creates_active_order_and_marks_table_occupied(self):
self.client.login(username="mesero", password="clave12345")
response = self.client.post(
reverse("table_detail", args=[self.table.pk]),
{"product": self.product.pk, "quantity": 2, "note": "sin cebolla"},
follow=True,
)
self.table.refresh_from_db()
order = Order.objects.get(table=self.table)
self.assertEqual(response.status_code, 200)
self.assertEqual(order.status, Order.Status.OPEN)
self.assertEqual(order.items.count(), 1)
self.assertEqual(self.table.status, Table.Status.OCCUPIED)
def test_mark_paid_frees_table(self):
self.client.login(username="mesero", password="clave12345")
order = Order.objects.create(table=self.table, status=Order.Status.READY)
self.table.status = Table.Status.OCCUPIED
self.table.save(update_fields=["status", "updated_at"])
response = self.client.post(
reverse("order_detail", args=[order.pk]),
{"status": Order.Status.PAID},
follow=True,
)
order.refresh_from_db()
self.table.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(order.status, Order.Status.PAID)
self.assertEqual(self.table.status, Table.Status.FREE)
def test_kitchen_screen_blocks_waiter_role(self):
self.client.login(username="mesero", password="clave12345")
response = self.client.get(reverse("kitchen_board"), follow=True)
self.assertEqual(response.status_code, 200)
self.assertRedirects(response, reverse("home"))
def test_kitchen_role_can_open_kds(self):
self.client.login(username="cocina", password="clave12345")
response = self.client.get(reverse("kitchen_board"))
self.assertEqual(response.status_code, 200)
def test_jwt_login_and_me_endpoint(self):
api_client = APIClient()
token_response = api_client.post(
reverse("token_obtain_pair"),
{"username": "mesero", "password": "clave12345"},
format="json",
)
self.assertEqual(token_response.status_code, 200)
self.assertIn("access", token_response.data)
api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {token_response.data['access']}")
me_response = api_client.get(reverse("current_user_api"))
self.assertEqual(me_response.status_code, 200)
self.assertEqual(me_response.data["role"], UserRole.Role.WAITER)
# Create your tests here.

View File

@ -1,27 +1,7 @@
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .api import current_user_api
from .forms import LoginForm
from .views import home, kitchen_board, order_detail, table_detail
from .views import home
urlpatterns = [
path("", home, name="home"),
path(
"login/",
LoginView.as_view(
template_name="registration/login.html",
authentication_form=LoginForm,
redirect_authenticated_user=True,
),
name="login",
),
path("logout/", LogoutView.as_view(), name="logout"),
path("kitchen/", kitchen_board, name="kitchen_board"),
path("tables/<int:table_id>/", table_detail, name="table_detail"),
path("orders/<int:order_id>/", order_detail, name="order_detail"),
path("api/auth/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("api/auth/me/", current_user_api, name="current_user_api"),
]

View File

@ -1,162 +1,25 @@
from functools import wraps
from urllib.parse import quote
import os
import platform
from django.contrib import messages
from django.db import transaction
from django.db.models import DecimalField, ExpressionWrapper, F, Prefetch, Sum
from django.shortcuts import get_object_or_404, redirect, render
from django import get_version as django_version
from django.shortcuts import render
from django.utils import timezone
from .forms import AddOrderItemForm, OrderStatusForm
from .models import Order, Product, Table, UserRole
LINE_TOTAL = ExpressionWrapper(
F("items__quantity") * F("items__product__price"),
output_field=DecimalField(max_digits=10, decimal_places=2),
)
def role_required(*allowed_roles):
def decorator(view_func):
@wraps(view_func)
def wrapped(request, *args, **kwargs):
if not request.user.is_authenticated:
return redirect(f"/login/?next={quote(request.get_full_path())}")
resolved_role = UserRole.resolve_for(request.user)
if allowed_roles and resolved_role not in allowed_roles:
messages.error(request, "Tu rol no tiene acceso a esta sección.")
return redirect("home")
return view_func(request, *args, **kwargs)
return wrapped
return decorator
def _dashboard_context():
active_orders = Order.objects.exclude(status=Order.Status.PAID).prefetch_related("items__product").order_by("-created_at")
tables = Table.objects.prefetch_related(
Prefetch("orders", queryset=active_orders, to_attr="open_orders_cache")
)
recent_orders = Order.objects.select_related("table").prefetch_related("items__product")[:6]
kitchen_orders = (
Order.objects.filter(status__in=[Order.Status.OPEN, Order.Status.PREPARING])
.select_related("table")
.prefetch_related("items__product")[:5]
)
daily_revenue = (
Order.objects.filter(status=Order.Status.PAID, paid_at__date=timezone.localdate())
.aggregate(total=Sum(LINE_TOTAL))["total"]
or 0
)
top_products = (
Product.objects.filter(order_items__order__status=Order.Status.PAID)
.annotate(sold=Sum("order_items__quantity"))
.order_by("-sold", "name")[:4]
)
return {
"tables": tables,
"recent_orders": recent_orders,
"kitchen_orders": kitchen_orders,
"available_products": Product.objects.filter(is_available=True).count(),
"occupied_tables": Table.objects.filter(status=Table.Status.OCCUPIED).count(),
"daily_revenue": daily_revenue,
"top_products": top_products,
"meta_title": "Restaurante POS | Operación de mesas, cocina y cobro",
"meta_description": "Dashboard táctil para controlar mesas, comandas, cocina y cobro desde una sola interfaz ligera.",
}
@role_required(UserRole.Role.ADMIN, UserRole.Role.WAITER, UserRole.Role.KITCHEN)
def home(request):
context = _dashboard_context()
context["resolved_user_role"] = UserRole.label_for(request.user)
"""Render the landing screen with loader and environment details."""
host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now()
context = {
"project_name": "New Style",
"agent_brand": agent_brand,
"django_version": django_version(),
"python_version": platform.python_version(),
"current_time": now,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
}
return render(request, "core/index.html", context)
@role_required(UserRole.Role.ADMIN, UserRole.Role.WAITER)
def table_detail(request, table_id):
table = get_object_or_404(
Table.objects.prefetch_related(
Prefetch(
"orders",
queryset=Order.objects.exclude(status=Order.Status.PAID).prefetch_related("items__product").order_by("-created_at"),
to_attr="open_orders_cache",
)
),
pk=table_id,
)
current_order = table.current_order
if request.method == "POST":
form = AddOrderItemForm(request.POST)
if form.is_valid():
with transaction.atomic():
if current_order is None:
current_order = Order.objects.create(table=table)
item = form.save(commit=False)
item.order = current_order
item.save()
if table.status != Table.Status.OCCUPIED:
table.status = Table.Status.OCCUPIED
table.save(update_fields=["status", "updated_at"])
messages.success(request, f"Se agregó {item.product.name} a {table.name}.")
return redirect("table_detail", table_id=table.pk)
else:
form = AddOrderItemForm()
context = {
"table": table,
"current_order": current_order,
"form": form,
"meta_title": f"{table.name} | Comanda activa",
"meta_description": f"Captura de comandas y notas para {table.name}.",
"resolved_user_role": UserRole.label_for(request.user),
}
return render(request, "core/table_detail.html", context)
@role_required(UserRole.Role.ADMIN, UserRole.Role.WAITER, UserRole.Role.KITCHEN)
def order_detail(request, order_id):
order = get_object_or_404(Order.objects.select_related("table").prefetch_related("items__product"), pk=order_id)
if request.method == "POST":
form = OrderStatusForm(request.POST, order=order)
if form.is_valid():
next_status = form.cleaned_data["status"]
with transaction.atomic():
order.advance_to(next_status)
messages.success(request, f"La orden #{order.pk} ahora está {order.get_status_display().lower()}.")
return redirect("order_detail", order_id=order.pk)
status_forms = {
status: OrderStatusForm(order=order, initial={"status": status})
for status in order.allowed_transitions()
}
context = {
"order": order,
"status_forms": status_forms,
"meta_title": f"Orden #{order.pk} | {order.table.name}",
"meta_description": f"Detalle de la orden #{order.pk} con total, ítems y estado operativo.",
"resolved_user_role": UserRole.label_for(request.user),
}
return render(request, "core/order_detail.html", context)
@role_required(UserRole.Role.ADMIN, UserRole.Role.KITCHEN)
def kitchen_board(request):
orders = (
Order.objects.filter(status__in=[Order.Status.OPEN, Order.Status.PREPARING])
.select_related("table")
.prefetch_related("items__product")
)
context = {
"orders": orders,
"meta_title": "KDS | Cocina pendiente",
"meta_description": "Pantalla de cocina con órdenes pendientes de preparación.",
"resolved_user_role": UserRole.label_for(request.user),
}
return render(request, "core/kitchen.html", context)

View File

@ -1,5 +1,3 @@
Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
djangorestframework==3.16.1
djangorestframework-simplejwt==5.5.1

View File

@ -1,597 +1,4 @@
/* Restaurant POS custom theme */
:root {
--pos-primary: #0f766e;
--pos-primary-dark: #0b5d57;
--pos-secondary: #16324f;
--pos-accent: #f97316;
--pos-accent-soft: #fff1e8;
--pos-surface: #ffffff;
--pos-surface-soft: #f5f8fb;
--pos-surface-muted: #eef4f7;
--pos-border: #d6e1e8;
--pos-text: #11243d;
--pos-muted: #61728a;
--pos-success: #15803d;
--pos-shadow: 0 26px 65px rgba(17, 36, 61, 0.12);
--pos-radius-xl: 32px;
--pos-radius-lg: 24px;
--pos-radius-md: 18px;
--pos-radius-sm: 12px;
}
html {
scroll-behavior: smooth;
}
body.pos-app {
margin: 0;
min-height: 100vh;
font-family: 'Inter', sans-serif;
color: var(--pos-text);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 32%),
radial-gradient(circle at 85% 10%, rgba(249, 115, 22, 0.14), transparent 25%),
linear-gradient(180deg, #fcfefd 0%, #f4f8fb 100%);
}
h1,
h2,
h3,
h4,
.navbar-brand strong {
font-family: 'Manrope', sans-serif;
letter-spacing: -0.03em;
}
p,
span,
a,
button,
label,
input,
select,
textarea {
font-family: 'Inter', sans-serif;
}
.page-shell {
position: relative;
overflow: hidden;
}
.site-header {
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(214, 225, 232, 0.72);
}
.navbar {
padding: 1rem 0;
}
.navbar-brand {
display: inline-flex;
align-items: center;
gap: 0.85rem;
color: var(--pos-text);
text-decoration: none;
}
.navbar-brand small {
display: block;
color: var(--pos-muted);
font-size: 0.8rem;
font-weight: 600;
}
.brand-mark {
width: 48px;
height: 48px;
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
color: #fff;
background: linear-gradient(135deg, var(--pos-primary) 0%, #15b39d 100%);
box-shadow: 0 16px 30px rgba(15, 118, 110, 0.28);
}
.nav-link {
color: var(--pos-muted);
font-weight: 600;
border-radius: 999px;
padding: 0.7rem 1rem !important;
}
.nav-link:hover,
.nav-link:focus {
color: var(--pos-secondary);
background: rgba(15, 118, 110, 0.08);
}
.navbar-toggler {
border: 1px solid var(--pos-border);
}
.pos-alert {
border-radius: var(--pos-radius-sm);
border: none;
box-shadow: var(--pos-shadow);
}
.hero-section,
.dashboard-section,
.content-section {
padding: 2.5rem 0 5rem;
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 2rem;
align-items: center;
}
.hero-copy {
padding: 2rem 0 1rem;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.9rem;
border-radius: 999px;
color: var(--pos-primary-dark);
background: rgba(15, 118, 110, 0.11);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero-copy h1,
.page-intro h1 {
font-size: clamp(2.8rem, 5vw, 4.7rem);
line-height: 0.97;
margin: 1.25rem 0 1rem;
}
.hero-text,
.page-intro p,
.section-heading p,
.empty-state-card p,
.stack-item p,
.receipt-line p,
.kds-card p,
.table-order-preview p,
.empty-mini,
.metric-card span {
color: var(--pos-muted);
}
.hero-text {
max-width: 56ch;
font-size: 1.05rem;
line-height: 1.7;
}
.hero-actions,
.page-actions,
.action-stack {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.hero-actions {
margin: 2rem 0;
}
.btn {
border-radius: 16px;
font-weight: 700;
padding: 0.88rem 1.35rem;
border: none;
}
.btn:focus-visible,
.form-control:focus,
.form-select:focus,
textarea:focus {
outline: 0;
box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.18);
}
.btn-pos-primary {
color: #fff;
background: linear-gradient(135deg, var(--pos-primary) 0%, #18a58f 100%);
box-shadow: 0 18px 32px rgba(15, 118, 110, 0.24);
}
.btn-pos-primary:hover,
.btn-pos-primary:focus {
color: #fff;
background: linear-gradient(135deg, var(--pos-primary-dark) 0%, #108d79 100%);
}
.btn-pos-soft {
color: var(--pos-secondary);
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(214, 225, 232, 0.95);
box-shadow: 0 14px 30px rgba(17, 36, 61, 0.08);
}
.btn-pos-soft:hover,
.btn-pos-soft:focus {
color: var(--pos-secondary);
background: #fff;
}
.hero-kpis {
max-width: 760px;
}
.metric-card,
.side-panel-card,
.panel-card,
.kds-card,
.empty-state-card,
.floating-card {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(214, 225, 232, 0.84);
box-shadow: var(--pos-shadow);
}
.metric-card {
height: 100%;
padding: 1.2rem 1.25rem;
border-radius: var(--pos-radius-md);
}
.metric-card strong {
display: block;
margin-top: 0.5rem;
font-size: 1.65rem;
}
.hero-visual {
position: relative;
min-height: 500px;
display: flex;
align-items: center;
justify-content: center;
}
.floating-card {
position: absolute;
width: min(100%, 320px);
padding: 1.5rem;
border-radius: 28px;
}
.primary-card {
top: 2rem;
right: 2rem;
}
.secondary-card {
left: 0;
bottom: 5rem;
}
.floating-card strong {
font-size: 1.6rem;
}
.floating-card p,
.stack-item p,
.kitchen-ticket p,
.receipt-line p,
.kds-items p {
margin: 0.4rem 0 0;
}
.floating-label,
.table-label,
.text-link {
color: var(--pos-accent);
font-weight: 700;
text-decoration: none;
}
.shape {
position: absolute;
border-radius: 28px;
filter: blur(0.2px);
}
.shape-sphere {
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #ffffff 0%, #a7f3d0 42%, rgba(15, 118, 110, 0.1) 72%, transparent 75%);
bottom: 0;
right: 18%;
}
.shape-cube {
width: 120px;
height: 120px;
background: linear-gradient(135deg, rgba(249, 115, 22, 0.16), rgba(22, 50, 79, 0.12));
transform: rotate(18deg);
top: 0;
left: 20%;
}
.section-muted {
background: linear-gradient(180deg, rgba(238, 244, 247, 0.55), rgba(255, 255, 255, 0));
}
.section-heading,
.page-intro {
display: flex;
justify-content: space-between;
align-items: end;
gap: 1rem;
margin-bottom: 1.75rem;
}
.section-heading.compact h2,
.panel-card h2,
.kds-card h2,
.table-card h3 {
margin: 0.45rem 0 0;
font-size: 1.45rem;
}
.table-grid,
.kds-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.15rem;
}
.table-card,
.stack-item-link {
text-decoration: none;
color: inherit;
}
.table-card {
padding: 1.3rem;
border-radius: 24px;
min-height: 190px;
border: 1px solid transparent;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 42px rgba(17, 36, 61, 0.08);
}
.table-card:hover,
.stack-item-link:hover,
.stack-item-link:focus {
transform: translateY(-3px);
box-shadow: 0 22px 46px rgba(17, 36, 61, 0.12);
}
.table-card.is-free {
border-color: rgba(21, 128, 61, 0.16);
background: linear-gradient(180deg, #ffffff 0%, #f5fff8 100%);
}
.table-card.is-occupied {
border-color: rgba(249, 115, 22, 0.18);
background: linear-gradient(180deg, #ffffff 0%, #fff8f3 100%);
}
.table-card-top,
.receipt-line,
.receipt-footer,
.stack-item,
.kitchen-ticket,
.workflow-steps {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.table-card-top {
align-items: start;
}
.table-order-preview,
.empty-mini {
margin-top: 2.5rem;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.85rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 800;
}
.status-free {
color: var(--pos-primary-dark);
background: rgba(15, 118, 110, 0.12);
}
.status-busy {
color: #b45309;
background: rgba(249, 115, 22, 0.14);
}
.status-ready {
color: var(--pos-success);
background: rgba(21, 128, 61, 0.13);
}
.side-panel-card,
.panel-card {
padding: 1.5rem;
border-radius: var(--pos-radius-lg);
height: 100%;
}
.stack-list,
.kitchen-preview-list,
.receipt-lines,
.kds-items {
display: grid;
gap: 0.95rem;
}
.stack-item,
.kitchen-ticket,
.receipt-line,
.workflow-step,
.kds-items li {
padding: 1rem 1.1rem;
border-radius: 18px;
background: var(--pos-surface-soft);
border: 1px solid rgba(214, 225, 232, 0.7);
}
.receipt-line,
.stack-item,
.kitchen-ticket {
align-items: center;
}
.receipt-line strong,
.stack-item strong,
.kds-items strong,
.kitchen-ticket strong {
font-size: 1.02rem;
}
.receipt-footer {
align-items: center;
margin-top: 1.2rem;
padding-top: 1.2rem;
border-top: 1px solid rgba(214, 225, 232, 0.8);
}
.receipt-footer.large strong {
font-size: 1.7rem;
}
.page-intro {
align-items: center;
margin-bottom: 2rem;
}
.panel-card .form-control,
.panel-card .form-select {
border-radius: 14px;
border-color: var(--pos-border);
padding: 0.95rem 1rem;
}
.form-label {
font-weight: 700;
color: var(--pos-secondary);
}
.workflow-steps {
flex-direction: column;
margin-bottom: 1.2rem;
}
.workflow-step {
font-weight: 700;
color: var(--pos-muted);
}
.workflow-step.active {
color: var(--pos-primary-dark);
background: rgba(15, 118, 110, 0.12);
}
.kds-items {
list-style: none;
padding: 0;
margin: 0 0 1rem;
}
.kds-card {
padding: 1.4rem;
border-radius: 28px;
}
.empty-state-card {
padding: 2rem;
border-radius: 28px;
}
.empty-state-card.small {
padding: 1.5rem;
}
.empty-state-card.centered {
text-align: center;
}
.paid-state {
background: linear-gradient(180deg, #ffffff 0%, #f4fff7 100%);
}
@media (max-width: 991px) {
.hero-grid,
.section-heading,
.page-intro {
grid-template-columns: 1fr;
display: grid;
align-items: start;
}
.hero-visual {
min-height: 360px;
margin-top: 1rem;
}
.primary-card {
right: 1rem;
}
}
@media (max-width: 767px) {
.hero-copy h1,
.page-intro h1 {
font-size: 2.5rem;
}
.hero-section,
.dashboard-section,
.content-section {
padding: 1.5rem 0 3rem;
}
.hero-actions,
.page-actions,
.action-stack {
flex-direction: column;
}
.primary-card,
.secondary-card {
position: relative;
inset: auto;
width: 100%;
}
.hero-visual {
display: grid;
gap: 1rem;
min-height: auto;
}
.shape {
display: none;
}
/* Custom styles for the application */
body {
font-family: system-ui, -apple-system, sans-serif;
}