Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
Binary file not shown.
@ -10,7 +10,6 @@ For the full list of settings and their values, see
|
|||||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@ -24,7 +23,6 @@ DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
|||||||
ALLOWED_HOSTS = [
|
ALLOWED_HOSTS = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"localhost",
|
"localhost",
|
||||||
"testserver",
|
|
||||||
os.getenv("HOST_FQDN", ""),
|
os.getenv("HOST_FQDN", ""),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -39,11 +37,17 @@ CSRF_TRUSTED_ORIGINS = [
|
|||||||
for host in CSRF_TRUSTED_ORIGINS
|
for host in CSRF_TRUSTED_ORIGINS
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
SESSION_COOKIE_SAMESITE = "None"
|
SESSION_COOKIE_SAMESITE = "None"
|
||||||
CSRF_COOKIE_SAMESITE = "None"
|
CSRF_COOKIE_SAMESITE = "None"
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
@ -51,7 +55,6 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'rest_framework',
|
|
||||||
'core',
|
'core',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -62,6 +65,8 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
|
||||||
|
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
X_FRAME_OPTIONS = 'ALLOWALL'
|
X_FRAME_OPTIONS = 'ALLOWALL'
|
||||||
@ -78,6 +83,7 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
# IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
|
||||||
'core.context_processors.project_context',
|
'core.context_processors.project_context',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -86,6 +92,10 @@ TEMPLATES = [
|
|||||||
|
|
||||||
WSGI_APPLICATION = 'config.wsgi.application'
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.mysql',
|
'ENGINE': 'django.db.backends.mysql',
|
||||||
@ -100,6 +110,10 @@ DATABASES = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
@ -115,23 +129,33 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
LANGUAGE_CODE = 'es-mx'
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = 'static/'
|
||||||
|
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
directory
|
BASE_DIR / 'static',
|
||||||
for directory in [
|
BASE_DIR / 'assets',
|
||||||
BASE_DIR / 'static',
|
BASE_DIR / 'node_modules',
|
||||||
BASE_DIR / 'assets',
|
|
||||||
BASE_DIR / 'node_modules',
|
|
||||||
]
|
|
||||||
if directory.exists()
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Email
|
||||||
EMAIL_BACKEND = os.getenv(
|
EMAIL_BACKEND = os.getenv(
|
||||||
"EMAIL_BACKEND",
|
"EMAIL_BACKEND",
|
||||||
"django.core.mail.backends.smtp.EmailBackend"
|
"django.core.mail.backends.smtp.EmailBackend"
|
||||||
@ -149,29 +173,10 @@ CONTACT_EMAIL_TO = [
|
|||||||
if item.strip()
|
if item.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
||||||
if EMAIL_USE_SSL:
|
if EMAIL_USE_SSL:
|
||||||
EMAIL_USE_TLS = False
|
EMAIL_USE_TLS = False
|
||||||
|
# Default primary key field type
|
||||||
LOGIN_URL = '/login/'
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
LOGIN_REDIRECT_URL = '/'
|
|
||||||
LOGOUT_REDIRECT_URL = '/login/'
|
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
|
||||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
|
||||||
),
|
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
|
||||||
'rest_framework.permissions.IsAuthenticated',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
SIMPLE_JWT = {
|
|
||||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
|
||||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
|
||||||
'ROTATE_REFRESH_TOKENS': False,
|
|
||||||
'BLACKLIST_AFTER_ROTATION': False,
|
|
||||||
'UPDATE_LAST_LOGIN': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,37 +1,3 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Order, OrderItem, Product, Table, UserRole
|
# Register your models here.
|
||||||
|
|
||||||
|
|
||||||
class OrderItemInline(admin.TabularInline):
|
|
||||||
model = OrderItem
|
|
||||||
extra = 0
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(UserRole)
|
|
||||||
class UserRoleAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("user", "role", "updated_at")
|
|
||||||
list_filter = ("role",)
|
|
||||||
search_fields = ("user__username", "user__email", "user__first_name", "user__last_name")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Table)
|
|
||||||
class TableAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "status", "seats", "area", "updated_at")
|
|
||||||
list_filter = ("status", "area")
|
|
||||||
search_fields = ("name",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Product)
|
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "category", "price", "station", "is_available")
|
|
||||||
list_filter = ("category", "station", "is_available")
|
|
||||||
search_fields = ("name",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Order)
|
|
||||||
class OrderAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("id", "table", "status", "created_at", "paid_at")
|
|
||||||
list_filter = ("status", "created_at")
|
|
||||||
search_fields = ("table__name", "guest_name")
|
|
||||||
inlines = [OrderItemInline]
|
|
||||||
|
|||||||
22
core/api.py
22
core/api.py
@ -1,22 +0,0 @@
|
|||||||
from rest_framework.decorators import api_view, permission_classes
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from .models import UserRole
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def current_user_api(request):
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"id": request.user.id,
|
|
||||||
"username": request.user.username,
|
|
||||||
"email": request.user.email,
|
|
||||||
"first_name": request.user.first_name,
|
|
||||||
"last_name": request.user.last_name,
|
|
||||||
"role": UserRole.resolve_for(request.user),
|
|
||||||
"role_label": UserRole.label_for(request.user),
|
|
||||||
"is_superuser": request.user.is_superuser,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
from django import forms
|
|
||||||
from django.contrib.auth.forms import AuthenticationForm
|
|
||||||
|
|
||||||
from .models import Order, OrderItem, Product
|
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(AuthenticationForm):
|
|
||||||
username = forms.CharField(
|
|
||||||
label="Usuario",
|
|
||||||
widget=forms.TextInput(attrs={"class": "form-control form-control-lg", "placeholder": "Tu usuario"}),
|
|
||||||
)
|
|
||||||
password = forms.CharField(
|
|
||||||
label="Contraseña",
|
|
||||||
strip=False,
|
|
||||||
widget=forms.PasswordInput(attrs={"class": "form-control form-control-lg", "placeholder": "••••••••"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AddOrderItemForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = OrderItem
|
|
||||||
fields = ["product", "quantity", "note"]
|
|
||||||
widgets = {
|
|
||||||
"product": forms.Select(attrs={"class": "form-select form-select-lg"}),
|
|
||||||
"quantity": forms.NumberInput(attrs={"class": "form-control form-control-lg", "min": 1}),
|
|
||||||
"note": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Ej. sin cebolla, extra salsa, término medio"}),
|
|
||||||
}
|
|
||||||
labels = {
|
|
||||||
"product": "Producto",
|
|
||||||
"quantity": "Cantidad",
|
|
||||||
"note": "Nota para cocina",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.fields["product"].queryset = Product.objects.filter(is_available=True)
|
|
||||||
self.fields["quantity"].initial = 1
|
|
||||||
|
|
||||||
|
|
||||||
class OrderStatusForm(forms.Form):
|
|
||||||
status = forms.ChoiceField(widget=forms.HiddenInput())
|
|
||||||
|
|
||||||
def __init__(self, *args, order=None, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
choices = [(status, dict(Order.Status.choices)[status]) for status in (order.allowed_transitions() if order else [])]
|
|
||||||
self.fields["status"].choices = choices
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2026-04-26 16:27
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Order',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('status', models.CharField(choices=[('open', 'Abierta'), ('preparing', 'En preparación'), ('ready', 'Lista'), ('paid', 'Pagada')], default='open', max_length=20)),
|
|
||||||
('guest_name', models.CharField(blank=True, max_length=120)),
|
|
||||||
('server_note', models.CharField(blank=True, max_length=200)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('sent_to_kitchen_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('paid_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Product',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=120)),
|
|
||||||
('category', models.CharField(max_length=80)),
|
|
||||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
|
||||||
('is_available', models.BooleanField(default=True)),
|
|
||||||
('station', models.CharField(default='Cocina', max_length=80)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['category', 'name'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Table',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=40, unique=True)),
|
|
||||||
('seats', models.PositiveSmallIntegerField(default=4)),
|
|
||||||
('status', models.CharField(choices=[('free', 'Libre'), ('occupied', 'Ocupada')], default='free', max_length=12)),
|
|
||||||
('area', models.CharField(blank=True, max_length=50)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['name'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='OrderItem',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('quantity', models.PositiveIntegerField(default=1)),
|
|
||||||
('note', models.CharField(blank=True, max_length=200)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.order')),
|
|
||||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='core.product')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['created_at', 'id'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='order',
|
|
||||||
name='table',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='core.table'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
def seed_demo_pos(apps, schema_editor):
|
|
||||||
Table = apps.get_model("core", "Table")
|
|
||||||
Product = apps.get_model("core", "Product")
|
|
||||||
|
|
||||||
tables = [
|
|
||||||
("Mesa 1", 2, "Terraza"),
|
|
||||||
("Mesa 2", 4, "Salón"),
|
|
||||||
("Mesa 3", 4, "Salón"),
|
|
||||||
("Mesa 4", 6, "Ventana"),
|
|
||||||
("Mesa 5", 2, "Barra"),
|
|
||||||
("Mesa 6", 4, "Terraza"),
|
|
||||||
("Mesa 7", 4, "Privado"),
|
|
||||||
("Mesa 8", 6, "Salón"),
|
|
||||||
]
|
|
||||||
for name, seats, area in tables:
|
|
||||||
Table.objects.get_or_create(name=name, defaults={"seats": seats, "area": area})
|
|
||||||
|
|
||||||
products = [
|
|
||||||
("Burger clásica", "Platos", Decimal("12.50"), "Cocina"),
|
|
||||||
("Pasta de trufa", "Platos", Decimal("15.00"), "Cocina"),
|
|
||||||
("Ensalada César", "Entradas", Decimal("9.50"), "Cocina"),
|
|
||||||
("Papas bravas", "Entradas", Decimal("7.00"), "Cocina"),
|
|
||||||
("Limonada artesanal", "Bebidas", Decimal("4.50"), "Bar"),
|
|
||||||
("Té helado", "Bebidas", Decimal("3.80"), "Bar"),
|
|
||||||
]
|
|
||||||
for name, category, price, station in products:
|
|
||||||
Product.objects.get_or_create(
|
|
||||||
name=name,
|
|
||||||
defaults={"category": category, "price": price, "station": station, "is_available": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_demo_pos(apps, schema_editor):
|
|
||||||
Table = apps.get_model("core", "Table")
|
|
||||||
Product = apps.get_model("core", "Product")
|
|
||||||
Table.objects.filter(name__in=[f"Mesa {idx}" for idx in range(1, 9)]).delete()
|
|
||||||
Product.objects.filter(name__in=[
|
|
||||||
"Burger clásica",
|
|
||||||
"Pasta de trufa",
|
|
||||||
"Ensalada César",
|
|
||||||
"Papas bravas",
|
|
||||||
"Limonada artesanal",
|
|
||||||
"Té helado",
|
|
||||||
]).delete()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("core", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(seed_demo_pos, remove_demo_pos),
|
|
||||||
]
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2026-04-26 16:38
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0002_seed_demo_pos'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='UserRole',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('role', models.CharField(choices=[('admin', 'Admin'), ('waiter', 'Mesero'), ('kitchen', 'Cocina')], default='waiter', max_length=20)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='role_profile', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Rol de usuario',
|
|
||||||
'verbose_name_plural': 'Roles de usuario',
|
|
||||||
'ordering': ['user__username'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
161
core/models.py
161
core/models.py
@ -1,162 +1,3 @@
|
|||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.signals import post_save
|
|
||||||
from django.dispatch import receiver
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
class UserRole(models.Model):
|
|
||||||
class Role(models.TextChoices):
|
|
||||||
ADMIN = "admin", "Admin"
|
|
||||||
WAITER = "waiter", "Mesero"
|
|
||||||
KITCHEN = "kitchen", "Cocina"
|
|
||||||
|
|
||||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="role_profile")
|
|
||||||
role = models.CharField(max_length=20, choices=Role.choices, default=Role.WAITER)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["user__username"]
|
|
||||||
verbose_name = "Rol de usuario"
|
|
||||||
verbose_name_plural = "Roles de usuario"
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.user.username} · {self.get_role_display()}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def resolve_for(cls, user):
|
|
||||||
if not user or not user.is_authenticated:
|
|
||||||
return None
|
|
||||||
if user.is_superuser:
|
|
||||||
return cls.Role.ADMIN
|
|
||||||
profile, _ = cls.objects.get_or_create(user=user, defaults={"role": cls.Role.WAITER})
|
|
||||||
return profile.role
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def label_for(cls, user):
|
|
||||||
role = cls.resolve_for(user)
|
|
||||||
return dict(cls.Role.choices).get(role, "Sin rol") if role else "Invitado"
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
|
|
||||||
def ensure_user_role(sender, instance, created, **kwargs):
|
|
||||||
if created and not instance.is_superuser:
|
|
||||||
UserRole.objects.get_or_create(user=instance, defaults={"role": UserRole.Role.WAITER})
|
|
||||||
|
|
||||||
|
|
||||||
class Table(models.Model):
|
|
||||||
class Status(models.TextChoices):
|
|
||||||
FREE = "free", "Libre"
|
|
||||||
OCCUPIED = "occupied", "Ocupada"
|
|
||||||
|
|
||||||
name = models.CharField(max_length=40, unique=True)
|
|
||||||
seats = models.PositiveSmallIntegerField(default=4)
|
|
||||||
status = models.CharField(max_length=12, choices=Status.choices, default=Status.FREE)
|
|
||||||
area = models.CharField(max_length=50, blank=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_order(self):
|
|
||||||
cached_orders = getattr(self, "open_orders_cache", None)
|
|
||||||
if cached_orders is not None:
|
|
||||||
return cached_orders[0] if cached_orders else None
|
|
||||||
return self.orders.exclude(status=Order.Status.PAID).order_by("-created_at").first()
|
|
||||||
|
|
||||||
|
|
||||||
class Product(models.Model):
|
|
||||||
name = models.CharField(max_length=120)
|
|
||||||
category = models.CharField(max_length=80)
|
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
|
||||||
is_available = models.BooleanField(default=True)
|
|
||||||
station = models.CharField(max_length=80, default="Cocina")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["category", "name"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class Order(models.Model):
|
|
||||||
class Status(models.TextChoices):
|
|
||||||
OPEN = "open", "Abierta"
|
|
||||||
PREPARING = "preparing", "En preparación"
|
|
||||||
READY = "ready", "Lista"
|
|
||||||
PAID = "paid", "Pagada"
|
|
||||||
|
|
||||||
table = models.ForeignKey(Table, on_delete=models.PROTECT, related_name="orders")
|
|
||||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN)
|
|
||||||
guest_name = models.CharField(max_length=120, blank=True)
|
|
||||||
server_note = models.CharField(max_length=200, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
sent_to_kitchen_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
paid_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Orden #{self.pk} · {self.table.name}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def subtotal(self):
|
|
||||||
if hasattr(self, "subtotal_value") and self.subtotal_value is not None:
|
|
||||||
return self.subtotal_value
|
|
||||||
total = sum((item.line_total for item in self.items.select_related("product").all()), Decimal("0.00"))
|
|
||||||
return total.quantize(Decimal("0.01"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def total_items(self):
|
|
||||||
if hasattr(self, "items_count") and self.items_count is not None:
|
|
||||||
return self.items_count
|
|
||||||
return sum(item.quantity for item in self.items.all())
|
|
||||||
|
|
||||||
def allowed_transitions(self):
|
|
||||||
transitions = {
|
|
||||||
self.Status.OPEN: [self.Status.PREPARING],
|
|
||||||
self.Status.PREPARING: [self.Status.READY],
|
|
||||||
self.Status.READY: [self.Status.PAID],
|
|
||||||
self.Status.PAID: [],
|
|
||||||
}
|
|
||||||
return transitions.get(self.status, [])
|
|
||||||
|
|
||||||
def advance_to(self, new_status):
|
|
||||||
if new_status not in self.allowed_transitions():
|
|
||||||
raise ValueError("Invalid status transition")
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
self.status = new_status
|
|
||||||
if new_status == self.Status.PREPARING and not self.sent_to_kitchen_at:
|
|
||||||
self.sent_to_kitchen_at = now
|
|
||||||
if new_status == self.Status.PAID:
|
|
||||||
self.paid_at = now
|
|
||||||
self.table.status = Table.Status.FREE
|
|
||||||
self.table.save(update_fields=["status", "updated_at"])
|
|
||||||
self.save(update_fields=["status", "sent_to_kitchen_at", "paid_at", "updated_at"])
|
|
||||||
|
|
||||||
|
|
||||||
class OrderItem(models.Model):
|
|
||||||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
|
|
||||||
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="order_items")
|
|
||||||
quantity = models.PositiveIntegerField(default=1)
|
|
||||||
note = models.CharField(max_length=200, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["created_at", "id"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.quantity} x {self.product.name}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def line_total(self):
|
|
||||||
return (self.product.price * self.quantity).quantize(Decimal("0.01"))
|
|
||||||
|
|||||||
@ -1,72 +1,25 @@
|
|||||||
{% load static %}
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="es">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||||
<title>{% block title %}{{ meta_title|default:"Restaurante POS" }}{% endblock %}</title>
|
{% if project_description %}
|
||||||
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'Gestión visual de mesas, comandas y cocina para restaurantes.' }}{% endblock %}">
|
<meta name="description" content="{{ project_description }}">
|
||||||
|
<meta property="og:description" content="{{ project_description }}">
|
||||||
|
<meta property="twitter:description" content="{{ project_description }}">
|
||||||
|
{% endif %}
|
||||||
{% if project_image_url %}
|
{% if project_image_url %}
|
||||||
<meta property="og:image" content="{{ project_image_url }}">
|
<meta property="og:image" content="{{ project_image_url }}">
|
||||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
{% load static %}
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="pos-app">
|
|
||||||
<div class="page-shell">
|
|
||||||
<header class="site-header sticky-top">
|
|
||||||
<nav class="navbar navbar-expand-lg">
|
|
||||||
<div class="container-xxl align-items-center">
|
|
||||||
<a class="navbar-brand" href="{% url 'home' %}">
|
|
||||||
<span class="brand-mark">RP</span>
|
|
||||||
<span>
|
|
||||||
<strong>Restaurante POS</strong>
|
|
||||||
<small>Operación en tiempo real</small>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Abrir navegación">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="mainNav">
|
|
||||||
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
|
||||||
{% if request.user.is_authenticated %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Dashboard</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'kitchen_board' %}">KDS Cocina</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<span class="nav-link disabled">{{ request.user.username }}{% if resolved_user_role %} · {{ resolved_user_role }}{% endif %}</span>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<form method="post" action="{% url 'logout' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn btn-outline-dark rounded-pill px-3">Salir</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Iniciar sesión</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if messages %}
|
<body>
|
||||||
<div class="container-xxl mt-3">
|
{% block content %}{% endblock %}
|
||||||
{% for message in messages %}
|
|
||||||
<div class="alert alert-{{ message.tags|default:'info' }} pos-alert" role="alert">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,190 +1,145 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ meta_title }}{% endblock %}
|
{% block title %}{{ project_name }}{% endblock %}
|
||||||
{% block meta_description %}{{ meta_description }}{% endblock %}
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color-start: #6a11cb;
|
||||||
|
--bg-color-end: #2575fc;
|
||||||
|
--text-color: #ffffff;
|
||||||
|
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||||
|
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||||
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
||||||
|
animation: bg-pan 20s linear infinite;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bg-pan {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 100% 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg-color);
|
||||||
|
border: 1px solid var(--card-border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 1.2rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime code {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
<main>
|
||||||
<section class="hero-section">
|
<div class="card">
|
||||||
<div class="container-xxl hero-grid">
|
<h1>Analyzing your requirements and generating your app…</h1>
|
||||||
<div class="hero-copy">
|
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||||
<span class="eyebrow">Primera iteración operativa</span>
|
<span class="sr-only">Loading…</span>
|
||||||
<h1>Controla mesas, comandas y cocina con un dashboard táctil y claro.</h1>
|
|
||||||
<p class="hero-text">Un punto de partida usable para meseros y cocina: abre una mesa, agrega productos con notas, sigue el estado de la orden y cobra cuando esté lista.</p>
|
|
||||||
<div class="hero-actions">
|
|
||||||
<a href="#tables-grid" class="btn btn-pos-primary btn-lg">Ver mesas</a>
|
|
||||||
<a href="{% url 'kitchen_board' %}" class="btn btn-pos-soft btn-lg">Abrir KDS</a>
|
|
||||||
</div>
|
|
||||||
<div class="hero-kpis row g-3">
|
|
||||||
<div class="col-sm-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<span>Mesas ocupadas</span>
|
|
||||||
<strong>{{ occupied_tables }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<span>Productos activos</span>
|
|
||||||
<strong>{{ available_products }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<span>Ventas de hoy</span>
|
|
||||||
<strong>${{ daily_revenue|floatformat:2 }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hero-visual">
|
|
||||||
<div class="floating-card primary-card">
|
|
||||||
<span class="floating-label">Orden activa</span>
|
|
||||||
<strong>Mesa 4</strong>
|
|
||||||
<p>2x Pasta trufa · 1x Limonada</p>
|
|
||||||
<div class="status-pill status-ready">Lista para cobrar</div>
|
|
||||||
</div>
|
|
||||||
<div class="floating-card secondary-card">
|
|
||||||
<span class="floating-label">Cocina</span>
|
|
||||||
<strong>{{ kitchen_orders|length }} órdenes</strong>
|
|
||||||
<p>Vista rápida para preparación</p>
|
|
||||||
</div>
|
|
||||||
<div class="shape shape-sphere"></div>
|
|
||||||
<div class="shape shape-cube"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||||
|
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
||||||
<section class="dashboard-section">
|
<p class="runtime">
|
||||||
<div class="container-xxl">
|
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
||||||
<div class="section-heading">
|
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||||
<div>
|
</p>
|
||||||
<span class="eyebrow">Flujo principal</span>
|
</div>
|
||||||
<h2 id="tables-grid">Dashboard de mesas</h2>
|
|
||||||
</div>
|
|
||||||
<p>Toca una mesa para registrar productos, notas para cocina y revisar la comanda activa.</p>
|
|
||||||
</div>
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-xl-8">
|
|
||||||
<div class="table-grid">
|
|
||||||
{% for table in tables %}
|
|
||||||
<a href="{% url 'table_detail' table.id %}" class="table-card {% if table.status == 'occupied' %}is-occupied{% else %}is-free{% endif %}">
|
|
||||||
<div class="table-card-top">
|
|
||||||
<div>
|
|
||||||
<span class="table-label">{{ table.name }}</span>
|
|
||||||
<h3>{{ table.seats }} puestos</h3>
|
|
||||||
</div>
|
|
||||||
<span class="status-pill {% if table.status == 'occupied' %}status-busy{% else %}status-free{% endif %}">{{ table.get_status_display }}</span>
|
|
||||||
</div>
|
|
||||||
{% if table.current_order %}
|
|
||||||
<div class="table-order-preview">
|
|
||||||
<strong>Orden #{{ table.current_order.id }}</strong>
|
|
||||||
<p>{{ table.current_order.total_items }} ítems · ${{ table.current_order.subtotal|floatformat:2 }}</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-mini">Lista para recibir una nueva comanda.</div>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
{% empty %}
|
|
||||||
<div class="empty-state-card">
|
|
||||||
<h3>No hay mesas configuradas</h3>
|
|
||||||
<p>Agrega mesas desde el admin para empezar a tomar comandas.</p>
|
|
||||||
<a href="/admin/" class="btn btn-pos-primary">Ir al admin</a>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xl-4">
|
|
||||||
<div class="side-panel-card">
|
|
||||||
<div class="section-heading compact">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Ventas rápidas</span>
|
|
||||||
<h2>Top productos</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if top_products %}
|
|
||||||
<div class="stack-list">
|
|
||||||
{% for product in top_products %}
|
|
||||||
<div class="stack-item">
|
|
||||||
<div>
|
|
||||||
<strong>{{ product.name }}</strong>
|
|
||||||
<p>{{ product.category }}</p>
|
|
||||||
</div>
|
|
||||||
<span>{{ product.sold }} vendidos</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-mini">Todavía no hay ventas pagadas para mostrar tendencias.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="dashboard-section section-muted">
|
|
||||||
<div class="container-xxl">
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-7">
|
|
||||||
<div class="side-panel-card h-100">
|
|
||||||
<div class="section-heading compact">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Seguimiento</span>
|
|
||||||
<h2>Órdenes recientes</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if recent_orders %}
|
|
||||||
<div class="stack-list">
|
|
||||||
{% for order in recent_orders %}
|
|
||||||
<a href="{% url 'order_detail' order.id %}" class="stack-item stack-item-link">
|
|
||||||
<div>
|
|
||||||
<strong>Orden #{{ order.id }} · {{ order.table.name }}</strong>
|
|
||||||
<p>{{ order.total_items }} ítems · {{ order.get_status_display }}</p>
|
|
||||||
</div>
|
|
||||||
<span>${{ order.subtotal|floatformat:2 }}</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-mini">Aún no hay órdenes. Abre una mesa y agrega el primer producto.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<div class="side-panel-card h-100">
|
|
||||||
<div class="section-heading compact">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Cocina</span>
|
|
||||||
<h2>Vista previa KDS</h2>
|
|
||||||
</div>
|
|
||||||
<a href="{% url 'kitchen_board' %}" class="text-link">Pantalla completa</a>
|
|
||||||
</div>
|
|
||||||
{% if kitchen_orders %}
|
|
||||||
<div class="kitchen-preview-list">
|
|
||||||
{% for order in kitchen_orders %}
|
|
||||||
<div class="kitchen-ticket">
|
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
|
||||||
<div>
|
|
||||||
<strong>#{{ order.id }} · {{ order.table.name }}</strong>
|
|
||||||
<p>{{ order.get_status_display }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="status-pill {% if order.status == 'open' %}status-free{% else %}status-busy{% endif %}">{{ order.get_status_display }}</span>
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
{% for item in order.items.all %}
|
|
||||||
<li>{{ item.quantity }} × {{ item.product.name }}{% if item.note %} — {{ item.note }}{% endif %}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-mini">La cocina está al día. No hay comandas pendientes.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
<footer>
|
||||||
|
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||||
|
</footer>
|
||||||
|
{% endblock %}
|
||||||
@ -1,52 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ meta_title }}{% endblock %}
|
|
||||||
{% block meta_description %}{{ meta_description }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main class="content-section">
|
|
||||||
<div class="container-xxl">
|
|
||||||
<div class="page-intro">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">KDS</span>
|
|
||||||
<h1>Pantalla de cocina</h1>
|
|
||||||
<p>Órdenes pendientes y en preparación, listas para seguir en prioridad visual.</p>
|
|
||||||
</div>
|
|
||||||
<div class="page-actions">
|
|
||||||
<a href="{% url 'home' %}" class="btn btn-pos-soft">Volver al dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if orders %}
|
|
||||||
<div class="kds-grid">
|
|
||||||
{% for order in orders %}
|
|
||||||
<article class="kds-card">
|
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">{{ order.table.name }}</span>
|
|
||||||
<h2>Orden #{{ order.id }}</h2>
|
|
||||||
</div>
|
|
||||||
<span class="status-pill {% if order.status == 'preparing' %}status-busy{% else %}status-free{% endif %}">{{ order.get_status_display }}</span>
|
|
||||||
</div>
|
|
||||||
<ul class="kds-items">
|
|
||||||
{% for item in order.items.all %}
|
|
||||||
<li>
|
|
||||||
<strong>{{ item.quantity }} × {{ item.product.name }}</strong>
|
|
||||||
<p>{% if item.note %}{{ item.note }}{% else %}Sin nota adicional{% endif %}</p>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<a href="{% url 'order_detail' order.id %}" class="btn btn-pos-primary w-100">Abrir detalle</a>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-state-card centered">
|
|
||||||
<h3>No hay órdenes pendientes</h3>
|
|
||||||
<p>Cuando un mesero envíe una comanda, aparecerá aquí automáticamente.</p>
|
|
||||||
<a href="{% url 'home' %}" class="btn btn-pos-primary">Volver al dashboard</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ meta_title }}{% endblock %}
|
|
||||||
{% block meta_description %}{{ meta_description }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main class="content-section">
|
|
||||||
<div class="container-xxl">
|
|
||||||
<div class="page-intro">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Orden / Facturación</span>
|
|
||||||
<h1>Orden #{{ order.id }}</h1>
|
|
||||||
<p>{{ order.table.name }} · {{ order.get_status_display }} · {{ order.created_at|date:"d M Y · H:i" }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="page-actions">
|
|
||||||
<a href="{% url 'table_detail' order.table.id %}" class="btn btn-pos-soft">Volver a la mesa</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 align-items-start">
|
|
||||||
<div class="col-lg-7">
|
|
||||||
<section class="panel-card">
|
|
||||||
<div class="section-heading compact">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Detalle</span>
|
|
||||||
<h2>Recibo en vivo</h2>
|
|
||||||
</div>
|
|
||||||
<span class="status-pill {% if order.status == 'paid' %}status-ready{% elif order.status == 'ready' %}status-ready{% elif order.status == 'preparing' %}status-busy{% else %}status-free{% endif %}">{{ order.get_status_display }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="receipt-lines">
|
|
||||||
{% for item in order.items.all %}
|
|
||||||
<div class="receipt-line">
|
|
||||||
<div>
|
|
||||||
<strong>{{ item.quantity }} × {{ item.product.name }}</strong>
|
|
||||||
<p>{% if item.note %}{{ item.note }}{% else %}Preparación estándar{% endif %}</p>
|
|
||||||
</div>
|
|
||||||
<span>${{ item.line_total|floatformat:2 }}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="receipt-footer large">
|
|
||||||
<div>
|
|
||||||
<span>Total</span>
|
|
||||||
<strong>${{ order.subtotal|floatformat:2 }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Ítems</span>
|
|
||||||
<strong>{{ order.total_items }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<section class="panel-card">
|
|
||||||
<div class="section-heading compact">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Siguiente acción</span>
|
|
||||||
<h2>Flujo operativo</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="workflow-steps">
|
|
||||||
<div class="workflow-step {% if order.status == 'open' %}active{% endif %}">Abierta</div>
|
|
||||||
<div class="workflow-step {% if order.status == 'preparing' %}active{% endif %}">En preparación</div>
|
|
||||||
<div class="workflow-step {% if order.status == 'ready' %}active{% endif %}">Lista</div>
|
|
||||||
<div class="workflow-step {% if order.status == 'paid' %}active{% endif %}">Pagada</div>
|
|
||||||
</div>
|
|
||||||
{% if status_forms %}
|
|
||||||
<div class="action-stack">
|
|
||||||
{% for status, form in status_forms.items %}
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ form.status }}
|
|
||||||
<button type="submit" class="btn btn-pos-primary btn-lg w-100">
|
|
||||||
{% if status == 'preparing' %}Enviar a cocina{% elif status == 'ready' %}Marcar lista{% elif status == 'paid' %}Cobrar y cerrar mesa{% else %}Actualizar{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-state-card small paid-state">
|
|
||||||
<h3>Cuenta cerrada</h3>
|
|
||||||
<p>La orden ya fue cobrada y la mesa quedó disponible para un nuevo servicio.</p>
|
|
||||||
<a href="{% url 'home' %}" class="btn btn-pos-soft">Volver al dashboard</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ meta_title }}{% endblock %}
|
|
||||||
{% block meta_description %}{{ meta_description }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main class="content-section">
|
|
||||||
<div class="container-xxl">
|
|
||||||
<div class="page-intro">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Mesero / Comanda</span>
|
|
||||||
<h1>{{ table.name }}</h1>
|
|
||||||
<p>{{ table.seats }} puestos · Estado actual: {{ table.get_status_display }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="page-actions">
|
|
||||||
<a href="{% url 'home' %}" class="btn btn-pos-soft">Volver al dashboard</a>
|
|
||||||
{% if current_order %}
|
|
||||||
<a href="{% url 'order_detail' current_order.id %}" class="btn btn-pos-primary">Ver recibo actual</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4 align-items-start">
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<section class="panel-card">
|
|
||||||
<div class="section-heading compact">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Agregar ítems</span>
|
|
||||||
<h2>Nueva línea de comanda</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="post" novalidate class="pos-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
{% for field in form %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
|
||||||
{{ field }}
|
|
||||||
{% if field.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ field.errors|join:", " }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
<button type="submit" class="btn btn-pos-primary btn-lg w-100">Agregar a la mesa</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-7">
|
|
||||||
<section class="panel-card">
|
|
||||||
<div class="section-heading compact">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Orden activa</span>
|
|
||||||
<h2>{% if current_order %}Comanda #{{ current_order.id }}{% else %}Sin comanda todavía{% endif %}</h2>
|
|
||||||
</div>
|
|
||||||
{% if current_order %}
|
|
||||||
<span class="status-pill {% if current_order.status == 'ready' %}status-ready{% elif current_order.status == 'preparing' %}status-busy{% else %}status-free{% endif %}">{{ current_order.get_status_display }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if current_order %}
|
|
||||||
<div class="receipt-lines">
|
|
||||||
{% for item in current_order.items.all %}
|
|
||||||
<div class="receipt-line">
|
|
||||||
<div>
|
|
||||||
<strong>{{ item.quantity }} × {{ item.product.name }}</strong>
|
|
||||||
<p>{% if item.note %}{{ item.note }}{% else %}Sin nota adicional{% endif %}</p>
|
|
||||||
</div>
|
|
||||||
<span>${{ item.line_total|floatformat:2 }}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="receipt-footer">
|
|
||||||
<div>
|
|
||||||
<span>Total actual</span>
|
|
||||||
<strong>${{ current_order.subtotal|floatformat:2 }}</strong>
|
|
||||||
</div>
|
|
||||||
<a href="{% url 'order_detail' current_order.id %}" class="btn btn-pos-soft">Gestionar estado</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-state-card small">
|
|
||||||
<h3>Mesa lista para abrir</h3>
|
|
||||||
<p>Agrega el primer producto para crear automáticamente la comanda activa.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Acceso POS | Restaurante{% endblock %}
|
|
||||||
{% block meta_description %}Acceso seguro para Admin, Mesero y Cocina dentro del POS del restaurante.{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main class="container-xxl py-5">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-lg-5 col-xl-4">
|
|
||||||
<section class="card border-0 shadow-lg rounded-4 p-4 p-md-5 bg-white">
|
|
||||||
<span class="text-uppercase fw-semibold small text-muted">Acceso seguro</span>
|
|
||||||
<h1 class="mt-2 mb-3" style="font-family: Manrope, sans-serif;">Iniciar sesión</h1>
|
|
||||||
<p class="text-secondary mb-4">Usa tu cuenta para entrar como Admin, Mesero o Cocina.</p>
|
|
||||||
|
|
||||||
<form method="post" novalidate>
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label" for="id_username">{{ form.username.label }}</label>
|
|
||||||
{{ form.username }}
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label" for="id_password">{{ form.password.label }}</label>
|
|
||||||
{{ form.password }}
|
|
||||||
</div>
|
|
||||||
{% if form.errors %}
|
|
||||||
<div class="alert alert-danger">Usuario o contraseña incorrectos.</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if next %}
|
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="btn btn-dark w-100 btn-lg rounded-4">Entrar al POS</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-4 pt-3 border-top text-secondary small">
|
|
||||||
<div><strong>JWT API:</strong> <code>/api/auth/token/</code></div>
|
|
||||||
<div><strong>Perfil actual:</strong> <code>/api/auth/me/</code></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,82 +1,3 @@
|
|||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from .models import Order, Product, Table, UserRole
|
# Create your tests here.
|
||||||
|
|
||||||
|
|
||||||
class PosWorkflowTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.table = Table.objects.create(name="Mesa 1", seats=4)
|
|
||||||
self.product = Product.objects.create(name="Burger clásica", category="Cocina", price="12.50")
|
|
||||||
self.waiter = User.objects.create_user(username="mesero", password="clave12345")
|
|
||||||
UserRole.objects.filter(user=self.waiter).update(role=UserRole.Role.WAITER)
|
|
||||||
self.kitchen = User.objects.create_user(username="cocina", password="clave12345")
|
|
||||||
UserRole.objects.filter(user=self.kitchen).update(role=UserRole.Role.KITCHEN)
|
|
||||||
|
|
||||||
def test_home_requires_login(self):
|
|
||||||
response = self.client.get(reverse("home"))
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertIn(reverse("login"), response.url)
|
|
||||||
|
|
||||||
def test_add_item_creates_active_order_and_marks_table_occupied(self):
|
|
||||||
self.client.login(username="mesero", password="clave12345")
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("table_detail", args=[self.table.pk]),
|
|
||||||
{"product": self.product.pk, "quantity": 2, "note": "sin cebolla"},
|
|
||||||
follow=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.table.refresh_from_db()
|
|
||||||
order = Order.objects.get(table=self.table)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(order.status, Order.Status.OPEN)
|
|
||||||
self.assertEqual(order.items.count(), 1)
|
|
||||||
self.assertEqual(self.table.status, Table.Status.OCCUPIED)
|
|
||||||
|
|
||||||
def test_mark_paid_frees_table(self):
|
|
||||||
self.client.login(username="mesero", password="clave12345")
|
|
||||||
order = Order.objects.create(table=self.table, status=Order.Status.READY)
|
|
||||||
self.table.status = Table.Status.OCCUPIED
|
|
||||||
self.table.save(update_fields=["status", "updated_at"])
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("order_detail", args=[order.pk]),
|
|
||||||
{"status": Order.Status.PAID},
|
|
||||||
follow=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
order.refresh_from_db()
|
|
||||||
self.table.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(order.status, Order.Status.PAID)
|
|
||||||
self.assertEqual(self.table.status, Table.Status.FREE)
|
|
||||||
|
|
||||||
def test_kitchen_screen_blocks_waiter_role(self):
|
|
||||||
self.client.login(username="mesero", password="clave12345")
|
|
||||||
response = self.client.get(reverse("kitchen_board"), follow=True)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertRedirects(response, reverse("home"))
|
|
||||||
|
|
||||||
def test_kitchen_role_can_open_kds(self):
|
|
||||||
self.client.login(username="cocina", password="clave12345")
|
|
||||||
response = self.client.get(reverse("kitchen_board"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_jwt_login_and_me_endpoint(self):
|
|
||||||
api_client = APIClient()
|
|
||||||
token_response = api_client.post(
|
|
||||||
reverse("token_obtain_pair"),
|
|
||||||
{"username": "mesero", "password": "clave12345"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(token_response.status_code, 200)
|
|
||||||
self.assertIn("access", token_response.data)
|
|
||||||
|
|
||||||
api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {token_response.data['access']}")
|
|
||||||
me_response = api_client.get(reverse("current_user_api"))
|
|
||||||
self.assertEqual(me_response.status_code, 200)
|
|
||||||
self.assertEqual(me_response.data["role"], UserRole.Role.WAITER)
|
|
||||||
|
|||||||
22
core/urls.py
22
core/urls.py
@ -1,27 +1,7 @@
|
|||||||
from django.contrib.auth.views import LoginView, LogoutView
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
|
||||||
|
|
||||||
from .api import current_user_api
|
from .views import home
|
||||||
from .forms import LoginForm
|
|
||||||
from .views import home, kitchen_board, order_detail, table_detail
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
path(
|
|
||||||
"login/",
|
|
||||||
LoginView.as_view(
|
|
||||||
template_name="registration/login.html",
|
|
||||||
authentication_form=LoginForm,
|
|
||||||
redirect_authenticated_user=True,
|
|
||||||
),
|
|
||||||
name="login",
|
|
||||||
),
|
|
||||||
path("logout/", LogoutView.as_view(), name="logout"),
|
|
||||||
path("kitchen/", kitchen_board, name="kitchen_board"),
|
|
||||||
path("tables/<int:table_id>/", table_detail, name="table_detail"),
|
|
||||||
path("orders/<int:order_id>/", order_detail, name="order_detail"),
|
|
||||||
path("api/auth/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
|
||||||
path("api/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
|
||||||
path("api/auth/me/", current_user_api, name="current_user_api"),
|
|
||||||
]
|
]
|
||||||
|
|||||||
175
core/views.py
175
core/views.py
@ -1,162 +1,25 @@
|
|||||||
from functools import wraps
|
import os
|
||||||
from urllib.parse import quote
|
import platform
|
||||||
|
|
||||||
from django.contrib import messages
|
from django import get_version as django_version
|
||||||
from django.db import transaction
|
from django.shortcuts import render
|
||||||
from django.db.models import DecimalField, ExpressionWrapper, F, Prefetch, Sum
|
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .forms import AddOrderItemForm, OrderStatusForm
|
|
||||||
from .models import Order, Product, Table, UserRole
|
|
||||||
|
|
||||||
|
|
||||||
LINE_TOTAL = ExpressionWrapper(
|
|
||||||
F("items__quantity") * F("items__product__price"),
|
|
||||||
output_field=DecimalField(max_digits=10, decimal_places=2),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def role_required(*allowed_roles):
|
|
||||||
def decorator(view_func):
|
|
||||||
@wraps(view_func)
|
|
||||||
def wrapped(request, *args, **kwargs):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return redirect(f"/login/?next={quote(request.get_full_path())}")
|
|
||||||
|
|
||||||
resolved_role = UserRole.resolve_for(request.user)
|
|
||||||
if allowed_roles and resolved_role not in allowed_roles:
|
|
||||||
messages.error(request, "Tu rol no tiene acceso a esta sección.")
|
|
||||||
return redirect("home")
|
|
||||||
return view_func(request, *args, **kwargs)
|
|
||||||
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_context():
|
|
||||||
active_orders = Order.objects.exclude(status=Order.Status.PAID).prefetch_related("items__product").order_by("-created_at")
|
|
||||||
tables = Table.objects.prefetch_related(
|
|
||||||
Prefetch("orders", queryset=active_orders, to_attr="open_orders_cache")
|
|
||||||
)
|
|
||||||
recent_orders = Order.objects.select_related("table").prefetch_related("items__product")[:6]
|
|
||||||
kitchen_orders = (
|
|
||||||
Order.objects.filter(status__in=[Order.Status.OPEN, Order.Status.PREPARING])
|
|
||||||
.select_related("table")
|
|
||||||
.prefetch_related("items__product")[:5]
|
|
||||||
)
|
|
||||||
daily_revenue = (
|
|
||||||
Order.objects.filter(status=Order.Status.PAID, paid_at__date=timezone.localdate())
|
|
||||||
.aggregate(total=Sum(LINE_TOTAL))["total"]
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
top_products = (
|
|
||||||
Product.objects.filter(order_items__order__status=Order.Status.PAID)
|
|
||||||
.annotate(sold=Sum("order_items__quantity"))
|
|
||||||
.order_by("-sold", "name")[:4]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tables": tables,
|
|
||||||
"recent_orders": recent_orders,
|
|
||||||
"kitchen_orders": kitchen_orders,
|
|
||||||
"available_products": Product.objects.filter(is_available=True).count(),
|
|
||||||
"occupied_tables": Table.objects.filter(status=Table.Status.OCCUPIED).count(),
|
|
||||||
"daily_revenue": daily_revenue,
|
|
||||||
"top_products": top_products,
|
|
||||||
"meta_title": "Restaurante POS | Operación de mesas, cocina y cobro",
|
|
||||||
"meta_description": "Dashboard táctil para controlar mesas, comandas, cocina y cobro desde una sola interfaz ligera.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@role_required(UserRole.Role.ADMIN, UserRole.Role.WAITER, UserRole.Role.KITCHEN)
|
|
||||||
def home(request):
|
def home(request):
|
||||||
context = _dashboard_context()
|
"""Render the landing screen with loader and environment details."""
|
||||||
context["resolved_user_role"] = UserRole.label_for(request.user)
|
host_name = request.get_host().lower()
|
||||||
|
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"project_name": "New Style",
|
||||||
|
"agent_brand": agent_brand,
|
||||||
|
"django_version": django_version(),
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
"current_time": now,
|
||||||
|
"host_name": host_name,
|
||||||
|
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||||
|
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||||
|
}
|
||||||
return render(request, "core/index.html", context)
|
return render(request, "core/index.html", context)
|
||||||
|
|
||||||
|
|
||||||
@role_required(UserRole.Role.ADMIN, UserRole.Role.WAITER)
|
|
||||||
def table_detail(request, table_id):
|
|
||||||
table = get_object_or_404(
|
|
||||||
Table.objects.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"orders",
|
|
||||||
queryset=Order.objects.exclude(status=Order.Status.PAID).prefetch_related("items__product").order_by("-created_at"),
|
|
||||||
to_attr="open_orders_cache",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
pk=table_id,
|
|
||||||
)
|
|
||||||
current_order = table.current_order
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
form = AddOrderItemForm(request.POST)
|
|
||||||
if form.is_valid():
|
|
||||||
with transaction.atomic():
|
|
||||||
if current_order is None:
|
|
||||||
current_order = Order.objects.create(table=table)
|
|
||||||
item = form.save(commit=False)
|
|
||||||
item.order = current_order
|
|
||||||
item.save()
|
|
||||||
if table.status != Table.Status.OCCUPIED:
|
|
||||||
table.status = Table.Status.OCCUPIED
|
|
||||||
table.save(update_fields=["status", "updated_at"])
|
|
||||||
messages.success(request, f"Se agregó {item.product.name} a {table.name}.")
|
|
||||||
return redirect("table_detail", table_id=table.pk)
|
|
||||||
else:
|
|
||||||
form = AddOrderItemForm()
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"table": table,
|
|
||||||
"current_order": current_order,
|
|
||||||
"form": form,
|
|
||||||
"meta_title": f"{table.name} | Comanda activa",
|
|
||||||
"meta_description": f"Captura de comandas y notas para {table.name}.",
|
|
||||||
"resolved_user_role": UserRole.label_for(request.user),
|
|
||||||
}
|
|
||||||
return render(request, "core/table_detail.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@role_required(UserRole.Role.ADMIN, UserRole.Role.WAITER, UserRole.Role.KITCHEN)
|
|
||||||
def order_detail(request, order_id):
|
|
||||||
order = get_object_or_404(Order.objects.select_related("table").prefetch_related("items__product"), pk=order_id)
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
form = OrderStatusForm(request.POST, order=order)
|
|
||||||
if form.is_valid():
|
|
||||||
next_status = form.cleaned_data["status"]
|
|
||||||
with transaction.atomic():
|
|
||||||
order.advance_to(next_status)
|
|
||||||
messages.success(request, f"La orden #{order.pk} ahora está {order.get_status_display().lower()}.")
|
|
||||||
return redirect("order_detail", order_id=order.pk)
|
|
||||||
status_forms = {
|
|
||||||
status: OrderStatusForm(order=order, initial={"status": status})
|
|
||||||
for status in order.allowed_transitions()
|
|
||||||
}
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"order": order,
|
|
||||||
"status_forms": status_forms,
|
|
||||||
"meta_title": f"Orden #{order.pk} | {order.table.name}",
|
|
||||||
"meta_description": f"Detalle de la orden #{order.pk} con total, ítems y estado operativo.",
|
|
||||||
"resolved_user_role": UserRole.label_for(request.user),
|
|
||||||
}
|
|
||||||
return render(request, "core/order_detail.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@role_required(UserRole.Role.ADMIN, UserRole.Role.KITCHEN)
|
|
||||||
def kitchen_board(request):
|
|
||||||
orders = (
|
|
||||||
Order.objects.filter(status__in=[Order.Status.OPEN, Order.Status.PREPARING])
|
|
||||||
.select_related("table")
|
|
||||||
.prefetch_related("items__product")
|
|
||||||
)
|
|
||||||
context = {
|
|
||||||
"orders": orders,
|
|
||||||
"meta_title": "KDS | Cocina pendiente",
|
|
||||||
"meta_description": "Pantalla de cocina con órdenes pendientes de preparación.",
|
|
||||||
"resolved_user_role": UserRole.label_for(request.user),
|
|
||||||
}
|
|
||||||
return render(request, "core/kitchen.html", context)
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
Django==5.2.7
|
Django==5.2.7
|
||||||
mysqlclient==2.2.7
|
mysqlclient==2.2.7
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
djangorestframework==3.16.1
|
|
||||||
djangorestframework-simplejwt==5.5.1
|
|
||||||
|
|||||||
@ -1,597 +1,4 @@
|
|||||||
/* Restaurant POS custom theme */
|
/* Custom styles for the application */
|
||||||
:root {
|
body {
|
||||||
--pos-primary: #0f766e;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
--pos-primary-dark: #0b5d57;
|
|
||||||
--pos-secondary: #16324f;
|
|
||||||
--pos-accent: #f97316;
|
|
||||||
--pos-accent-soft: #fff1e8;
|
|
||||||
--pos-surface: #ffffff;
|
|
||||||
--pos-surface-soft: #f5f8fb;
|
|
||||||
--pos-surface-muted: #eef4f7;
|
|
||||||
--pos-border: #d6e1e8;
|
|
||||||
--pos-text: #11243d;
|
|
||||||
--pos-muted: #61728a;
|
|
||||||
--pos-success: #15803d;
|
|
||||||
--pos-shadow: 0 26px 65px rgba(17, 36, 61, 0.12);
|
|
||||||
--pos-radius-xl: 32px;
|
|
||||||
--pos-radius-lg: 24px;
|
|
||||||
--pos-radius-md: 18px;
|
|
||||||
--pos-radius-sm: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.pos-app {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
color: var(--pos-text);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 32%),
|
|
||||||
radial-gradient(circle at 85% 10%, rgba(249, 115, 22, 0.14), transparent 25%),
|
|
||||||
linear-gradient(180deg, #fcfefd 0%, #f4f8fb 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
.navbar-brand strong {
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p,
|
|
||||||
span,
|
|
||||||
a,
|
|
||||||
button,
|
|
||||||
label,
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-shell {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-header {
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
border-bottom: 1px solid rgba(214, 225, 232, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.85rem;
|
|
||||||
color: var(--pos-text);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand small {
|
|
||||||
display: block;
|
|
||||||
color: var(--pos-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-mark {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 16px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(135deg, var(--pos-primary) 0%, #15b39d 100%);
|
|
||||||
box-shadow: 0 16px 30px rgba(15, 118, 110, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--pos-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.7rem 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover,
|
|
||||||
.nav-link:focus {
|
|
||||||
color: var(--pos-secondary);
|
|
||||||
background: rgba(15, 118, 110, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-toggler {
|
|
||||||
border: 1px solid var(--pos-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pos-alert {
|
|
||||||
border-radius: var(--pos-radius-sm);
|
|
||||||
border: none;
|
|
||||||
box-shadow: var(--pos-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-section,
|
|
||||||
.dashboard-section,
|
|
||||||
.content-section {
|
|
||||||
padding: 2.5rem 0 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
|
||||||
gap: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy {
|
|
||||||
padding: 2rem 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--pos-primary-dark);
|
|
||||||
background: rgba(15, 118, 110, 0.11);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy h1,
|
|
||||||
.page-intro h1 {
|
|
||||||
font-size: clamp(2.8rem, 5vw, 4.7rem);
|
|
||||||
line-height: 0.97;
|
|
||||||
margin: 1.25rem 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-text,
|
|
||||||
.page-intro p,
|
|
||||||
.section-heading p,
|
|
||||||
.empty-state-card p,
|
|
||||||
.stack-item p,
|
|
||||||
.receipt-line p,
|
|
||||||
.kds-card p,
|
|
||||||
.table-order-preview p,
|
|
||||||
.empty-mini,
|
|
||||||
.metric-card span {
|
|
||||||
color: var(--pos-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-text {
|
|
||||||
max-width: 56ch;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions,
|
|
||||||
.page-actions,
|
|
||||||
.action-stack {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions {
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
border-radius: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0.88rem 1.35rem;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:focus-visible,
|
|
||||||
.form-control:focus,
|
|
||||||
.form-select:focus,
|
|
||||||
textarea:focus {
|
|
||||||
outline: 0;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-pos-primary {
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(135deg, var(--pos-primary) 0%, #18a58f 100%);
|
|
||||||
box-shadow: 0 18px 32px rgba(15, 118, 110, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-pos-primary:hover,
|
|
||||||
.btn-pos-primary:focus {
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(135deg, var(--pos-primary-dark) 0%, #108d79 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-pos-soft {
|
|
||||||
color: var(--pos-secondary);
|
|
||||||
background: rgba(255, 255, 255, 0.88);
|
|
||||||
border: 1px solid rgba(214, 225, 232, 0.95);
|
|
||||||
box-shadow: 0 14px 30px rgba(17, 36, 61, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-pos-soft:hover,
|
|
||||||
.btn-pos-soft:focus {
|
|
||||||
color: var(--pos-secondary);
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-kpis {
|
|
||||||
max-width: 760px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card,
|
|
||||||
.side-panel-card,
|
|
||||||
.panel-card,
|
|
||||||
.kds-card,
|
|
||||||
.empty-state-card,
|
|
||||||
.floating-card {
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
border: 1px solid rgba(214, 225, 232, 0.84);
|
|
||||||
box-shadow: var(--pos-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
height: 100%;
|
|
||||||
padding: 1.2rem 1.25rem;
|
|
||||||
border-radius: var(--pos-radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 1.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-visual {
|
|
||||||
position: relative;
|
|
||||||
min-height: 500px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-card {
|
|
||||||
position: absolute;
|
|
||||||
width: min(100%, 320px);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-card {
|
|
||||||
top: 2rem;
|
|
||||||
right: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary-card {
|
|
||||||
left: 0;
|
|
||||||
bottom: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-card strong {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-card p,
|
|
||||||
.stack-item p,
|
|
||||||
.kitchen-ticket p,
|
|
||||||
.receipt-line p,
|
|
||||||
.kds-items p {
|
|
||||||
margin: 0.4rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-label,
|
|
||||||
.table-label,
|
|
||||||
.text-link {
|
|
||||||
color: var(--pos-accent);
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 28px;
|
|
||||||
filter: blur(0.2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape-sphere {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(circle at 30% 30%, #ffffff 0%, #a7f3d0 42%, rgba(15, 118, 110, 0.1) 72%, transparent 75%);
|
|
||||||
bottom: 0;
|
|
||||||
right: 18%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape-cube {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.16), rgba(22, 50, 79, 0.12));
|
|
||||||
transform: rotate(18deg);
|
|
||||||
top: 0;
|
|
||||||
left: 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-muted {
|
|
||||||
background: linear-gradient(180deg, rgba(238, 244, 247, 0.55), rgba(255, 255, 255, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading,
|
|
||||||
.page-intro {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: end;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading.compact h2,
|
|
||||||
.panel-card h2,
|
|
||||||
.kds-card h2,
|
|
||||||
.table-card h3 {
|
|
||||||
margin: 0.45rem 0 0;
|
|
||||||
font-size: 1.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-grid,
|
|
||||||
.kds-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card,
|
|
||||||
.stack-item-link {
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card {
|
|
||||||
padding: 1.3rem;
|
|
||||||
border-radius: 24px;
|
|
||||||
min-height: 190px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
|
||||||
background: rgba(255, 255, 255, 0.88);
|
|
||||||
box-shadow: 0 18px 42px rgba(17, 36, 61, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card:hover,
|
|
||||||
.stack-item-link:hover,
|
|
||||||
.stack-item-link:focus {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 22px 46px rgba(17, 36, 61, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card.is-free {
|
|
||||||
border-color: rgba(21, 128, 61, 0.16);
|
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #f5fff8 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card.is-occupied {
|
|
||||||
border-color: rgba(249, 115, 22, 0.18);
|
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #fff8f3 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card-top,
|
|
||||||
.receipt-line,
|
|
||||||
.receipt-footer,
|
|
||||||
.stack-item,
|
|
||||||
.kitchen-ticket,
|
|
||||||
.workflow-steps {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card-top {
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-order-preview,
|
|
||||||
.empty-mini {
|
|
||||||
margin-top: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.5rem 0.85rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-free {
|
|
||||||
color: var(--pos-primary-dark);
|
|
||||||
background: rgba(15, 118, 110, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-busy {
|
|
||||||
color: #b45309;
|
|
||||||
background: rgba(249, 115, 22, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-ready {
|
|
||||||
color: var(--pos-success);
|
|
||||||
background: rgba(21, 128, 61, 0.13);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-panel-card,
|
|
||||||
.panel-card {
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: var(--pos-radius-lg);
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stack-list,
|
|
||||||
.kitchen-preview-list,
|
|
||||||
.receipt-lines,
|
|
||||||
.kds-items {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stack-item,
|
|
||||||
.kitchen-ticket,
|
|
||||||
.receipt-line,
|
|
||||||
.workflow-step,
|
|
||||||
.kds-items li {
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: var(--pos-surface-soft);
|
|
||||||
border: 1px solid rgba(214, 225, 232, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-line,
|
|
||||||
.stack-item,
|
|
||||||
.kitchen-ticket {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-line strong,
|
|
||||||
.stack-item strong,
|
|
||||||
.kds-items strong,
|
|
||||||
.kitchen-ticket strong {
|
|
||||||
font-size: 1.02rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-footer {
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 1.2rem;
|
|
||||||
padding-top: 1.2rem;
|
|
||||||
border-top: 1px solid rgba(214, 225, 232, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.receipt-footer.large strong {
|
|
||||||
font-size: 1.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro {
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-card .form-control,
|
|
||||||
.panel-card .form-select {
|
|
||||||
border-radius: 14px;
|
|
||||||
border-color: var(--pos-border);
|
|
||||||
padding: 0.95rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--pos-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-steps {
|
|
||||||
flex-direction: column;
|
|
||||||
margin-bottom: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-step {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--pos-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-step.active {
|
|
||||||
color: var(--pos-primary-dark);
|
|
||||||
background: rgba(15, 118, 110, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kds-items {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kds-card {
|
|
||||||
padding: 1.4rem;
|
|
||||||
border-radius: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-card {
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-card.small {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-card.centered {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paid-state {
|
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #f4fff7 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 991px) {
|
|
||||||
.hero-grid,
|
|
||||||
.section-heading,
|
|
||||||
.page-intro {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
display: grid;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-visual {
|
|
||||||
min-height: 360px;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-card {
|
|
||||||
right: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.hero-copy h1,
|
|
||||||
.page-intro h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-section,
|
|
||||||
.dashboard-section,
|
|
||||||
.content-section {
|
|
||||||
padding: 1.5rem 0 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions,
|
|
||||||
.page-actions,
|
|
||||||
.action-stack {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-card,
|
|
||||||
.secondary-card {
|
|
||||||
position: relative;
|
|
||||||
inset: auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-visual {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user