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 %} + + {% 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 %} +
+
+
+
+
+
+ Cocina +

Vista previa KDS

+
+ Pantalla completa +
+ {% 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 }} +
+
    + {% for item in order.items.all %} +
  • + {{ item.quantity }} × {{ item.product.name }} +

    {% if item.note %}{{ item.note }}{% else %}Sin nota adicional{% endif %}

    +
  • + {% endfor %} +
+ 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 %} +
+ {% csrf_token %} + {{ form.status }} + +
+ {% endfor %} +
+ {% else %} + + {% 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

+
+
+
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} +
{{ field.errors|join:", " }}
+ {% endif %} +
+ {% endfor %} + +
+
+
+
+
+
+
+ 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.

+ +
+ {% csrf_token %} +
+ + {{ form.username }} +
+
+ + {{ form.password }} +
+ {% if form.errors %} +
Usuario o contraseña incorrectos.
+ {% endif %} + {% if next %} + + {% endif %} + +
+ +
+
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; + } }