diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc
index d79d6a7..c465477 100644
Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ
diff --git a/config/settings.py b/config/settings.py
index 291d043..e03d82e 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -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'
diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 5e8987a..ecb9e48 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/api.cpython-311.pyc b/core/__pycache__/api.cpython-311.pyc
new file mode 100644
index 0000000..7f4e7e5
Binary files /dev/null and b/core/__pycache__/api.cpython-311.pyc differ
diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc
new file mode 100644
index 0000000..cc6ab14
Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ
diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index a251b5f..5673bb1 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc
new file mode 100644
index 0000000..195979f
Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index f705988..7542e8e 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 2f0989c..049a46f 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/admin.py b/core/admin.py
index 8c38f3f..b939f08 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -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]
diff --git a/core/api.py b/core/api.py
new file mode 100644
index 0000000..6f24dfd
--- /dev/null
+++ b/core/api.py
@@ -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,
+ }
+ )
diff --git a/core/forms.py b/core/forms.py
new file mode 100644
index 0000000..996a71c
--- /dev/null
+++ b/core/forms.py
@@ -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
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..e0f74d4
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -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'),
+ ),
+ ]
diff --git a/core/migrations/0002_seed_demo_pos.py b/core/migrations/0002_seed_demo_pos.py
new file mode 100644
index 0000000..8942e78
--- /dev/null
+++ b/core/migrations/0002_seed_demo_pos.py
@@ -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),
+ ]
diff --git a/core/migrations/0003_userrole.py b/core/migrations/0003_userrole.py
new file mode 100644
index 0000000..a425183
--- /dev/null
+++ b/core/migrations/0003_userrole.py
@@ -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'],
+ },
+ ),
+ ]
diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc
new file mode 100644
index 0000000..435975f
Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0002_seed_demo_pos.cpython-311.pyc b/core/migrations/__pycache__/0002_seed_demo_pos.cpython-311.pyc
new file mode 100644
index 0000000..0fcc89f
Binary files /dev/null and b/core/migrations/__pycache__/0002_seed_demo_pos.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0003_userrole.cpython-311.pyc b/core/migrations/__pycache__/0003_userrole.cpython-311.pyc
new file mode 100644
index 0000000..18b11f0
Binary files /dev/null and b/core/migrations/__pycache__/0003_userrole.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 71a8362..378601b 100644
--- a/core/models.py
+++ b/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"))
diff --git a/core/templates/base.html b/core/templates/base.html
index 1e7e5fb..4e0ad4a 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -1,25 +1,72 @@
+{% load static %}
-
-
+
- {% block title %}Knowledge Base{% endblock %}
- {% if project_description %}
-
-
-
- {% endif %}
+
+ {% block title %}{{ meta_title|default:"Restaurante POS" }}{% endblock %}
+
{% if project_image_url %}
{% endif %}
- {% load static %}
+
+
+
+
{% block head %}{% endblock %}
+
+
+
-
- {% block content %}{% endblock %}
+ {% if messages %}
+
+ {% for message in messages %}
+
{{ message }}
+ {% endfor %}
+
+ {% endif %}
+
+ {% block content %}{% endblock %}
+
+
+
-
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
index faec813..a2376b1 100644
--- a/core/templates/core/index.html
+++ b/core/templates/core/index.html
@@ -1,145 +1,190 @@
{% extends "base.html" %}
-{% block title %}{{ project_name }}{% endblock %}
-
-{% block head %}
-
-
-
-
-{% endblock %}
+{% block title %}{{ meta_title }}{% endblock %}
+{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
-
-
Analyzing your requirements and generating your app…
-
-
Loading…
+
+
+
+
Primera iteración operativa
+
Controla mesas, comandas y cocina con un dashboard táctil y claro.
+
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.
+
+
+
+
+ Mesas ocupadas
+ {{ occupied_tables }}
+
+
+
+
+ Productos activos
+ {{ available_products }}
+
+
+
+
+ Ventas de hoy
+ ${{ daily_revenue|floatformat:2 }}
+
+
+
+
+
+
+
Orden activa
+
Mesa 4
+
2x Pasta trufa · 1x Limonada
+
Lista para cobrar
+
+
+
Cocina
+
{{ kitchen_orders|length }} órdenes
+
Vista rápida para preparación
+
+
+
+
- AppWizzy AI is collecting your requirements and applying the first changes.
- This page will refresh automatically as the plan is implemented.
-
- Runtime: Django {{ django_version }} · Python {{ python_version }}
- — UTC {{ current_time|date:"Y-m-d H:i:s" }}
-
-
+
+
+
+
+
+
+ Flujo principal
+
Dashboard de mesas
+
+
Toca una mesa para registrar productos, notas para cocina y revisar la comanda activa.
+
+
+
+
+
+
+
+ Ventas rápidas
+
Top productos
+
+
+ {% if top_products %}
+
+ {% for product in top_products %}
+
+
+
{{ product.name }}
+
{{ product.category }}
+
+
{{ product.sold }} vendidos
+
+ {% endfor %}
+
+ {% else %}
+
Todavía no hay ventas pagadas para mostrar tendencias.
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Seguimiento
+
Órdenes recientes
+
+
+ {% if recent_orders %}
+
+ {% else %}
+
Aún no hay órdenes. Abre una mesa y agrega el primer producto.
+ {% endif %}
+
+
+
+
+
+ {% if kitchen_orders %}
+
+ {% for order in kitchen_orders %}
+
+
+
+
#{{ order.id }} · {{ order.table.name }}
+
{{ order.get_status_display }}
+
+
{{ order.get_status_display }}
+
+
+ {% for item in order.items.all %}
+ - {{ item.quantity }} × {{ item.product.name }}{% if item.note %} — {{ item.note }}{% endif %}
+ {% endfor %}
+
+
+ {% endfor %}
+
+ {% else %}
+
La cocina está al día. No hay comandas pendientes.
+ {% endif %}
+
+
+
+
+
-
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/core/templates/core/kitchen.html b/core/templates/core/kitchen.html
new file mode 100644
index 0000000..9021c6b
--- /dev/null
+++ b/core/templates/core/kitchen.html
@@ -0,0 +1,52 @@
+{% extends "base.html" %}
+
+{% block title %}{{ meta_title }}{% endblock %}
+{% block meta_description %}{{ meta_description }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
KDS
+
Pantalla de cocina
+
Órdenes pendientes y en preparación, listas para seguir en prioridad visual.
+
+
+
+
+ {% if orders %}
+
+ {% for order in orders %}
+
+
+
+ {{ order.table.name }}
+
Orden #{{ order.id }}
+
+
{{ order.get_status_display }}
+
+
+ Abrir detalle
+
+ {% endfor %}
+
+ {% else %}
+
+
No hay órdenes pendientes
+
Cuando un mesero envíe una comanda, aparecerá aquí automáticamente.
+
Volver al dashboard
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/core/templates/core/order_detail.html b/core/templates/core/order_detail.html
new file mode 100644
index 0000000..b31c91d
--- /dev/null
+++ b/core/templates/core/order_detail.html
@@ -0,0 +1,91 @@
+{% extends "base.html" %}
+
+{% block title %}{{ meta_title }}{% endblock %}
+{% block meta_description %}{{ meta_description }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
Orden / Facturación
+
Orden #{{ order.id }}
+
{{ order.table.name }} · {{ order.get_status_display }} · {{ order.created_at|date:"d M Y · H:i" }}
+
+
+
+
+
+
+
+
+
+ Detalle
+
Recibo en vivo
+
+
{{ order.get_status_display }}
+
+
+ {% for item in order.items.all %}
+
+
+
{{ item.quantity }} × {{ item.product.name }}
+
{% if item.note %}{{ item.note }}{% else %}Preparación estándar{% endif %}
+
+
${{ item.line_total|floatformat:2 }}
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+ Siguiente acción
+
Flujo operativo
+
+
+
+
Abierta
+
En preparación
+
Lista
+
Pagada
+
+ {% if status_forms %}
+
+ {% for status, form in status_forms.items %}
+
+ {% endfor %}
+
+ {% else %}
+
+
Cuenta cerrada
+
La orden ya fue cobrada y la mesa quedó disponible para un nuevo servicio.
+
Volver al dashboard
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/table_detail.html b/core/templates/core/table_detail.html
new file mode 100644
index 0000000..5fe1049
--- /dev/null
+++ b/core/templates/core/table_detail.html
@@ -0,0 +1,88 @@
+{% extends "base.html" %}
+
+{% block title %}{{ meta_title }}{% endblock %}
+{% block meta_description %}{{ meta_description }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
Mesero / Comanda
+
{{ table.name }}
+
{{ table.seats }} puestos · Estado actual: {{ table.get_status_display }}
+
+
+
Volver al dashboard
+ {% if current_order %}
+
Ver recibo actual
+ {% endif %}
+
+
+
+
+
+
+
+
+ Agregar ítems
+
Nueva línea de comanda
+
+
+
+
+
+
+
+
+
+ Orden activa
+
{% if current_order %}Comanda #{{ current_order.id }}{% else %}Sin comanda todavía{% endif %}
+
+ {% if current_order %}
+
{{ current_order.get_status_display }}
+ {% endif %}
+
+ {% if current_order %}
+
+ {% for item in current_order.items.all %}
+
+
+
{{ item.quantity }} × {{ item.product.name }}
+
{% if item.note %}{{ item.note }}{% else %}Sin nota adicional{% endif %}
+
+
${{ item.line_total|floatformat:2 }}
+
+ {% endfor %}
+
+
+ {% else %}
+
+
Mesa lista para abrir
+
Agrega el primer producto para crear automáticamente la comanda activa.
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html
new file mode 100644
index 0000000..90d37f0
--- /dev/null
+++ b/core/templates/registration/login.html
@@ -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 %}
+
+
+
+
+ Acceso seguro
+ Iniciar sesión
+ Usa tu cuenta para entrar como Admin, Mesero o Cocina.
+
+
+
+
+
JWT API: /api/auth/token/
+
Perfil actual: /api/auth/me/
+
+
+
+
+
+{% endblock %}
diff --git a/core/tests.py b/core/tests.py
index 7ce503c..d165c31 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -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)
diff --git a/core/urls.py b/core/urls.py
index 6299e3d..2cdb4cc 100644
--- a/core/urls.py
+++ b/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/
/", table_detail, name="table_detail"),
+ path("orders//", 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"),
]
diff --git a/core/views.py b/core/views.py
index c9aed12..d0be6b8 100644
--- a/core/views.py
+++ b/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)
diff --git a/requirements.txt b/requirements.txt
index e22994c..a415ed8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/static/css/custom.css b/static/css/custom.css
index 925f6ed..7e5daed 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -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;
+ }
}