Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a175ba97 |
Binary file not shown.
@ -10,6 +10,7 @@ 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
|
||||
@ -23,6 +24,7 @@ DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
||||
ALLOWED_HOSTS = [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"testserver",
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
]
|
||||
|
||||
@ -37,17 +39,11 @@ 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',
|
||||
@ -55,6 +51,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'core',
|
||||
]
|
||||
|
||||
@ -65,8 +62,6 @@ 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'
|
||||
@ -83,7 +78,6 @@ 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',
|
||||
],
|
||||
},
|
||||
@ -92,10 +86,6 @@ TEMPLATES = [
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'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 = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
@ -129,33 +115,23 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
LANGUAGE_CODE = 'es-mx'
|
||||
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 = [
|
||||
BASE_DIR / 'static',
|
||||
BASE_DIR / 'assets',
|
||||
BASE_DIR / 'node_modules',
|
||||
directory
|
||||
for directory in [
|
||||
BASE_DIR / 'static',
|
||||
BASE_DIR / 'assets',
|
||||
BASE_DIR / 'node_modules',
|
||||
]
|
||||
if directory.exists()
|
||||
]
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND",
|
||||
"django.core.mail.backends.smtp.EmailBackend"
|
||||
@ -173,10 +149,29 @@ 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
|
||||
# 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'
|
||||
|
||||
Binary file not shown.
BIN
core/__pycache__/api.cpython-311.pyc
Normal file
BIN
core/__pycache__/api.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,37 @@
|
||||
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
22
core/api.py
Normal 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
46
core/forms.py
Normal 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
|
||||
78
core/migrations/0001_initial.py
Normal file
78
core/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
60
core/migrations/0002_seed_demo_pos.py
Normal file
60
core/migrations/0002_seed_demo_pos.py
Normal 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),
|
||||
]
|
||||
30
core/migrations/0003_userrole.py
Normal file
30
core/migrations/0003_userrole.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/migrations/__pycache__/0002_seed_demo_pos.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0002_seed_demo_pos.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/migrations/__pycache__/0003_userrole.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0003_userrole.cpython-311.pyc
Normal file
Binary file not shown.
163
core/models.py
163
core/models.py
@ -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"))
|
||||
|
||||
@ -1,25 +1,72 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 %}
|
||||
<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 %}">
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% 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 }}">
|
||||
{% 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>
|
||||
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
{% 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>
|
||||
|
||||
</html>
|
||||
|
||||
@ -1,145 +1,190 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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 title %}{{ meta_title }}{% endblock %}
|
||||
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
52
core/templates/core/kitchen.html
Normal file
52
core/templates/core/kitchen.html
Normal 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 %}
|
||||
91
core/templates/core/order_detail.html
Normal file
91
core/templates/core/order_detail.html
Normal 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 %}
|
||||
88
core/templates/core/table_detail.html
Normal file
88
core/templates/core/table_detail.html
Normal 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 %}
|
||||
42
core/templates/registration/login.html
Normal file
42
core/templates/registration/login.html
Normal 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 %}
|
||||
@ -1,3 +1,82 @@
|
||||
from django.contrib.auth.models import User
|
||||
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)
|
||||
|
||||
22
core/urls.py
22
core/urls.py
@ -1,7 +1,27 @@
|
||||
from django.contrib.auth.views import LoginView, LogoutView
|
||||
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 = [
|
||||
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"),
|
||||
]
|
||||
|
||||
171
core/views.py
171
core/views.py
@ -1,25 +1,162 @@
|
||||
import os
|
||||
import platform
|
||||
from functools import wraps
|
||||
from urllib.parse import quote
|
||||
|
||||
from django import get_version as django_version
|
||||
from django.shortcuts import render
|
||||
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.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):
|
||||
"""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 = _dashboard_context()
|
||||
context["resolved_user_role"] = UserRole.label_for(request.user)
|
||||
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 = {
|
||||
"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", ""),
|
||||
"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/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)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
Django==5.2.7
|
||||
mysqlclient==2.2.7
|
||||
python-dotenv==1.1.1
|
||||
djangorestframework==3.16.1
|
||||
djangorestframework-simplejwt==5.5.1
|
||||
|
||||
@ -1,4 +1,597 @@
|
||||
/* Custom styles for the application */
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user