Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
97a175ba97 Restaurnate_v1 2026-04-26 16:42:14 +00:00
30 changed files with 1833 additions and 213 deletions

View File

@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/ https://docs.djangoproject.com/en/5.2/ref/settings/
""" """
from datetime import timedelta
from pathlib import Path from pathlib import Path
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
@ -23,6 +24,7 @@ DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
"127.0.0.1", "127.0.0.1",
"localhost", "localhost",
"testserver",
os.getenv("HOST_FQDN", ""), os.getenv("HOST_FQDN", ""),
] ]
@ -37,17 +39,11 @@ CSRF_TRUSTED_ORIGINS = [
for host in 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 SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None" SESSION_COOKIE_SAMESITE = "None"
CSRF_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 = [ INSTALLED_APPS = [
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
@ -55,6 +51,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'rest_framework',
'core', 'core',
] ]
@ -65,8 +62,6 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
X_FRAME_OPTIONS = 'ALLOWALL' X_FRAME_OPTIONS = 'ALLOWALL'
@ -83,7 +78,6 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', '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', 'core.context_processors.project_context',
], ],
}, },
@ -92,10 +86,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'config.wsgi.application' WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.mysql', 'ENGINE': 'django.db.backends.mysql',
@ -110,10 +100,6 @@ DATABASES = {
}, },
} }
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -129,33 +115,23 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
LANGUAGE_CODE = 'es-mx'
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'static', directory
BASE_DIR / 'assets', for directory in [
BASE_DIR / 'node_modules', BASE_DIR / 'static',
BASE_DIR / 'assets',
BASE_DIR / 'node_modules',
]
if directory.exists()
] ]
# Email
EMAIL_BACKEND = os.getenv( EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND", "EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend" "django.core.mail.backends.smtp.EmailBackend"
@ -173,10 +149,29 @@ CONTACT_EMAIL_TO = [
if item.strip() if item.strip()
] ]
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL: if EMAIL_USE_SSL:
EMAIL_USE_TLS = False EMAIL_USE_TLS = False
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field 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_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,37 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. 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]

22
core/api.py Normal file
View File

@ -0,0 +1,22 @@
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,
}
)

46
core/forms.py Normal file
View File

@ -0,0 +1,46 @@
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

@ -0,0 +1,78 @@
# 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

@ -0,0 +1,60 @@
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

@ -0,0 +1,30 @@
# 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,3 +1,162 @@
from django.db import models from decimal import Decimal
# Create your models here. 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"))

View File

@ -1,25 +1,72 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1">
{% if project_description %} <title>{% block title %}{{ meta_title|default:"Restaurante POS" }}{% endblock %}</title>
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'Gestión visual de mesas, comandas y cocina para restaurantes.' }}{% endblock %}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
{% if project_image_url %} {% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}"> <meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}"> <meta property="twitter:image" content="{{ project_image_url }}">
{% endif %} {% endif %}
{% load static %} <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">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </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>
<body> {% if messages %}
{% block content %}{% endblock %} <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> </body>
</html> </html>

View File

@ -1,145 +1,190 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %} {% block title %}{{ meta_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% 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 %} {% block content %}
<main> <main>
<div class="card"> <section class="hero-section">
<h1>Analyzing your requirements and generating your app…</h1> <div class="container-xxl hero-grid">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="hero-copy">
<span class="sr-only">Loading…</span> <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> </div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p> </section>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime"> <section class="dashboard-section">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code> <div class="container-xxl">
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code> <div class="section-heading">
</p> <div>
</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>
</main> </main>
<footer> {% endblock %}
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}

View File

@ -0,0 +1,52 @@
{% 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

@ -0,0 +1,91 @@
{% 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

@ -0,0 +1,88 @@
{% 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

@ -0,0 +1,42 @@
{% 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,3 +1,82 @@
from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
# Create your tests here. 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)

View File

@ -1,7 +1,27 @@
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .views import home from .api import current_user_api
from .forms import LoginForm
from .views import home, kitchen_board, order_detail, table_detail
urlpatterns = [ urlpatterns = [
path("", home, name="home"), 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,25 +1,162 @@
import os from functools import wraps
import platform from urllib.parse import quote
from django import get_version as django_version from django.contrib import messages
from django.shortcuts import render 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.utils import timezone 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): def home(request):
"""Render the landing screen with loader and environment details.""" context = _dashboard_context()
host_name = request.get_host().lower() context["resolved_user_role"] = UserRole.label_for(request.user)
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" return render(request, "core/index.html", context)
now = timezone.now()
@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 = { context = {
"project_name": "New Style", "table": table,
"agent_brand": agent_brand, "current_order": current_order,
"django_version": django_version(), "form": form,
"python_version": platform.python_version(), "meta_title": f"{table.name} | Comanda activa",
"current_time": now, "meta_description": f"Captura de comandas y notas para {table.name}.",
"host_name": host_name, "resolved_user_role": UserRole.label_for(request.user),
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
return render(request, "core/index.html", context) 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,3 +1,5 @@
Django==5.2.7 Django==5.2.7
mysqlclient==2.2.7 mysqlclient==2.2.7
python-dotenv==1.1.1 python-dotenv==1.1.1
djangorestframework==3.16.1
djangorestframework-simplejwt==5.5.1

View File

@ -1,4 +1,597 @@
/* Custom styles for the application */ /* Restaurant POS custom theme */
body { :root {
font-family: system-ui, -apple-system, sans-serif; --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;
}
} }