From d68d47648238f54fcae0d44039961e392cd39825 Mon Sep 17 00:00:00 2001 From: sandy Date: Wed, 1 Apr 2026 03:20:54 +0200 Subject: [PATCH] Initial commit --- .gitignore | 47 + README.md | 17 + backend/README.md | 27 + backend/apps/__init__.py | 1 + backend/apps/accounts/__init__.py | 1 + backend/apps/accounts/admin.py | 30 + backend/apps/accounts/apps.py | 7 + backend/apps/accounts/management/__init__.py | 1 + .../accounts/management/commands/__init__.py | 1 + .../management/commands/import_legacy_data.py | 541 ++++ .../apps/accounts/migrations/0001_initial.py | 131 + backend/apps/accounts/migrations/__init__.py | 1 + backend/apps/accounts/models.py | 86 + backend/apps/api/__init__.py | 1 + backend/apps/api/apps.py | 7 + backend/apps/api/migrations/__init__.py | 1 + backend/apps/api/urls.py | 32 + backend/apps/api/views.py | 1081 +++++++ backend/apps/core/__init__.py | 1 + backend/apps/core/admin.py | 19 + backend/apps/core/apps.py | 7 + backend/apps/core/migrations/0001_initial.py | 124 + backend/apps/core/migrations/__init__.py | 1 + backend/apps/core/models.py | 91 + backend/apps/notifications/__init__.py | 1 + backend/apps/notifications/admin.py | 5 + backend/apps/notifications/apps.py | 7 + .../notifications/migrations/0001_initial.py | 30 + .../apps/notifications/migrations/__init__.py | 1 + backend/apps/notifications/models.py | 14 + backend/apps/operations/__init__.py | 1 + backend/apps/operations/admin.py | 31 + backend/apps/operations/apps.py | 7 + .../operations/migrations/0001_initial.py | 274 ++ .../apps/operations/migrations/__init__.py | 1 + backend/apps/operations/models.py | 169 ++ backend/apps/operations/services.py | 187 ++ backend/apps/reporting/__init__.py | 1 + backend/apps/reporting/admin.py | 5 + backend/apps/reporting/apps.py | 7 + .../apps/reporting/migrations/0001_initial.py | 38 + backend/apps/reporting/migrations/__init__.py | 1 + backend/apps/reporting/models.py | 20 + backend/config/__init__.py | 1 + backend/config/asgi.py | 6 + backend/config/settings.py | 114 + backend/config/urls.py | 7 + backend/config/wsgi.py | 6 + backend/manage.py | 14 + backend/pyproject.toml | 23 + docs/port-plan.md | 41 + docs/session-handoff-2026-04-01.md | 1046 +++++++ frontend/components.json | 15 + frontend/package-lock.json | 2568 +++++++++++++++++ frontend/package.json | 31 + frontend/src/app.css | 38 + frontend/src/app.html | 11 + frontend/src/lib/api/client.ts | 197 ++ .../lib/components/app-shell/sidebar.svelte | 52 + frontend/src/lib/components/ui/button.svelte | 42 + frontend/src/lib/components/ui/card.svelte | 3 + frontend/src/lib/stores/auth.ts | 51 + frontend/src/lib/types/domain.ts | 306 ++ frontend/src/routes/+layout.svelte | 5 + frontend/src/routes/+page.ts | 5 + frontend/src/routes/app/+layout.svelte | 45 + frontend/src/routes/app/+layout.ts | 1 + .../src/routes/app/business/[id]/+page.svelte | 84 + .../src/routes/app/dashboard/+page.svelte | 74 + frontend/src/routes/app/devices/+page.svelte | 209 ++ frontend/src/routes/app/events/+page.svelte | 209 ++ .../src/routes/app/inventory/+page.svelte | 93 + frontend/src/routes/app/invoices/+page.svelte | 382 +++ frontend/src/routes/app/schedule/+page.svelte | 131 + frontend/src/routes/app/settings/+page.svelte | 253 ++ frontend/src/routes/app/vendors/+page.svelte | 230 ++ frontend/src/routes/login/+page.svelte | 84 + frontend/svelte.config.js | 10 + frontend/tsconfig.json | 14 + frontend/vite.config.ts | 7 + 80 files changed, 9464 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/README.md create mode 100644 backend/apps/__init__.py create mode 100644 backend/apps/accounts/__init__.py create mode 100644 backend/apps/accounts/admin.py create mode 100644 backend/apps/accounts/apps.py create mode 100644 backend/apps/accounts/management/__init__.py create mode 100644 backend/apps/accounts/management/commands/__init__.py create mode 100644 backend/apps/accounts/management/commands/import_legacy_data.py create mode 100644 backend/apps/accounts/migrations/0001_initial.py create mode 100644 backend/apps/accounts/migrations/__init__.py create mode 100644 backend/apps/accounts/models.py create mode 100644 backend/apps/api/__init__.py create mode 100644 backend/apps/api/apps.py create mode 100644 backend/apps/api/migrations/__init__.py create mode 100644 backend/apps/api/urls.py create mode 100644 backend/apps/api/views.py create mode 100644 backend/apps/core/__init__.py create mode 100644 backend/apps/core/admin.py create mode 100644 backend/apps/core/apps.py create mode 100644 backend/apps/core/migrations/0001_initial.py create mode 100644 backend/apps/core/migrations/__init__.py create mode 100644 backend/apps/core/models.py create mode 100644 backend/apps/notifications/__init__.py create mode 100644 backend/apps/notifications/admin.py create mode 100644 backend/apps/notifications/apps.py create mode 100644 backend/apps/notifications/migrations/0001_initial.py create mode 100644 backend/apps/notifications/migrations/__init__.py create mode 100644 backend/apps/notifications/models.py create mode 100644 backend/apps/operations/__init__.py create mode 100644 backend/apps/operations/admin.py create mode 100644 backend/apps/operations/apps.py create mode 100644 backend/apps/operations/migrations/0001_initial.py create mode 100644 backend/apps/operations/migrations/__init__.py create mode 100644 backend/apps/operations/models.py create mode 100644 backend/apps/operations/services.py create mode 100644 backend/apps/reporting/__init__.py create mode 100644 backend/apps/reporting/admin.py create mode 100644 backend/apps/reporting/apps.py create mode 100644 backend/apps/reporting/migrations/0001_initial.py create mode 100644 backend/apps/reporting/migrations/__init__.py create mode 100644 backend/apps/reporting/models.py create mode 100644 backend/config/__init__.py create mode 100644 backend/config/asgi.py create mode 100644 backend/config/settings.py create mode 100644 backend/config/urls.py create mode 100644 backend/config/wsgi.py create mode 100644 backend/manage.py create mode 100644 backend/pyproject.toml create mode 100644 docs/port-plan.md create mode 100644 docs/session-handoff-2026-04-01.md create mode 100644 frontend/components.json create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/app.css create mode 100644 frontend/src/app.html create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/components/app-shell/sidebar.svelte create mode 100644 frontend/src/lib/components/ui/button.svelte create mode 100644 frontend/src/lib/components/ui/card.svelte create mode 100644 frontend/src/lib/stores/auth.ts create mode 100644 frontend/src/lib/types/domain.ts create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/src/routes/+page.ts create mode 100644 frontend/src/routes/app/+layout.svelte create mode 100644 frontend/src/routes/app/+layout.ts create mode 100644 frontend/src/routes/app/business/[id]/+page.svelte create mode 100644 frontend/src/routes/app/dashboard/+page.svelte create mode 100644 frontend/src/routes/app/devices/+page.svelte create mode 100644 frontend/src/routes/app/events/+page.svelte create mode 100644 frontend/src/routes/app/inventory/+page.svelte create mode 100644 frontend/src/routes/app/invoices/+page.svelte create mode 100644 frontend/src/routes/app/schedule/+page.svelte create mode 100644 frontend/src/routes/app/settings/+page.svelte create mode 100644 frontend/src/routes/app/vendors/+page.svelte create mode 100644 frontend/src/routes/login/+page.svelte create mode 100644 frontend/svelte.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..376a232 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Virtual environments +venv/ +.venv/ +env/ + +# Django +backend/db.sqlite3 +backend/media/ +backend/staticfiles/ + +# Packaging +*.egg-info/ +dist/ +build/ + +# Node / Svelte / Vite +frontend/node_modules/ +frontend/.svelte-kit/ +frontend/build/ +frontend/dist/ +frontend/.vite/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment files +.env +.env.* +!.env.example + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1aa716 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Django Port Workspace + +This folder contains the rewrite target for the current FastAPI + React application. + +## What is here + +- [`backend`](/home/sandy/HUB-master/django-port/backend): Django project and domain apps +- [`frontend`](/home/sandy/HUB-master/django-port/frontend): SvelteKit frontend prepared for `shadcn-svelte` +- [`docs/port-plan.md`](/home/sandy/HUB-master/django-port/docs/port-plan.md): migration strategy and scope + +## What is intentionally not done + +- No package installation +- No Svelte or Django bootstrapping commands +- No heavy build, dev-server, or migration runs + +Everything committed here is code and structure only. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..27648a7 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,27 @@ +# Django Port + +This is the replacement backend for the legacy FastAPI app. + +## Shape + +- `config/`: Django project settings and URL root +- `apps/accounts`: users, roles, business access, device registration +- `apps/core`: businesses, vendors, products, categories +- `apps/operations`: invoices, inventory, events, schedules +- `apps/reporting`: dashboard-facing revenue aggregates +- `apps/notifications`: reminders and inbox items +- `apps/api`: JSON endpoints for the Svelte frontend + +## Expected setup + +1. Install dependencies from [`pyproject.toml`](/home/sandy/HUB-master/django-port/backend/pyproject.toml). +2. Run `python manage.py makemigrations`. +3. Run `python manage.py migrate`. +4. Run `python manage.py createsuperuser`. +5. Run `python manage.py import_legacy_data`. + +## Notes + +- Auth uses Django sessions instead of custom JWT cookies. +- The import command consolidates both legacy SQLite files into one Django schema. +- Password hashes are not carried over directly; imported users get a forced reset placeholder. diff --git a/backend/apps/__init__.py b/backend/apps/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/accounts/__init__.py b/backend/apps/accounts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/accounts/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py new file mode 100644 index 0000000..c7fa25c --- /dev/null +++ b/backend/apps/accounts/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from .models import ( + AllowedDevice, + DeviceRegistrationToken, + DomainPermission, + Role, + RolePermission, + User, + UserBusinessAccess, + UserDeviceLogin, +) + + +@admin.register(User) +class HubUserAdmin(UserAdmin): + fieldsets = UserAdmin.fieldsets + ( + ("Hub", {"fields": ("display_name", "role", "last_login_ip")}), + ) + list_display = ("username", "display_name", "role", "is_active", "last_login") + + +admin.site.register(Role) +admin.site.register(DomainPermission) +admin.site.register(RolePermission) +admin.site.register(UserBusinessAccess) +admin.site.register(UserDeviceLogin) +admin.site.register(AllowedDevice) +admin.site.register(DeviceRegistrationToken) diff --git a/backend/apps/accounts/apps.py b/backend/apps/accounts/apps.py new file mode 100644 index 0000000..7d07530 --- /dev/null +++ b/backend/apps/accounts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" + label = "accounts" diff --git a/backend/apps/accounts/management/__init__.py b/backend/apps/accounts/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/accounts/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/accounts/management/commands/__init__.py b/backend/apps/accounts/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/accounts/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/accounts/management/commands/import_legacy_data.py b/backend/apps/accounts/management/commands/import_legacy_data.py new file mode 100644 index 0000000..ce31d2a --- /dev/null +++ b/backend/apps/accounts/management/commands/import_legacy_data.py @@ -0,0 +1,541 @@ +import sqlite3 +from pathlib import Path + +from django.conf import settings +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils.dateparse import parse_date, parse_datetime +from django.utils.timezone import make_aware + +from apps.accounts.models import ( + AllowedDevice, + DeviceRegistrationToken, + DomainPermission, + Role, + RolePermission, + User, + UserBusinessAccess, + UserDeviceLogin, +) +from apps.core.models import Business, Category, Product, ProductCategory, Vendor, VendorBusiness, VendorCategory +from apps.notifications.models import Notification +from apps.operations.models import ( + Event, + InventoryBalance, + InventoryBulkMapping, + InventoryMovement, + Invoice, + InvoiceCategory, + InvoiceLineItem, + ShiftAssignment, + ShiftRole, + ShiftTemplate, + StockCount, + StockCountLine, + WorkerAvailability, +) +from apps.reporting.models import DailyRevenueSummary + + +def _dt(value): + if not value: + return None + parsed = parse_datetime(value) + if parsed is None: + return None + return make_aware(parsed) if parsed.tzinfo is None else parsed + + +def _date(value): + return parse_date(value) if value else None + + +class Command(BaseCommand): + help = "Import the legacy FastAPI SQLite databases into the Django schema." + + def add_arguments(self, parser): + parser.add_argument("--cincin", type=Path, default=settings.LEGACY_CINCIN_DB) + parser.add_argument("--dalcorso", type=Path, default=settings.LEGACY_DALCORSO_DB) + + @transaction.atomic + def handle(self, *args, **options): + cincin = options["cincin"] + dalcorso = options["dalcorso"] + if not cincin.exists(): + raise CommandError(f"CinCin DB not found: {cincin}") + if not dalcorso.exists(): + raise CommandError(f"Dal Corso DB not found: {dalcorso}") + + self._import_db(cincin, include_operational_data=True) + self._import_db(dalcorso, include_operational_data=False) + self.stdout.write(self.style.SUCCESS("Legacy import complete.")) + + def _connect(self, path: Path): + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + return conn + + def _rows(self, conn, query: str): + return conn.execute(query).fetchall() + + def _table_exists(self, conn, table_name: str) -> bool: + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name = ?", + (table_name,), + ).fetchone() + return row is not None + + def _import_db(self, path: Path, include_operational_data: bool): + self.business_map: dict[int, Business] = {} + self.category_map: dict[int, Category] = {} + self.vendor_map: dict[int, Vendor] = {} + self.product_map: dict[int, Product] = {} + self.user_map: dict[int, User] = {} + self.role_map: dict[int, Role] = {} + self.shift_role_map: dict[int, ShiftRole] = {} + self.shift_template_map: dict[int, ShiftTemplate] = {} + with self._connect(path) as conn: + self._import_businesses(conn) + self._import_categories(conn) + self._import_vendors(conn) + self._import_products(conn) + self._import_invoices(conn) + if include_operational_data: + self._import_accounts(conn) + self._import_devices(conn) + self._import_notifications(conn) + self._import_inventory(conn) + self._import_events_and_shifts(conn) + self._import_reporting(conn) + + def _import_businesses(self, conn): + for row in self._rows(conn, "SELECT * FROM dim_business"): + business, _ = Business.objects.update_or_create( + short_code=row["short_code"], + defaults={ + "legacy_id": row["business_id"], + "name": row["name"], + "currency": row["currency"], + "is_active": bool(row["is_active"]), + }, + ) + self.business_map[row["business_id"]] = business + + def _import_categories(self, conn): + for row in self._rows(conn, "SELECT * FROM dim_category"): + category, _ = Category.objects.update_or_create( + name=row["name"], + defaults={"legacy_id": row["category_id"], "is_active": bool(row["is_active"])}, + ) + self.category_map[row["category_id"]] = category + + def _import_vendors(self, conn): + for row in self._rows(conn, "SELECT * FROM dim_vendor"): + vendor, _ = Vendor.objects.update_or_create( + name=row["name"], + defaults={ + "legacy_id": row["vendor_id"], + "name": row["name"], + "vat_number": row["vat_number"] or "", + "registration_id": row["registration_id"] or "", + "contact_email": row["contact_email"] or "", + "contact_phone": row["contact_phone"] or "", + "address": row["address"] or "", + "notes": row["notes"] or "", + "is_active": bool(row["is_active"]), + }, + ) + self.vendor_map[row["vendor_id"]] = vendor + if self._table_exists(conn, "vendor_business"): + for link in self._rows(conn, f"SELECT business_id FROM vendor_business WHERE vendor_id = {row['vendor_id']}"): + business = self.business_map.get(link["business_id"]) + if business: + VendorBusiness.objects.get_or_create(vendor=vendor, business=business) + if self._table_exists(conn, "vendor_category"): + for link in self._rows(conn, f"SELECT category_id FROM vendor_category WHERE vendor_id = {row['vendor_id']}"): + category = self.category_map.get(link["category_id"]) + if category: + VendorCategory.objects.get_or_create(vendor=vendor, category=category) + + def _import_products(self, conn): + for row in self._rows(conn, "SELECT * FROM dim_product"): + lookup = {"gtin": row["gtin"] or "", "name": row["name"]} + product, _ = Product.objects.update_or_create( + **lookup, + defaults={ + "legacy_id": row["product_id"], + "name": row["name"], + "ledger": row["ledger"] or "", + "product_status": row["product_status"] or "", + "is_active": bool(row["is_active"]), + "tax_type": row["tax_type"] or "", + "vat_rate": row["vat_rate"] or 0, + "net_purchase_price": row["net_purchase_price"] or 0, + "display_sales_price": row["display_sales_price"] or 0, + "uom": row["uom"] or "pcs", + "amount": row["amount"] or 0, + "is_placeholder": bool(row["is_placeholder"]), + "short_name": row["short_name"] or "", + "currency_code": row["currency_code"] or "CZK", + }, + ) + self.product_map[row["product_id"]] = product + if self._table_exists(conn, "product_category"): + for link in self._rows(conn, f"SELECT category_id FROM product_category WHERE product_id = {row['product_id']}"): + category = self.category_map.get(link["category_id"]) + if category: + ProductCategory.objects.get_or_create(product=product, category=category) + + def _import_invoices(self, conn): + for row in self._rows(conn, "SELECT * FROM invoice"): + business = self.business_map.get(row["business_id"]) if row["business_id"] else None + vendor = self.vendor_map.get(row["vendor_id"]) + if vendor is None: + continue + invoice, _ = Invoice.objects.update_or_create( + legacy_id=row["invoice_id"], + defaults={ + "business": business, + "vendor": vendor, + "vendor_not_configured": bool(row["vendor_not_configured"]), + "vendor_config_note": row["vendor_config_note"] or "", + "invoice_number": row["invoice_number"] or "", + "invoice_date": _date(row["invoice_date"]), + "order_date": _date(row["order_date"]), + "entered_at": _dt(row["entered_at"]), + "payment_status": row["payment_status"], + "paid_date": _date(row["paid_date"]), + "due_date": _date(row["due_date"]), + "subtotal": row["subtotal"] or 0, + "discount_pct": row["discount_pct"] or 0, + "discount_amount": row["discount_amount"] or 0, + "total_after_discount": row["total_after_discount"] or 0, + "vat_amount": row["vat_amount"] or 0, + "gross_total": row["gross_total"] or 0, + "currency": row["currency"] or "CZK", + "goods_received_status": row["goods_received_status"], + "goods_date": _date(row["goods_date"]), + "notes": row["notes"] or "", + "is_editable": bool(row["is_editable"]), + "inventory_updated": bool(row["inventory_updated"]), + "rate_czk_eur": row["rate_czk_eur"] or 0, + "rate_czk_usd": row["rate_czk_usd"] or 0, + "vat_exempt": bool(row["vat_exempt"]), + "created_at": _dt(row["created_at"]), + "updated_at": _dt(row["updated_at"]), + }, + ) + for line in self._rows(conn, f"SELECT * FROM invoice_line_item WHERE invoice_id = {row['invoice_id']}"): + product = self.product_map.get(line["product_id"]) + if product is None: + continue + InvoiceLineItem.objects.update_or_create( + legacy_id=line["line_item_id"], + defaults={ + "invoice": invoice, + "product": product, + "quantity": line["quantity"], + "unit_price": line["unit_price"], + "total_price": line["total_price"], + "vat_rate": line["vat_rate"] or 0, + "vat_amount": line["vat_amount"] or 0, + "line_order": line["line_order"], + "created_at": _dt(line["created_at"]), + }, + ) + if self._table_exists(conn, "invoice_category"): + for link in self._rows(conn, f"SELECT category_id FROM invoice_category WHERE invoice_id = {row['invoice_id']}"): + category = self.category_map.get(link["category_id"]) + if category: + InvoiceCategory.objects.get_or_create(invoice=invoice, category=category) + + def _import_accounts(self, conn): + if not self._table_exists(conn, "app_role"): + return + for row in self._rows(conn, "SELECT * FROM app_role"): + role, _ = Role.objects.update_or_create( + name=row["name"], + defaults={ + "name": row["name"], + "description": row["description"] or "", + "is_system": bool(row["is_system"]), + }, + ) + self.role_map[row["role_id"]] = role + for row in self._rows(conn, "SELECT * FROM role_permission"): + permission, _ = DomainPermission.objects.get_or_create( + key=row["permission_key"], + defaults={"label": row["permission_key"], "group": row["permission_key"].split(".")[0].title()}, + ) + role = self.role_map.get(row["role_id"]) + if role: + RolePermission.objects.get_or_create(role=role, permission=permission) + for row in self._rows(conn, "SELECT * FROM app_user"): + user, _ = User.objects.update_or_create( + username=row["username"], + defaults={ + "password": make_password("imported-password-reset-required"), + "display_name": row["display_name"] or "", + "is_active": bool(row["is_active"]), + "role": self.role_map.get(row["role_id"]), + "last_login_ip": row["last_login_ip"], + "last_login": _dt(row["last_login_at"]), + }, + ) + self.user_map[row["user_id"]] = user + if self._table_exists(conn, "user_business"): + for link in self._rows(conn, f"SELECT business_id FROM user_business WHERE user_id = {row['user_id']}"): + business = self.business_map.get(link["business_id"]) + if business: + UserBusinessAccess.objects.get_or_create(user=user, business=business) + if not self._table_exists(conn, "user_device_login"): + return + for row in self._rows(conn, "SELECT * FROM user_device_login"): + user = self.user_map.get(row["user_id"]) + if user: + UserDeviceLogin.objects.update_or_create( + user=user, + ip_address=row["ip_address"], + defaults={"last_login_at": _dt(row["last_login_at"])}, + ) + + def _import_devices(self, conn): + if not self._table_exists(conn, "allowed_device"): + return + for row in self._rows(conn, "SELECT * FROM allowed_device"): + AllowedDevice.objects.update_or_create( + id=row["device_id"], + defaults={ + "ip_address": row["ip_address"], + "label": row["label"] or "", + "user_agent": row["user_agent"] or "", + "last_seen_at": _dt(row["last_seen_at"]), + "is_active": bool(row["is_active"]), + "ipv6_prefix": row["ipv6_prefix"] or "", + "device_token": row["device_token"] or "", + "known_ips": row["known_ips"] or "", + }, + ) + if not self._table_exists(conn, "device_registration_token"): + return + for row in self._rows(conn, "SELECT * FROM device_registration_token"): + DeviceRegistrationToken.objects.update_or_create( + id=row["token_id"], + defaults={ + "token": row["token"], + "label": row["label"] or "", + "expires_at": _dt(row["expires_at"]), + "used_at": _dt(row["used_at"]), + "used_by_ip": row["used_by_ip"], + "created_by": self.user_map.get(row["created_by"]), + }, + ) + + def _import_notifications(self, conn): + if not self._table_exists(conn, "notification"): + return + for row in self._rows(conn, "SELECT * FROM notification"): + Notification.objects.update_or_create( + legacy_id=row["notification_id"], + defaults={ + "type": row["type"], + "title": row["title"], + "message": row["message"], + "reference_type": row["reference_type"] or "", + "reference_id": row["reference_id"], + "is_read": bool(row["is_read"]), + "is_dismissed": bool(row["is_dismissed"]), + "remind_at": _dt(row["remind_at"]), + "created_at": _dt(row["created_at"]), + }, + ) + + def _import_inventory(self, conn): + if not self._table_exists(conn, "inventory_balance"): + return + for row in self._rows(conn, "SELECT * FROM inventory_balance"): + product = self.product_map.get(row["product_id"]) + if product: + InventoryBalance.objects.update_or_create( + product=product, + defaults={ + "quantity_on_hand": row["quantity_on_hand"], + "uom": row["uom"], + "last_updated_at": _dt(row["last_updated_at"]), + }, + ) + for row in self._rows(conn, "SELECT * FROM fact_inventory_movement"): + product = self.product_map.get(row["product_id"]) + sellable = self.product_map.get(row["sellable_product_id"]) + if product: + InventoryMovement.objects.update_or_create( + legacy_id=row["inventory_movement_id"], + defaults={ + "product": product, + "sellable_product": sellable, + "movement_ts": _dt(row["movement_ts"]), + "movement_date": _date(row["movement_date"]), + "movement_type": row["movement_type"], + "quantity_delta": row["quantity_delta"], + "uom": row["uom"], + "source_type": row["source_type"], + "source_ref": row["source_ref"] or "", + }, + ) + for row in self._rows(conn, "SELECT * FROM inventory_bulk_mapping"): + bulk = self.product_map.get(row["bulk_product_id"]) + sellable = self.product_map.get(row["sellable_product_id"]) + if bulk and sellable: + InventoryBulkMapping.objects.update_or_create( + legacy_id=row["mapping_id"], + defaults={ + "bulk_product": bulk, + "sellable_product": sellable, + "decrement_amount": row["decrement_amount"], + "decrement_uom": row["decrement_uom"] or "", + "chip_name": row["chip_name"] or "", + }, + ) + for row in self._rows(conn, "SELECT * FROM stock_count"): + business = self.business_map.get(row["business_id"]) if row["business_id"] else None + stock_count, _ = StockCount.objects.update_or_create( + legacy_id=row["stock_count_id"], + defaults={ + "business": business, + "counted_by": row["counted_by"] or "", + "count_date": _date(row["count_date"]), + "notes": row["notes"] or "", + "status": row["status"], + "created_at": _dt(row["created_at"]), + "completed_at": _dt(row["completed_at"]), + }, + ) + for line in self._rows(conn, f"SELECT * FROM stock_count_line WHERE stock_count_id = {row['stock_count_id']}"): + product = self.product_map.get(line["product_id"]) + if product: + StockCountLine.objects.update_or_create( + legacy_id=line["stock_count_line_id"], + defaults={ + "stock_count": stock_count, + "product": product, + "full_units": line["full_units"], + "partial_units": line["partial_units"], + "total_quantity": line["total_quantity"], + "previous_quantity": line["previous_quantity"] or 0, + "created_at": _dt(line["created_at"]), + }, + ) + + def _import_events_and_shifts(self, conn): + if not self._table_exists(conn, "event_schedule"): + return + for row in self._rows(conn, "SELECT * FROM event_schedule"): + business = self.business_map.get(row["business_id"]) + if business: + Event.objects.update_or_create( + legacy_id=row["event_id"], + defaults={ + "business": business, + "title": row["title"], + "description": row["description"] or "", + "event_type": row["event_type"] or "other", + "start_datetime": _dt(row["start_datetime"]), + "end_datetime": _dt(row["end_datetime"]), + "all_day": bool(row["all_day"]), + "location": row["location"] or "", + "color": row["color"] or "", + "recurrence_type": row["recurrence_type"] or "none", + "recurrence_end_date": _date(row["recurrence_end_date"]), + "created_by": self.user_map.get(row["created_by"]), + "is_active": bool(row["is_active"]), + }, + ) + for row in self._rows(conn, "SELECT * FROM shift_role"): + business = self.business_map.get(row["business_id"]) + if business: + shift_role, _ = ShiftRole.objects.update_or_create( + legacy_id=row["shift_role_id"], + defaults={ + "business": business, + "name": row["name"], + "color": row["color"] or "", + "sort_order": row["sort_order"], + "is_active": bool(row["is_active"]), + }, + ) + self.shift_role_map[row["shift_role_id"]] = shift_role + for row in self._rows(conn, "SELECT * FROM shift_template"): + business = self.business_map.get(row["business_id"]) + if business: + shift_template, _ = ShiftTemplate.objects.update_or_create( + legacy_id=row["shift_id"], + defaults={ + "business": business, + "name": row["name"], + "start_datetime": _dt(row["start_datetime"]), + "end_datetime": _dt(row["end_datetime"]), + "min_staff": row["min_staff"], + "max_staff": row["max_staff"], + "color": row["color"] or "", + "recurrence_type": row["recurrence_type"] or "none", + "recurrence_end_date": _date(row["recurrence_end_date"]), + "created_by": self.user_map.get(row["created_by"]), + "shift_role": self.shift_role_map.get(row["shift_role_id"]), + "is_active": bool(row["is_active"]), + }, + ) + self.shift_template_map[row["shift_id"]] = shift_template + for row in self._rows(conn, "SELECT * FROM shift_assignment"): + ShiftAssignment.objects.update_or_create( + legacy_id=row["assignment_id"], + defaults={ + "shift_template": self.shift_template_map.get(row["shift_id"]), + "user": self.user_map.get(row["user_id"]), + "occurrence_date": _date(row["occurrence_date"]), + "start_override": _dt(row["start_override"]), + "end_override": _dt(row["end_override"]), + "status": row["status"], + "notes": row["notes"] or "", + "notification_sent_at": _dt(row["notification_sent_at"]), + }, + ) + for row in self._rows(conn, "SELECT * FROM worker_availability"): + WorkerAvailability.objects.update_or_create( + legacy_id=row["availability_id"], + defaults={ + "user": self.user_map.get(row["user_id"]), + "business": self.business_map.get(row["business_id"]), + "date": _date(row["date"]), + "status": row["status"], + "note": row["note"] or "", + }, + ) + + def _import_reporting(self, conn): + if not self._table_exists(conn, "daily_revenue_summary"): + return + default_business = ( + Business.objects.filter(short_code__iexact="CINCIN").first() + or next(iter(self.business_map.values()), None) + ) + for row in self._rows(conn, "SELECT * FROM daily_revenue_summary"): + business = default_business + if business: + DailyRevenueSummary.objects.update_or_create( + business=business, + business_date=_date(row["business_date"]), + defaults={ + "sales_revenue": row["sales_revenue"] or 0, + "food_revenue": row["food_revenue"] or 0, + "alcohol_revenue": row["alcohol_revenue"] or 0, + "tips_payable": row["tips_payable"] or 0, + "card_receivable": row["card_receivable"] or 0, + "cash": row["cash"] or 0, + "vat_total": row["vat_total"] or 0, + "vat_reduced_12": row["vat_reduced_12"] or 0, + "vat_standard_21": row["vat_standard_21"] or 0, + "accounts_receivable_pending": row["accounts_receivable_pending"] or 0, + "ecom_payment_receivable": row["ecom_payment_receivable"] or 0, + }, + ) diff --git a/backend/apps/accounts/migrations/0001_initial.py b/backend/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..c423624 --- /dev/null +++ b/backend/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,131 @@ +# Generated by Django 5.2.12 on 2026-04-01 00:09 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AllowedDevice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip_address', models.GenericIPAddressField(unique=True)), + ('label', models.CharField(blank=True, max_length=255)), + ('user_agent', models.TextField(blank=True)), + ('registered_at', models.DateTimeField(auto_now_add=True)), + ('last_seen_at', models.DateTimeField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('ipv6_prefix', models.CharField(blank=True, max_length=120)), + ('device_token', models.CharField(blank=True, max_length=255, unique=True)), + ('known_ips', models.TextField(blank=True)), + ], + ), + migrations.CreateModel( + name='DomainPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=120, unique=True)), + ('label', models.CharField(max_length=200)), + ('group', models.CharField(max_length=120)), + ], + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.TextField(blank=True)), + ('is_system', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('display_name', models.CharField(blank=True, max_length=255)), + ('last_login_ip', models.GenericIPAddressField(blank=True, null=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='accounts.role')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='DeviceRegistrationToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=255, unique=True)), + ('label', models.CharField(blank=True, max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('used_at', models.DateTimeField(blank=True, null=True)), + ('used_by_ip', models.GenericIPAddressField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='RolePermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_links', to='accounts.domainpermission')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permission_links', to='accounts.role')), + ], + options={ + 'unique_together': {('role', 'permission')}, + }, + ), + migrations.CreateModel( + name='UserBusinessAccess', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_links', to='core.business')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_links', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'business')}, + }, + ), + migrations.CreateModel( + name='UserDeviceLogin', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip_address', models.GenericIPAddressField()), + ('last_login_at', models.DateTimeField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_logins', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'ip_address')}, + }, + ), + ] diff --git a/backend/apps/accounts/migrations/__init__.py b/backend/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/accounts/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py new file mode 100644 index 0000000..cc2f138 --- /dev/null +++ b/backend/apps/accounts/models.py @@ -0,0 +1,86 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class Role(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.TextField(blank=True) + is_system = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return self.name + + +class DomainPermission(models.Model): + key = models.CharField(max_length=120, unique=True) + label = models.CharField(max_length=200) + group = models.CharField(max_length=120) + + def __str__(self) -> str: + return self.key + + +class RolePermission(models.Model): + role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="permission_links") + permission = models.ForeignKey(DomainPermission, on_delete=models.CASCADE, related_name="role_links") + + class Meta: + unique_together = ("role", "permission") + + +class User(AbstractUser): + display_name = models.CharField(max_length=255, blank=True) + role = models.ForeignKey(Role, null=True, blank=True, on_delete=models.SET_NULL, related_name="users") + last_login_ip = models.GenericIPAddressField(null=True, blank=True) + + def permission_keys(self) -> list[str]: + if not self.role_id: + return [] + return list( + self.role.permission_links.select_related("permission") + .order_by("permission__group", "permission__key") + .values_list("permission__key", flat=True) + ) + + def has_domain_permission(self, key: str) -> bool: + return key in self.permission_keys() + + +class UserBusinessAccess(models.Model): + user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="business_links") + business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="user_links") + + class Meta: + unique_together = ("user", "business") + + +class UserDeviceLogin(models.Model): + user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="device_logins") + ip_address = models.GenericIPAddressField() + last_login_at = models.DateTimeField() + + class Meta: + unique_together = ("user", "ip_address") + + +class AllowedDevice(models.Model): + ip_address = models.GenericIPAddressField(unique=True) + label = models.CharField(max_length=255, blank=True) + user_agent = models.TextField(blank=True) + registered_at = models.DateTimeField(auto_now_add=True) + last_seen_at = models.DateTimeField(null=True, blank=True) + is_active = models.BooleanField(default=True) + ipv6_prefix = models.CharField(max_length=120, blank=True) + device_token = models.CharField(max_length=255, blank=True, unique=True) + known_ips = models.TextField(blank=True) + + +class DeviceRegistrationToken(models.Model): + token = models.CharField(max_length=255, unique=True) + label = models.CharField(max_length=255, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + used_at = models.DateTimeField(null=True, blank=True) + used_by_ip = models.GenericIPAddressField(null=True, blank=True) + created_by = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL) diff --git a/backend/apps/api/__init__.py b/backend/apps/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/api/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/api/apps.py b/backend/apps/api/apps.py new file mode 100644 index 0000000..243781c --- /dev/null +++ b/backend/apps/api/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.api" + label = "api" diff --git a/backend/apps/api/migrations/__init__.py b/backend/apps/api/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/api/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/api/urls.py b/backend/apps/api/urls.py new file mode 100644 index 0000000..6b0cf54 --- /dev/null +++ b/backend/apps/api/urls.py @@ -0,0 +1,32 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("auth/login/", views.login_view), + path("auth/logout/", views.logout_view), + path("auth/me/", views.me_view), + path("auth/csrf/", views.csrf_view), + path("businesses/", views.businesses_view), + path("businesses//summary/", views.business_summary_view), + path("products/", views.products_view), + path("categories/", views.categories_view), + path("dashboard/overview/", views.dashboard_overview_view), + path("dashboard/business-summary/", views.dashboard_business_summary_view), + path("vendors/", views.vendors_view), + path("vendors//", views.vendor_detail_view), + path("invoices/", views.invoices_view), + path("invoices//", views.invoice_detail_view), + path("inventory/", views.inventory_view), + path("events/", views.events_view), + path("events//", views.event_detail_view), + path("schedule/overview/", views.schedule_overview_view), + path("settings/overview/", views.settings_overview_view), + path("settings/users/", views.users_view), + path("settings/users//", views.user_detail_view), + path("devices/", views.devices_view), + path("devices//", views.device_detail_view), + path("devices/tokens/", views.device_tokens_view), + path("devices/tokens//", views.device_token_detail_view), + path("notifications/", views.notifications_view), +] diff --git a/backend/apps/api/views.py b/backend/apps/api/views.py new file mode 100644 index 0000000..e6a1aa6 --- /dev/null +++ b/backend/apps/api/views.py @@ -0,0 +1,1081 @@ +import json +import secrets +from functools import wraps +from decimal import Decimal +from datetime import timedelta + +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.hashers import make_password +from django.db import models +from django.db.models import Count, Q, Sum +from django.http import HttpRequest, JsonResponse +from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from django.views.decorators.http import require_GET, require_http_methods + +from apps.accounts.models import AllowedDevice, DeviceRegistrationToken, DomainPermission, Role, User, UserBusinessAccess +from apps.core.models import Business, Category, Product, ProductCategory, Vendor, VendorBusiness, VendorCategory +from apps.notifications.models import Notification +from apps.operations.models import Event, InventoryBalance, Invoice, ShiftAssignment, ShiftRole, ShiftTemplate +from apps.operations.services import build_invoice_payload, create_invoice_from_payload, update_invoice_from_payload +from apps.reporting.models import DailyRevenueSummary + + +def _json_body(request: HttpRequest) -> dict: + return json.loads(request.body.decode("utf-8") or "{}") + + +def _money(value) -> float: + return float(value or Decimal("0")) + + +def _bad_request(message: str) -> JsonResponse: + return JsonResponse({"detail": message}, status=400) + + +def _forbidden(message: str = "Permission denied", missing_permissions: list[str] | None = None) -> JsonResponse: + payload = {"detail": message} + if missing_permissions is not None: + payload["missing_permissions"] = missing_permissions + return JsonResponse(payload, status=403) + + +def api_login_required(view_func): + @wraps(view_func) + def _wrapped(request: HttpRequest, *args, **kwargs): + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + return view_func(request, *args, **kwargs) + + return _wrapped + + +def require_permissions(*required_permissions: str): + def decorator(view_func): + @wraps(view_func) + def _wrapped(request: HttpRequest, *args, **kwargs): + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if request.user.is_superuser: + return view_func(request, *args, **kwargs) + granted = set(request.user.permission_keys()) + missing = [permission for permission in required_permissions if permission not in granted] + if missing: + return JsonResponse( + {"detail": "Permission denied", "missing_permissions": missing}, + status=403, + ) + return view_func(request, *args, **kwargs) + + return _wrapped + + return decorator + + +def _user_payload(user) -> dict: + return { + "id": user.id, + "username": user.username, + "display_name": user.display_name, + "role": user.role.name if user.role_id else None, + "is_superuser": user.is_superuser, + "permission_keys": user.permission_keys(), + "allowed_business_ids": list(user.business_links.values_list("business_id", flat=True)), + } + + +def _allowed_business_ids(user: User) -> set[int]: + if user.is_superuser: + return set(Business.objects.filter(is_active=True).values_list("id", flat=True)) + return set(user.business_links.values_list("business_id", flat=True)) + + +def _ensure_business_access(request: HttpRequest, business_id: int | None) -> JsonResponse | None: + if business_id is None or request.user.is_superuser: + return None + if business_id not in _allowed_business_ids(request.user): + return _forbidden("Business access denied") + return None + + +def _scoped_vendors_queryset(user: User, queryset=None): + qs = queryset if queryset is not None else Vendor.objects.all() + if user.is_superuser: + return qs + return qs.filter(business_links__business_id__in=_allowed_business_ids(user)).distinct() + + +def _scoped_categories_queryset(user: User, queryset=None): + qs = queryset if queryset is not None else Category.objects.all() + if user.is_superuser: + return qs + return qs.filter(vendor_links__vendor__business_links__business_id__in=_allowed_business_ids(user)).distinct() + + +def _scoped_products_queryset(user: User, queryset=None): + qs = queryset if queryset is not None else Product.objects.all() + if user.is_superuser: + return qs + return qs.filter( + category_links__category__vendor_links__vendor__business_links__business_id__in=_allowed_business_ids(user) + ).distinct() + + +def _scoped_invoices_queryset(user: User, queryset=None): + qs = queryset if queryset is not None else Invoice.objects.all() + if user.is_superuser: + return qs + return qs.filter(business_id__in=_allowed_business_ids(user)) + + +def _vendor_is_accessible(user: User, vendor: Vendor) -> bool: + if user.is_superuser: + return True + return vendor.business_links.filter(business_id__in=_allowed_business_ids(user)).exists() + + +def _validate_invoice_payload_access(request: HttpRequest, data: dict) -> JsonResponse | None: + business_id = data.get("business_id") + if business_id not in (None, ""): + if denied := _ensure_business_access(request, int(business_id)): + return denied + + vendor_id = data.get("vendor_id") + if vendor_id in (None, ""): + return _bad_request("vendor_id is required") + try: + vendor = Vendor.objects.prefetch_related("business_links").get(pk=int(vendor_id)) + except Vendor.DoesNotExist: + return _bad_request("Unknown vendor_id") + if not _vendor_is_accessible(request.user, vendor): + return _forbidden("Vendor access denied") + + if not request.user.is_superuser: + category_ids = [int(category_id) for category_id in data.get("category_ids", [])] + allowed_category_ids = set( + _scoped_categories_queryset(request.user).filter(id__in=category_ids).values_list("id", flat=True) + ) + if len(allowed_category_ids) != len(set(category_ids)): + return _forbidden("Category access denied") + + product_ids = [int(item["product_id"]) for item in data.get("line_items", []) if item.get("product_id")] + allowed_product_ids = set( + _scoped_products_queryset(request.user).filter(id__in=product_ids).values_list("id", flat=True) + ) + if len(allowed_product_ids) != len(set(product_ids)): + return _forbidden("Product access denied") + + return None + + +def _vendor_payload(vendor: Vendor) -> dict: + return { + "id": vendor.id, + "legacy_id": vendor.legacy_id, + "name": vendor.name, + "vat_number": vendor.vat_number, + "registration_id": vendor.registration_id, + "contact_email": vendor.contact_email, + "contact_phone": vendor.contact_phone, + "address": vendor.address, + "notes": vendor.notes, + "is_active": vendor.is_active, + "business_ids": list(vendor.business_links.values_list("business_id", flat=True)), + "category_ids": list(vendor.category_links.values_list("category_id", flat=True)), + } + + +def _business_summary_payload(business: Business) -> dict: + invoice_qs = Invoice.objects.filter(business=business).exclude(payment_status="void") + revenue_qs = DailyRevenueSummary.objects.filter(business=business) + inventory_qs = InventoryBalance.objects.filter( + product__category_links__category__vendor_links__vendor__business_links__business=business + ).distinct() + + recent_invoices = [ + { + "id": invoice.id, + "vendor_name": invoice.vendor.name, + "invoice_number": invoice.invoice_number, + "gross_total": _money(invoice.gross_total), + "payment_status": invoice.payment_status, + "due_date": invoice.due_date.isoformat() if invoice.due_date else None, + } + for invoice in invoice_qs.select_related("vendor").order_by("-created_at")[:5] + ] + + return { + "business": { + "id": business.id, + "legacy_id": business.legacy_id, + "name": business.name, + "short_code": business.short_code, + "currency": business.currency, + }, + "stats": { + "invoice_count": invoice_qs.count(), + "outstanding_invoices": invoice_qs.filter(payment_status="unpaid").count(), + "total_expenses": _money(invoice_qs.aggregate(total=Sum("gross_total"))["total"]), + "total_revenue": _money(revenue_qs.aggregate(total=Sum("sales_revenue"))["total"]), + "total_vat": _money(revenue_qs.aggregate(total=Sum("vat_total"))["total"]), + "inventory_items": inventory_qs.count(), + }, + "recent_revenue": [ + { + "business_date": row.business_date.isoformat(), + "sales_revenue": _money(row.sales_revenue), + "food_revenue": _money(row.food_revenue), + "alcohol_revenue": _money(row.alcohol_revenue), + "tips_payable": _money(row.tips_payable), + "vat_total": _money(row.vat_total), + } + for row in revenue_qs.order_by("-business_date")[:14] + ][::-1], + "recent_invoices": recent_invoices, + } + + +def _event_payload(event: Event) -> dict: + return { + "id": event.id, + "legacy_id": event.legacy_id, + "business_id": event.business_id, + "business_name": event.business.name, + "title": event.title, + "description": event.description, + "event_type": event.event_type, + "start_datetime": event.start_datetime.isoformat(), + "end_datetime": event.end_datetime.isoformat(), + "all_day": event.all_day, + "location": event.location, + "color": event.color, + "recurrence_type": event.recurrence_type, + "recurrence_end_date": event.recurrence_end_date.isoformat() if event.recurrence_end_date else None, + "created_by": event.created_by.display_name or event.created_by.username if event.created_by else None, + "is_active": event.is_active, + } + + +def _user_admin_payload(user: User) -> dict: + return { + "id": user.id, + "username": user.username, + "display_name": user.display_name, + "email": user.email, + "is_active": user.is_active, + "is_superuser": user.is_superuser, + "role_id": user.role_id, + "role_name": user.role.name if user.role_id else None, + "last_login": user.last_login.isoformat() if user.last_login else None, + "last_login_ip": user.last_login_ip, + "business_ids": list(user.business_links.values_list("business_id", flat=True)), + } + + +def _device_payload(device: AllowedDevice) -> dict: + return { + "id": device.id, + "ip_address": device.ip_address, + "label": device.label, + "user_agent": device.user_agent, + "registered_at": device.registered_at.isoformat() if device.registered_at else None, + "last_seen_at": device.last_seen_at.isoformat() if device.last_seen_at else None, + "is_active": device.is_active, + "ipv6_prefix": device.ipv6_prefix, + "device_token": device.device_token, + "known_ips": [ip.strip() for ip in device.known_ips.split(",") if ip.strip()], + } + + +def _device_token_payload(token: DeviceRegistrationToken) -> dict: + return { + "id": token.id, + "token": token.token, + "label": token.label, + "created_at": token.created_at.isoformat(), + "expires_at": token.expires_at.isoformat(), + "used_at": token.used_at.isoformat() if token.used_at else None, + "used_by_ip": token.used_by_ip, + "created_by": token.created_by.display_name or token.created_by.username if token.created_by else None, + } + + +@ensure_csrf_cookie +@require_GET +def csrf_view(request: HttpRequest) -> JsonResponse: + return JsonResponse({"detail": "CSRF cookie set"}) + + +@csrf_exempt +@require_http_methods(["POST"]) +def login_view(request: HttpRequest) -> JsonResponse: + data = _json_body(request) + user = authenticate(request, username=data.get("username"), password=data.get("password")) + if user is None or not user.is_active: + return JsonResponse({"detail": "Invalid credentials"}, status=401) + login(request, user) + return JsonResponse({"user": _user_payload(user)}) + + +@require_http_methods(["POST"]) +def logout_view(request: HttpRequest) -> JsonResponse: + logout(request) + return JsonResponse({"detail": "Logged out"}) + + +@api_login_required +@require_GET +def me_view(request: HttpRequest) -> JsonResponse: + return JsonResponse({"user": _user_payload(request.user)}) + + +@require_permissions("dashboard.view") +@require_GET +def businesses_view(request: HttpRequest) -> JsonResponse: + allowed_business_ids = _allowed_business_ids(request.user) + businesses = [ + { + "id": business.id, + "legacy_id": business.legacy_id, + "name": business.name, + "short_code": business.short_code, + "currency": business.currency, + } + for business in Business.objects.filter(is_active=True, id__in=allowed_business_ids).order_by("name") + ] + return JsonResponse({"results": businesses}) + + +@require_permissions("products.view") +@require_GET +def products_view(request: HttpRequest) -> JsonResponse: + search = request.GET.get("q", "").strip() + products = _scoped_products_queryset(request.user, Product.objects.filter(is_active=True)) + if search: + products = products.filter(models.Q(name__icontains=search) | models.Q(gtin__icontains=search)) + results = [ + { + "id": product.id, + "legacy_id": product.legacy_id, + "name": product.name, + "gtin": product.gtin, + "vat_rate": _money(product.vat_rate), + "uom": product.uom, + "currency_code": product.currency_code, + "category_ids": list(product.category_links.values_list("category_id", flat=True)), + "net_purchase_price": _money(product.net_purchase_price), + "display_sales_price": _money(product.display_sales_price), + } + for product in products.order_by("name")[:100] + ] + return JsonResponse({"results": results}) + + +@require_permissions("categories.manage") +@require_GET +def categories_view(request: HttpRequest) -> JsonResponse: + results = [ + {"id": category.id, "legacy_id": category.legacy_id, "name": category.name} + for category in _scoped_categories_queryset(request.user, Category.objects.filter(is_active=True)).order_by("name") + ] + return JsonResponse({"results": results}) + + +@require_permissions("dashboard.view") +@require_GET +def dashboard_overview_view(request: HttpRequest) -> JsonResponse: + allowed_business_ids = _allowed_business_ids(request.user) + invoices = _scoped_invoices_queryset(request.user, Invoice.objects.exclude(payment_status="void")) + revenue = DailyRevenueSummary.objects.filter(business_id__in=allowed_business_ids) + payload = { + "total_revenue": _money(revenue.aggregate(total=Sum("sales_revenue"))["total"]), + "total_expenses": _money(invoices.aggregate(total=Sum("gross_total"))["total"]), + "outstanding_invoices": invoices.filter(payment_status="unpaid").count(), + "business_count": Business.objects.filter(is_active=True, id__in=allowed_business_ids).count(), + "vendor_count": _scoped_vendors_queryset(request.user, Vendor.objects.filter(is_active=True)).count(), + "invoice_count": invoices.count(), + "total_vat": _money(revenue.aggregate(total=Sum("vat_total"))["total"]), + "unread_notifications": Notification.objects.filter(is_read=False, is_dismissed=False).count(), + } + return JsonResponse(payload) + + +@require_permissions("dashboard.view") +@require_GET +def dashboard_business_summary_view(request: HttpRequest) -> JsonResponse: + businesses = Business.objects.filter(is_active=True, id__in=_allowed_business_ids(request.user)).order_by("name") + results = [] + for business in businesses: + invoice_qs = Invoice.objects.filter(business=business).exclude(payment_status="void") + revenue_qs = DailyRevenueSummary.objects.filter(business=business) + results.append( + { + "business_id": business.id, + "business_name": business.name, + "short_code": business.short_code, + "currency": business.currency, + "total_revenue": _money(revenue_qs.aggregate(total=Sum("sales_revenue"))["total"]), + "total_expenses": _money(invoice_qs.aggregate(total=Sum("gross_total"))["total"]), + "outstanding_invoices": invoice_qs.filter(payment_status="unpaid").count(), + "invoice_count": invoice_qs.count(), + } + ) + return JsonResponse({"results": results}) + + +@require_permissions("dashboard.view") +@require_GET +def business_summary_view(request: HttpRequest, business_id: int) -> JsonResponse: + try: + business = Business.objects.get(pk=business_id, is_active=True) + except Business.DoesNotExist: + return JsonResponse({"detail": "Business not found"}, status=404) + if denied := _ensure_business_access(request, business.id): + return denied + return JsonResponse(_business_summary_payload(business)) + + +@require_http_methods(["GET", "POST"]) +def vendors_view(request: HttpRequest) -> JsonResponse: + if request.method == "GET": + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "vendors.view" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["vendors.view"]}, status=403) + query = request.GET.get("q", "").strip() + business_id = request.GET.get("business_id") + category_id = request.GET.get("category_id") + vendors = _scoped_vendors_queryset( + request.user, + Vendor.objects.all().prefetch_related("business_links", "category_links"), + ) + if query: + vendors = vendors.filter(Q(name__icontains=query) | Q(contact_email__icontains=query)) + if business_id: + if denied := _ensure_business_access(request, int(business_id)): + return denied + vendors = vendors.filter(business_links__business_id=business_id) + if category_id: + vendors = vendors.filter(category_links__category_id=category_id) + vendors = vendors.distinct().order_by("name") + vendors = [_vendor_payload(vendor) for vendor in vendors] + return JsonResponse({"results": vendors}) + + data = _json_body(request) + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "vendors.create" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["vendors.create"]}, status=403) + if not data.get("name"): + return _bad_request("Vendor name is required") + vendor = Vendor.objects.create( + name=data["name"], + vat_number=data.get("vat_number", ""), + registration_id=data.get("registration_id", ""), + contact_email=data.get("contact_email", ""), + contact_phone=data.get("contact_phone", ""), + address=data.get("address", ""), + notes=data.get("notes", ""), + is_active=data.get("is_active", True), + ) + if business_ids := data.get("business_ids"): + for business_id in business_ids: + if denied := _ensure_business_access(request, int(business_id)): + vendor.delete() + return denied + VendorBusiness.objects.bulk_create( + [VendorBusiness(vendor=vendor, business_id=int(business_id)) for business_id in business_ids], + ignore_conflicts=True, + ) + if category_ids := data.get("category_ids"): + VendorCategory.objects.bulk_create( + [VendorCategory(vendor=vendor, category_id=int(category_id)) for category_id in category_ids], + ignore_conflicts=True, + ) + vendor = Vendor.objects.prefetch_related("business_links", "category_links").get(pk=vendor.pk) + return JsonResponse(_vendor_payload(vendor), status=201) + + +@require_http_methods(["GET", "PUT"]) +def vendor_detail_view(request: HttpRequest, vendor_id: int) -> JsonResponse: + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + try: + vendor = Vendor.objects.prefetch_related("business_links", "category_links").get(pk=vendor_id) + except Vendor.DoesNotExist: + return JsonResponse({"detail": "Vendor not found"}, status=404) + + if request.method == "GET": + if not request.user.is_superuser and "vendors.view" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["vendors.view"]}, status=403) + if not _vendor_is_accessible(request.user, vendor): + return _forbidden("Vendor access denied") + return JsonResponse(_vendor_payload(vendor)) + + if not request.user.is_superuser and "vendors.edit" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["vendors.edit"]}, status=403) + if not _vendor_is_accessible(request.user, vendor): + return _forbidden("Vendor access denied") + data = _json_body(request) + vendor.name = data.get("name", vendor.name) + vendor.vat_number = data.get("vat_number", vendor.vat_number) + vendor.registration_id = data.get("registration_id", vendor.registration_id) + vendor.contact_email = data.get("contact_email", vendor.contact_email) + vendor.contact_phone = data.get("contact_phone", vendor.contact_phone) + vendor.address = data.get("address", vendor.address) + vendor.notes = data.get("notes", vendor.notes) + if "is_active" in data: + vendor.is_active = bool(data["is_active"]) + vendor.save() + + if "business_ids" in data: + for business_id in data["business_ids"]: + if denied := _ensure_business_access(request, int(business_id)): + return denied + vendor.business_links.all().delete() + VendorBusiness.objects.bulk_create( + [VendorBusiness(vendor=vendor, business_id=int(business_id)) for business_id in data["business_ids"]], + ignore_conflicts=True, + ) + if "category_ids" in data: + vendor.category_links.all().delete() + VendorCategory.objects.bulk_create( + [VendorCategory(vendor=vendor, category_id=int(category_id)) for category_id in data["category_ids"]], + ignore_conflicts=True, + ) + + vendor = Vendor.objects.prefetch_related("business_links", "category_links").get(pk=vendor.pk) + return JsonResponse(_vendor_payload(vendor)) + + +@require_http_methods(["GET", "POST"]) +def invoices_view(request: HttpRequest) -> JsonResponse: + if request.method == "GET": + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "invoices.view" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["invoices.view"]}, status=403) + invoices = _scoped_invoices_queryset( + request.user, + Invoice.objects.select_related("business", "vendor").prefetch_related( + "line_items__product", "category_links__category" + ), + ) + if business_id := request.GET.get("business_id"): + if denied := _ensure_business_access(request, int(business_id)): + return denied + invoices = invoices.filter(business_id=business_id) + if vendor_id := request.GET.get("vendor_id"): + invoices = invoices.filter(vendor_id=vendor_id) + if payment_status := request.GET.get("payment_status"): + invoices = invoices.filter(payment_status=payment_status) + if query := request.GET.get("q", "").strip(): + invoices = invoices.filter( + Q(invoice_number__icontains=query) + | Q(vendor__name__icontains=query) + | Q(notes__icontains=query) + ) + results = [build_invoice_payload(invoice) for invoice in invoices.order_by("-created_at")[:100]] + return JsonResponse({"results": results}) + + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "invoices.create" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["invoices.create"]}, status=403) + data = _json_body(request) + if denied := _validate_invoice_payload_access(request, data): + return denied + try: + invoice = create_invoice_from_payload(data) + except (KeyError, TypeError, ValueError) as exc: + return _bad_request(str(exc)) + invoice = ( + Invoice.objects.select_related("business", "vendor") + .prefetch_related("line_items__product", "category_links__category") + .get(pk=invoice.pk) + ) + return JsonResponse(build_invoice_payload(invoice), status=201) + + +@require_http_methods(["GET", "PUT", "DELETE"]) +def invoice_detail_view(request: HttpRequest, invoice_id: int) -> JsonResponse: + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + try: + invoice = _scoped_invoices_queryset( + request.user, + Invoice.objects.select_related("business", "vendor").prefetch_related( + "line_items__product", "category_links__category" + ), + ).get(pk=invoice_id) + except Invoice.DoesNotExist: + return JsonResponse({"detail": "Invoice not found"}, status=404) + + if request.method == "GET": + if not request.user.is_superuser and "invoices.view" not in set(request.user.permission_keys()): + return _forbidden(missing_permissions=["invoices.view"]) + return JsonResponse(build_invoice_payload(invoice)) + + if request.method == "DELETE": + if not request.user.is_superuser and "invoices.delete" not in set(request.user.permission_keys()): + return _forbidden(missing_permissions=["invoices.delete"]) + invoice.delete() + return JsonResponse({"detail": "Invoice deleted"}) + + if not request.user.is_superuser and "invoices.edit" not in set(request.user.permission_keys()): + return _forbidden(missing_permissions=["invoices.edit"]) + data = _json_body(request) + if denied := _validate_invoice_payload_access(request, data): + return denied + try: + invoice = update_invoice_from_payload(invoice, data) + except (KeyError, TypeError, ValueError) as exc: + return _bad_request(str(exc)) + invoice = ( + Invoice.objects.select_related("business", "vendor") + .prefetch_related("line_items__product", "category_links__category") + .get(pk=invoice.pk) + ) + return JsonResponse(build_invoice_payload(invoice)) + + +@require_permissions("inventory.view") +@require_GET +def inventory_view(request: HttpRequest) -> JsonResponse: + search = request.GET.get("q", "").strip() + category_id = request.GET.get("category_id") + visible_products = _scoped_products_queryset(request.user, Product.objects.filter(is_active=True)) + rows = ( + InventoryBalance.objects.select_related("product") + .annotate(category_count=Count("product__category_links")) + .prefetch_related("product__category_links__category") + .filter(product__in=visible_products) + ) + if search: + rows = rows.filter(Q(product__name__icontains=search) | Q(product__gtin__icontains=search)) + if category_id: + rows = rows.filter(product__category_links__category_id=category_id) + rows = rows.distinct().order_by("product__name") + return JsonResponse( + { + "results": [ + { + "product_id": row.product_id, + "legacy_product_id": row.product.legacy_id, + "product_name": row.product.name, + "gtin": row.product.gtin, + "quantity_on_hand": float(row.quantity_on_hand), + "uom": row.uom, + "vat_rate": _money(row.product.vat_rate), + "net_purchase_price": _money(row.product.net_purchase_price), + "display_sales_price": _money(row.product.display_sales_price), + "category_count": row.category_count, + "category_ids": list(row.product.category_links.values_list("category_id", flat=True)), + "category_names": [link.category.name for link in row.product.category_links.all()], + } + for row in rows + ] + } + ) + + +@require_http_methods(["GET", "POST"]) +def events_view(request: HttpRequest) -> JsonResponse: + if request.method == "GET": + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "events.view" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["events.view"]}, status=403) + business_id = request.GET.get("business_id") + events = Event.objects.select_related("business", "created_by").filter(is_active=True) + if business_id: + if denied := _ensure_business_access(request, int(business_id)): + return denied + events = events.filter(business_id=business_id) + elif not request.user.is_superuser: + events = events.filter(business_id__in=_allowed_business_ids(request.user)) + if event_type := request.GET.get("event_type"): + events = events.filter(event_type=event_type) + results = [_event_payload(event) for event in events.order_by("start_datetime")] + return JsonResponse({"results": results}) + + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "events.create" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["events.create"]}, status=403) + data = _json_body(request) + if not data.get("business_id"): + return _bad_request("business_id is required") + if denied := _ensure_business_access(request, int(data["business_id"])): + return denied + if not data.get("title"): + return _bad_request("title is required") + if not data.get("start_datetime") or not data.get("end_datetime"): + return _bad_request("start_datetime and end_datetime are required") + event = Event.objects.create( + business_id=int(data["business_id"]), + title=data["title"], + description=data.get("description", ""), + event_type=data.get("event_type", "other"), + start_datetime=data["start_datetime"], + end_datetime=data["end_datetime"], + all_day=bool(data.get("all_day", False)), + location=data.get("location", ""), + color=data.get("color", ""), + recurrence_type=data.get("recurrence_type", "none"), + recurrence_end_date=data.get("recurrence_end_date") or None, + created_by=request.user, + is_active=True, + ) + event = Event.objects.select_related("business", "created_by").get(pk=event.pk) + return JsonResponse(_event_payload(event), status=201) + + +@require_http_methods(["GET", "PUT", "DELETE"]) +def event_detail_view(request: HttpRequest, event_id: int) -> JsonResponse: + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + try: + event = Event.objects.select_related("business", "created_by").get(pk=event_id) + except Event.DoesNotExist: + return JsonResponse({"detail": "Event not found"}, status=404) + if denied := _ensure_business_access(request, event.business_id): + return denied + + if request.method == "GET": + if not request.user.is_superuser and "events.view" not in set(request.user.permission_keys()): + return _forbidden(missing_permissions=["events.view"]) + return JsonResponse(_event_payload(event)) + + if request.method == "DELETE": + if not request.user.is_superuser and "events.delete" not in set(request.user.permission_keys()): + return _forbidden(missing_permissions=["events.delete"]) + event.delete() + return JsonResponse({"detail": "Event deleted"}) + + if not request.user.is_superuser and "events.edit" not in set(request.user.permission_keys()): + return _forbidden(missing_permissions=["events.edit"]) + data = _json_body(request) + if "business_id" in data: + denied = _ensure_business_access(request, int(data["business_id"])) + if denied: + return denied + for field in ["title", "description", "event_type", "location", "color", "recurrence_type"]: + if field in data: + setattr(event, field, data[field]) + if "business_id" in data: + event.business_id = int(data["business_id"]) + if "start_datetime" in data: + event.start_datetime = data["start_datetime"] + if "end_datetime" in data: + event.end_datetime = data["end_datetime"] + if "all_day" in data: + event.all_day = bool(data["all_day"]) + if "recurrence_end_date" in data: + event.recurrence_end_date = data["recurrence_end_date"] or None + if "is_active" in data: + event.is_active = bool(data["is_active"]) + event.save() + event = Event.objects.select_related("business", "created_by").get(pk=event.pk) + return JsonResponse(_event_payload(event)) + + +@require_permissions("shifts.view") +@require_GET +def schedule_overview_view(request: HttpRequest) -> JsonResponse: + business_id = request.GET.get("business_id") + templates = ShiftTemplate.objects.select_related("business", "shift_role", "created_by").prefetch_related("assignments__user") + roles = ShiftRole.objects.select_related("business").filter(is_active=True) + assignments = ShiftAssignment.objects.select_related("shift_template", "user").all() + + if business_id: + if denied := _ensure_business_access(request, int(business_id)): + return denied + templates = templates.filter(business_id=business_id) + roles = roles.filter(business_id=business_id) + assignments = assignments.filter(shift_template__business_id=business_id) + elif not request.user.is_superuser: + allowed_business_ids = _allowed_business_ids(request.user) + templates = templates.filter(business_id__in=allowed_business_ids) + roles = roles.filter(business_id__in=allowed_business_ids) + assignments = assignments.filter(shift_template__business_id__in=allowed_business_ids) + + upcoming_templates = templates.order_by("start_datetime")[:30] + upcoming_assignments = assignments.order_by("occurrence_date", "shift_template__start_datetime")[:50] + + results = { + "roles": [ + { + "id": role.id, + "legacy_id": role.legacy_id, + "business_id": role.business_id, + "business_name": role.business.name, + "name": role.name, + "color": role.color, + "sort_order": role.sort_order, + } + for role in roles.order_by("business__name", "sort_order", "name") + ], + "templates": [ + { + "id": template.id, + "legacy_id": template.legacy_id, + "business_id": template.business_id, + "business_name": template.business.name, + "name": template.name, + "start_datetime": template.start_datetime.isoformat(), + "end_datetime": template.end_datetime.isoformat(), + "min_staff": template.min_staff, + "max_staff": template.max_staff, + "color": template.color, + "recurrence_type": template.recurrence_type, + "recurrence_end_date": template.recurrence_end_date.isoformat() if template.recurrence_end_date else None, + "shift_role_name": template.shift_role.name if template.shift_role_id else None, + "assignment_count": template.assignments.count(), + } + for template in upcoming_templates + ], + "assignments": [ + { + "id": assignment.id, + "legacy_id": assignment.legacy_id, + "shift_template_id": assignment.shift_template_id, + "shift_name": assignment.shift_template.name, + "user_name": assignment.user.display_name or assignment.user.username, + "occurrence_date": assignment.occurrence_date.isoformat(), + "status": assignment.status, + "start_override": assignment.start_override.isoformat() if assignment.start_override else None, + "end_override": assignment.end_override.isoformat() if assignment.end_override else None, + "notes": assignment.notes, + } + for assignment in upcoming_assignments + ], + } + return JsonResponse(results) + + +@require_permissions("users.manage") +@require_GET +def settings_overview_view(request: HttpRequest) -> JsonResponse: + roles = Role.objects.prefetch_related("permission_links__permission").order_by("name") + users = User.objects.select_related("role").prefetch_related("business_links").order_by("username") + permissions = DomainPermission.objects.order_by("group", "key") + return JsonResponse( + { + "roles": [ + { + "id": role.id, + "name": role.name, + "description": role.description, + "is_system": role.is_system, + "permission_keys": list(role.permission_links.values_list("permission__key", flat=True)), + "user_count": role.users.filter(is_active=True).count(), + } + for role in roles + ], + "users": [_user_admin_payload(user) for user in users], + "permissions": [ + { + "id": permission.id, + "key": permission.key, + "label": permission.label, + "group": permission.group, + } + for permission in permissions + ], + } + ) + + +@require_permissions("users.manage") +@require_http_methods(["POST"]) +def users_view(request: HttpRequest) -> JsonResponse: + data = _json_body(request) + if not data.get("username"): + return _bad_request("username is required") + if not data.get("password"): + return _bad_request("password is required") + if User.objects.filter(username=data["username"]).exists(): + return _bad_request("Username already exists") + user = User.objects.create( + username=data["username"], + password=make_password(data["password"]), + display_name=data.get("display_name", ""), + email=data.get("email", ""), + is_active=bool(data.get("is_active", True)), + role_id=data.get("role_id") or None, + ) + if business_ids := data.get("business_ids"): + for business_id in business_ids: + if denied := _ensure_business_access(request, int(business_id)): + user.delete() + return denied + UserBusinessAccess.objects.bulk_create( + [UserBusinessAccess(user=user, business_id=int(business_id)) for business_id in business_ids], + ignore_conflicts=True, + ) + user = User.objects.select_related("role").prefetch_related("business_links").get(pk=user.pk) + return JsonResponse(_user_admin_payload(user), status=201) + + +@require_permissions("users.manage") +@require_http_methods(["GET", "PUT", "DELETE"]) +def user_detail_view(request: HttpRequest, user_id: int) -> JsonResponse: + try: + user = User.objects.select_related("role").prefetch_related("business_links").get(pk=user_id) + except User.DoesNotExist: + return JsonResponse({"detail": "User not found"}, status=404) + + if request.method == "GET": + return JsonResponse(_user_admin_payload(user)) + + if request.method == "DELETE": + if request.user.pk == user.pk: + return _bad_request("You cannot delete your own account") + user.delete() + return JsonResponse({"detail": "User deleted"}) + + data = _json_body(request) + if "username" in data: + username = str(data["username"]).strip() + if not username: + return _bad_request("username is required") + if User.objects.exclude(pk=user.pk).filter(username=username).exists(): + return _bad_request("Username already exists") + user.username = username + for field in ["display_name", "email"]: + if field in data: + setattr(user, field, data[field]) + if "password" in data and data["password"]: + user.password = make_password(data["password"]) + if "is_active" in data: + user.is_active = bool(data["is_active"]) + if "is_superuser" in data: + user.is_superuser = bool(data["is_superuser"]) + if "role_id" in data: + user.role_id = data["role_id"] or None + user.save() + + if "business_ids" in data: + for business_id in data["business_ids"]: + if denied := _ensure_business_access(request, int(business_id)): + return denied + user.business_links.all().delete() + UserBusinessAccess.objects.bulk_create( + [UserBusinessAccess(user=user, business_id=int(business_id)) for business_id in data["business_ids"]], + ignore_conflicts=True, + ) + + user = User.objects.select_related("role").prefetch_related("business_links").get(pk=user.pk) + return JsonResponse(_user_admin_payload(user)) + + +@require_http_methods(["GET", "POST"]) +def devices_view(request: HttpRequest) -> JsonResponse: + if request.method == "GET": + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "users.manage" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["users.manage"]}, status=403) + devices = AllowedDevice.objects.order_by("-registered_at") + return JsonResponse({"results": [_device_payload(device) for device in devices]}) + + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "users.manage" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["users.manage"]}, status=403) + data = _json_body(request) + if not data.get("ip_address"): + return _bad_request("ip_address is required") + device = AllowedDevice.objects.create( + ip_address=data["ip_address"], + label=data.get("label", ""), + user_agent=data.get("user_agent", ""), + is_active=bool(data.get("is_active", True)), + ipv6_prefix=data.get("ipv6_prefix", ""), + device_token=data.get("device_token", secrets.token_urlsafe(24)), + known_ips=",".join(data.get("known_ips", [])), + ) + return JsonResponse(_device_payload(device), status=201) + + +@require_permissions("users.manage") +@require_http_methods(["PUT", "DELETE"]) +def device_detail_view(request: HttpRequest, device_id: int) -> JsonResponse: + try: + device = AllowedDevice.objects.get(pk=device_id) + except AllowedDevice.DoesNotExist: + return JsonResponse({"detail": "Device not found"}, status=404) + + if request.method == "DELETE": + device.delete() + return JsonResponse({"detail": "Device deleted"}) + + data = _json_body(request) + for field in ["ip_address", "label", "user_agent", "ipv6_prefix", "device_token"]: + if field in data: + setattr(device, field, data[field]) + if "is_active" in data: + device.is_active = bool(data["is_active"]) + if "known_ips" in data: + device.known_ips = ",".join(data["known_ips"]) + device.save() + return JsonResponse(_device_payload(device)) + + +@require_http_methods(["GET", "POST"]) +def device_tokens_view(request: HttpRequest) -> JsonResponse: + if request.method == "GET": + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "users.manage" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["users.manage"]}, status=403) + tokens = DeviceRegistrationToken.objects.select_related("created_by").order_by("-created_at") + return JsonResponse({"results": [_device_token_payload(token) for token in tokens]}) + + if not request.user.is_authenticated: + return JsonResponse({"detail": "Authentication required"}, status=401) + if not request.user.is_superuser and "users.manage" not in set(request.user.permission_keys()): + return JsonResponse({"detail": "Permission denied", "missing_permissions": ["users.manage"]}, status=403) + data = _json_body(request) + expires_in_days = int(data.get("expires_in_days", 7)) + token = DeviceRegistrationToken.objects.create( + token=secrets.token_urlsafe(24), + label=data.get("label", ""), + expires_at=timezone.now() + timedelta(days=expires_in_days), + created_by=request.user, + ) + token = DeviceRegistrationToken.objects.select_related("created_by").get(pk=token.pk) + return JsonResponse(_device_token_payload(token), status=201) + + +@require_permissions("users.manage") +@require_http_methods(["DELETE"]) +def device_token_detail_view(request: HttpRequest, token_id: int) -> JsonResponse: + try: + token = DeviceRegistrationToken.objects.get(pk=token_id) + except DeviceRegistrationToken.DoesNotExist: + return JsonResponse({"detail": "Registration token not found"}, status=404) + token.delete() + return JsonResponse({"detail": "Registration token deleted"}) + + +@api_login_required +@require_GET +def notifications_view(request: HttpRequest) -> JsonResponse: + rows = Notification.objects.filter(Q(is_dismissed=False) | Q(is_read=False)).order_by("-created_at")[:50] + return JsonResponse( + { + "results": [ + { + "id": row.id, + "type": row.type, + "title": row.title, + "message": row.message, + "is_read": row.is_read, + "created_at": row.created_at.isoformat(), + } + for row in rows + ] + } + ) diff --git a/backend/apps/core/__init__.py b/backend/apps/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/core/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/core/admin.py b/backend/apps/core/admin.py new file mode 100644 index 0000000..cbf736a --- /dev/null +++ b/backend/apps/core/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import ( + Business, + Category, + Product, + ProductCategory, + Vendor, + VendorBusiness, + VendorCategory, +) + +admin.site.register(Business) +admin.site.register(Category) +admin.site.register(Vendor) +admin.site.register(VendorBusiness) +admin.site.register(VendorCategory) +admin.site.register(Product) +admin.site.register(ProductCategory) diff --git a/backend/apps/core/apps.py b/backend/apps/core/apps.py new file mode 100644 index 0000000..68868e8 --- /dev/null +++ b/backend/apps/core/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" + label = "core" diff --git a/backend/apps/core/migrations/0001_initial.py b/backend/apps/core/migrations/0001_initial.py new file mode 100644 index 0000000..1396fb2 --- /dev/null +++ b/backend/apps/core/migrations/0001_initial.py @@ -0,0 +1,124 @@ +# Generated by Django 5.2.12 on 2026-04-01 00:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Business', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, unique=True)), + ('short_code', models.CharField(max_length=32, unique=True)), + ('currency', models.CharField(default='CZK', max_length=8)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, unique=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Vendor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ('vat_number', models.CharField(blank=True, max_length=64)), + ('registration_id', models.CharField(blank=True, max_length=64)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('contact_phone', models.CharField(blank=True, max_length=64)), + ('address', models.TextField(blank=True)), + ('notes', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('gtin', models.CharField(blank=True, max_length=64)), + ('name', models.CharField(max_length=255)), + ('ledger', models.CharField(blank=True, max_length=128)), + ('product_status', models.CharField(blank=True, max_length=64)), + ('is_active', models.BooleanField(default=True)), + ('tax_type', models.CharField(blank=True, max_length=64)), + ('vat_rate', models.DecimalField(decimal_places=2, default=0, max_digits=8)), + ('net_purchase_price', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('display_sales_price', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('uom', models.CharField(default='pcs', max_length=32)), + ('amount', models.DecimalField(decimal_places=3, default=0, max_digits=12)), + ('is_placeholder', models.BooleanField(default=False)), + ('short_name', models.CharField(blank=True, max_length=120)), + ('currency_code', models.CharField(default='CZK', max_length=8)), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('gtin', 'name'), name='uniq_product_gtin_name')], + }, + ), + migrations.CreateModel( + name='ProductCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_links', to='core.category')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_links', to='core.product')), + ], + options={ + 'unique_together': {('product', 'category')}, + }, + ), + migrations.CreateModel( + name='VendorBusiness', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vendor_links', to='core.business')), + ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_links', to='core.vendor')), + ], + options={ + 'unique_together': {('vendor', 'business')}, + }, + ), + migrations.CreateModel( + name='VendorCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vendor_links', to='core.category')), + ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_links', to='core.vendor')), + ], + options={ + 'unique_together': {('vendor', 'category')}, + }, + ), + ] diff --git a/backend/apps/core/migrations/__init__.py b/backend/apps/core/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/core/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/core/models.py b/backend/apps/core/models.py new file mode 100644 index 0000000..e1340f3 --- /dev/null +++ b/backend/apps/core/models.py @@ -0,0 +1,91 @@ +from django.db import models + + +class LegacyTrackedModel(models.Model): + legacy_id = models.IntegerField(null=True, blank=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class Business(LegacyTrackedModel): + name = models.CharField(max_length=255, unique=True) + short_code = models.CharField(max_length=32, unique=True) + currency = models.CharField(max_length=8, default="CZK") + is_active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + + +class Category(LegacyTrackedModel): + name = models.CharField(max_length=255, unique=True) + is_active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + + +class Vendor(LegacyTrackedModel): + name = models.CharField(max_length=255) + vat_number = models.CharField(max_length=64, blank=True) + registration_id = models.CharField(max_length=64, blank=True) + contact_email = models.EmailField(blank=True) + contact_phone = models.CharField(max_length=64, blank=True) + address = models.TextField(blank=True) + notes = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + + +class VendorBusiness(models.Model): + vendor = models.ForeignKey(Vendor, on_delete=models.CASCADE, related_name="business_links") + business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name="vendor_links") + + class Meta: + unique_together = ("vendor", "business") + + +class VendorCategory(models.Model): + vendor = models.ForeignKey(Vendor, on_delete=models.CASCADE, related_name="category_links") + category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="vendor_links") + + class Meta: + unique_together = ("vendor", "category") + + +class Product(LegacyTrackedModel): + gtin = models.CharField(max_length=64, blank=True) + name = models.CharField(max_length=255) + ledger = models.CharField(max_length=128, blank=True) + product_status = models.CharField(max_length=64, blank=True) + is_active = models.BooleanField(default=True) + tax_type = models.CharField(max_length=64, blank=True) + vat_rate = models.DecimalField(max_digits=8, decimal_places=2, default=0) + net_purchase_price = models.DecimalField(max_digits=12, decimal_places=2, default=0) + display_sales_price = models.DecimalField(max_digits=12, decimal_places=2, default=0) + uom = models.CharField(max_length=32, default="pcs") + amount = models.DecimalField(max_digits=12, decimal_places=3, default=0) + is_placeholder = models.BooleanField(default=False) + short_name = models.CharField(max_length=120, blank=True) + currency_code = models.CharField(max_length=8, default="CZK") + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["gtin", "name"], name="uniq_product_gtin_name"), + ] + + def __str__(self) -> str: + return self.name + + +class ProductCategory(models.Model): + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="category_links") + category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="product_links") + + class Meta: + unique_together = ("product", "category") diff --git a/backend/apps/notifications/__init__.py b/backend/apps/notifications/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/notifications/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/notifications/admin.py b/backend/apps/notifications/admin.py new file mode 100644 index 0000000..c966af7 --- /dev/null +++ b/backend/apps/notifications/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import Notification + +admin.site.register(Notification) diff --git a/backend/apps/notifications/apps.py b/backend/apps/notifications/apps.py new file mode 100644 index 0000000..95dbb8b --- /dev/null +++ b/backend/apps/notifications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.notifications" + label = "notifications" diff --git a/backend/apps/notifications/migrations/0001_initial.py b/backend/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..80c7114 --- /dev/null +++ b/backend/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.12 on 2026-04-01 00:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('type', models.CharField(max_length=80)), + ('title', models.CharField(max_length=255)), + ('message', models.TextField()), + ('reference_type', models.CharField(blank=True, max_length=80)), + ('reference_id', models.IntegerField(blank=True, null=True)), + ('is_read', models.BooleanField(default=False)), + ('is_dismissed', models.BooleanField(default=False)), + ('remind_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/backend/apps/notifications/migrations/__init__.py b/backend/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/notifications/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/notifications/models.py b/backend/apps/notifications/models.py new file mode 100644 index 0000000..630f554 --- /dev/null +++ b/backend/apps/notifications/models.py @@ -0,0 +1,14 @@ +from django.db import models + + +class Notification(models.Model): + legacy_id = models.IntegerField(null=True, blank=True, db_index=True) + type = models.CharField(max_length=80) + title = models.CharField(max_length=255) + message = models.TextField() + reference_type = models.CharField(max_length=80, blank=True) + reference_id = models.IntegerField(null=True, blank=True) + is_read = models.BooleanField(default=False) + is_dismissed = models.BooleanField(default=False) + remind_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/apps/operations/__init__.py b/backend/apps/operations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/operations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/operations/admin.py b/backend/apps/operations/admin.py new file mode 100644 index 0000000..8e90b5a --- /dev/null +++ b/backend/apps/operations/admin.py @@ -0,0 +1,31 @@ +from django.contrib import admin + +from .models import ( + Event, + InventoryBalance, + InventoryBulkMapping, + InventoryMovement, + Invoice, + InvoiceCategory, + InvoiceLineItem, + ShiftAssignment, + ShiftRole, + ShiftTemplate, + StockCount, + StockCountLine, + WorkerAvailability, +) + +admin.site.register(Invoice) +admin.site.register(InvoiceLineItem) +admin.site.register(InvoiceCategory) +admin.site.register(InventoryBalance) +admin.site.register(InventoryMovement) +admin.site.register(InventoryBulkMapping) +admin.site.register(StockCount) +admin.site.register(StockCountLine) +admin.site.register(Event) +admin.site.register(ShiftRole) +admin.site.register(ShiftTemplate) +admin.site.register(ShiftAssignment) +admin.site.register(WorkerAvailability) diff --git a/backend/apps/operations/apps.py b/backend/apps/operations/apps.py new file mode 100644 index 0000000..5601775 --- /dev/null +++ b/backend/apps/operations/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class OperationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.operations" + label = "operations" diff --git a/backend/apps/operations/migrations/0001_initial.py b/backend/apps/operations/migrations/0001_initial.py new file mode 100644 index 0000000..5d1a370 --- /dev/null +++ b/backend/apps/operations/migrations/0001_initial.py @@ -0,0 +1,274 @@ +# Generated by Django 5.2.12 on 2026-04-01 00:09 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('event_type', models.CharField(default='other', max_length=32)), + ('start_datetime', models.DateTimeField()), + ('end_datetime', models.DateTimeField()), + ('all_day', models.BooleanField(default=False)), + ('location', models.CharField(blank=True, max_length=255)), + ('color', models.CharField(blank=True, max_length=32)), + ('recurrence_type', models.CharField(default='none', max_length=32)), + ('recurrence_end_date', models.DateField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.business')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InventoryBalance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity_on_hand', models.DecimalField(decimal_places=3, default=0, max_digits=12)), + ('uom', models.CharField(default='pcs', max_length=32)), + ('last_updated_at', models.DateTimeField(blank=True, null=True)), + ('product', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_balance', to='core.product')), + ], + ), + migrations.CreateModel( + name='InventoryMovement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('movement_ts', models.DateTimeField(blank=True, null=True)), + ('movement_date', models.DateField()), + ('movement_type', models.CharField(max_length=32)), + ('quantity_delta', models.DecimalField(decimal_places=3, max_digits=12)), + ('uom', models.CharField(default='pcs', max_length=32)), + ('source_type', models.CharField(max_length=32)), + ('source_ref', models.CharField(blank=True, max_length=255)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_movements', to='core.product')), + ('sellable_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sellable_inventory_movements', to='core.product')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('vendor_not_configured', models.BooleanField(default=False)), + ('vendor_config_note', models.TextField(blank=True)), + ('invoice_number', models.CharField(blank=True, max_length=120)), + ('invoice_date', models.DateField(blank=True, null=True)), + ('order_date', models.DateField(blank=True, null=True)), + ('entered_at', models.DateTimeField(blank=True, null=True)), + ('payment_status', models.CharField(default='unpaid', max_length=32)), + ('paid_date', models.DateField(blank=True, null=True)), + ('due_date', models.DateField(blank=True, null=True)), + ('subtotal', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('discount_pct', models.DecimalField(decimal_places=2, default=0, max_digits=8)), + ('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('total_after_discount', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('vat_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('gross_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('currency', models.CharField(default='CZK', max_length=8)), + ('goods_received_status', models.CharField(default='not_received', max_length=32)), + ('goods_date', models.DateField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ('is_editable', models.BooleanField(default=True)), + ('inventory_updated', models.BooleanField(default=False)), + ('rate_czk_eur', models.DecimalField(decimal_places=4, default=0, max_digits=12)), + ('rate_czk_usd', models.DecimalField(decimal_places=4, default=0, max_digits=12)), + ('vat_exempt', models.BooleanField(default=False)), + ('business', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.business')), + ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='core.vendor')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InvoiceLineItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('quantity', models.DecimalField(decimal_places=3, max_digits=12)), + ('unit_price', models.DecimalField(decimal_places=2, max_digits=12)), + ('total_price', models.DecimalField(decimal_places=2, max_digits=12)), + ('vat_rate', models.DecimalField(decimal_places=2, default=0, max_digits=8)), + ('vat_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('line_order', models.PositiveIntegerField(default=0)), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='line_items', to='operations.invoice')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='core.product')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ShiftRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ('color', models.CharField(blank=True, max_length=32)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shift_roles', to='core.business')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ShiftTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ('start_datetime', models.DateTimeField()), + ('end_datetime', models.DateTimeField()), + ('min_staff', models.PositiveIntegerField(default=1)), + ('max_staff', models.PositiveIntegerField(default=3)), + ('color', models.CharField(blank=True, max_length=32)), + ('recurrence_type', models.CharField(default='none', max_length=32)), + ('recurrence_end_date', models.DateField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shift_templates', to='core.business')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('shift_role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='operations.shiftrole')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='StockCount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('counted_by', models.CharField(blank=True, max_length=255)), + ('count_date', models.DateField()), + ('notes', models.TextField(blank=True)), + ('status', models.CharField(default='in_progress', max_length=32)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('business', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.business')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='StockCountLine', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('full_units', models.DecimalField(decimal_places=3, default=0, max_digits=12)), + ('partial_units', models.DecimalField(decimal_places=3, default=0, max_digits=12)), + ('total_quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12)), + ('previous_quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='core.product')), + ('stock_count', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='operations.stockcount')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InventoryBulkMapping', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('decrement_amount', models.DecimalField(decimal_places=3, default=1, max_digits=12)), + ('decrement_uom', models.CharField(blank=True, max_length=32)), + ('chip_name', models.CharField(blank=True, max_length=120)), + ('bulk_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_mappings', to='core.product')), + ('sellable_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sellable_mappings', to='core.product')), + ], + options={ + 'unique_together': {('bulk_product', 'sellable_product')}, + }, + ), + migrations.CreateModel( + name='InvoiceCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.category')), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_links', to='operations.invoice')), + ], + options={ + 'unique_together': {('invoice', 'category')}, + }, + ), + migrations.CreateModel( + name='ShiftAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('occurrence_date', models.DateField()), + ('start_override', models.DateTimeField(blank=True, null=True)), + ('end_override', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(default='assigned', max_length=32)), + ('notes', models.TextField(blank=True)), + ('notification_sent_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shift_assignments', to=settings.AUTH_USER_MODEL)), + ('shift_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='operations.shifttemplate')), + ], + options={ + 'unique_together': {('shift_template', 'user', 'occurrence_date')}, + }, + ), + migrations.CreateModel( + name='WorkerAvailability', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('date', models.DateField()), + ('status', models.CharField(default='available', max_length=32)), + ('note', models.TextField(blank=True)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='availabilities', to='core.business')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='availabilities', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'date')}, + }, + ), + ] diff --git a/backend/apps/operations/migrations/__init__.py b/backend/apps/operations/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/operations/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/operations/models.py b/backend/apps/operations/models.py new file mode 100644 index 0000000..7ac3faf --- /dev/null +++ b/backend/apps/operations/models.py @@ -0,0 +1,169 @@ +from django.db import models + +from apps.core.models import LegacyTrackedModel + + +class Invoice(LegacyTrackedModel): + business = models.ForeignKey("core.Business", null=True, blank=True, on_delete=models.SET_NULL) + vendor = models.ForeignKey("core.Vendor", on_delete=models.PROTECT) + vendor_not_configured = models.BooleanField(default=False) + vendor_config_note = models.TextField(blank=True) + invoice_number = models.CharField(max_length=120, blank=True) + invoice_date = models.DateField(null=True, blank=True) + order_date = models.DateField(null=True, blank=True) + entered_at = models.DateTimeField(null=True, blank=True) + payment_status = models.CharField(max_length=32, default="unpaid") + paid_date = models.DateField(null=True, blank=True) + due_date = models.DateField(null=True, blank=True) + subtotal = models.DecimalField(max_digits=12, decimal_places=2, default=0) + discount_pct = models.DecimalField(max_digits=8, decimal_places=2, default=0) + discount_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + total_after_discount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + gross_total = models.DecimalField(max_digits=12, decimal_places=2, default=0) + currency = models.CharField(max_length=8, default="CZK") + goods_received_status = models.CharField(max_length=32, default="not_received") + goods_date = models.DateField(null=True, blank=True) + notes = models.TextField(blank=True) + is_editable = models.BooleanField(default=True) + inventory_updated = models.BooleanField(default=False) + rate_czk_eur = models.DecimalField(max_digits=12, decimal_places=4, default=0) + rate_czk_usd = models.DecimalField(max_digits=12, decimal_places=4, default=0) + vat_exempt = models.BooleanField(default=False) + + +class InvoiceLineItem(LegacyTrackedModel): + invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="line_items") + product = models.ForeignKey("core.Product", on_delete=models.PROTECT) + quantity = models.DecimalField(max_digits=12, decimal_places=3) + unit_price = models.DecimalField(max_digits=12, decimal_places=2) + total_price = models.DecimalField(max_digits=12, decimal_places=2) + vat_rate = models.DecimalField(max_digits=8, decimal_places=2, default=0) + vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + line_order = models.PositiveIntegerField(default=0) + + +class InvoiceCategory(models.Model): + invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="category_links") + category = models.ForeignKey("core.Category", on_delete=models.CASCADE) + + class Meta: + unique_together = ("invoice", "category") + + +class InventoryBalance(models.Model): + product = models.OneToOneField("core.Product", on_delete=models.CASCADE, related_name="inventory_balance") + quantity_on_hand = models.DecimalField(max_digits=12, decimal_places=3, default=0) + uom = models.CharField(max_length=32, default="pcs") + last_updated_at = models.DateTimeField(null=True, blank=True) + + +class InventoryMovement(LegacyTrackedModel): + product = models.ForeignKey("core.Product", on_delete=models.CASCADE, related_name="inventory_movements") + sellable_product = models.ForeignKey( + "core.Product", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="sellable_inventory_movements", + ) + movement_ts = models.DateTimeField(null=True, blank=True) + movement_date = models.DateField() + movement_type = models.CharField(max_length=32) + quantity_delta = models.DecimalField(max_digits=12, decimal_places=3) + uom = models.CharField(max_length=32, default="pcs") + source_type = models.CharField(max_length=32) + source_ref = models.CharField(max_length=255, blank=True) + + +class InventoryBulkMapping(LegacyTrackedModel): + bulk_product = models.ForeignKey("core.Product", on_delete=models.CASCADE, related_name="bulk_mappings") + sellable_product = models.ForeignKey("core.Product", on_delete=models.CASCADE, related_name="sellable_mappings") + decrement_amount = models.DecimalField(max_digits=12, decimal_places=3, default=1) + decrement_uom = models.CharField(max_length=32, blank=True) + chip_name = models.CharField(max_length=120, blank=True) + + class Meta: + unique_together = ("bulk_product", "sellable_product") + + +class StockCount(LegacyTrackedModel): + business = models.ForeignKey("core.Business", null=True, blank=True, on_delete=models.SET_NULL) + counted_by = models.CharField(max_length=255, blank=True) + count_date = models.DateField() + notes = models.TextField(blank=True) + status = models.CharField(max_length=32, default="in_progress") + completed_at = models.DateTimeField(null=True, blank=True) + + +class StockCountLine(LegacyTrackedModel): + stock_count = models.ForeignKey(StockCount, on_delete=models.CASCADE, related_name="lines") + product = models.ForeignKey("core.Product", on_delete=models.PROTECT) + full_units = models.DecimalField(max_digits=12, decimal_places=3, default=0) + partial_units = models.DecimalField(max_digits=12, decimal_places=3, default=0) + total_quantity = models.DecimalField(max_digits=12, decimal_places=3, default=0) + previous_quantity = models.DecimalField(max_digits=12, decimal_places=3, default=0) + + +class Event(LegacyTrackedModel): + business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="events") + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + event_type = models.CharField(max_length=32, default="other") + start_datetime = models.DateTimeField() + end_datetime = models.DateTimeField() + all_day = models.BooleanField(default=False) + location = models.CharField(max_length=255, blank=True) + color = models.CharField(max_length=32, blank=True) + recurrence_type = models.CharField(max_length=32, default="none") + recurrence_end_date = models.DateField(null=True, blank=True) + created_by = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL) + is_active = models.BooleanField(default=True) + + +class ShiftRole(LegacyTrackedModel): + business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="shift_roles") + name = models.CharField(max_length=255) + color = models.CharField(max_length=32, blank=True) + sort_order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + +class ShiftTemplate(LegacyTrackedModel): + business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="shift_templates") + name = models.CharField(max_length=255) + start_datetime = models.DateTimeField() + end_datetime = models.DateTimeField() + min_staff = models.PositiveIntegerField(default=1) + max_staff = models.PositiveIntegerField(default=3) + color = models.CharField(max_length=32, blank=True) + recurrence_type = models.CharField(max_length=32, default="none") + recurrence_end_date = models.DateField(null=True, blank=True) + created_by = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL) + shift_role = models.ForeignKey(ShiftRole, null=True, blank=True, on_delete=models.SET_NULL) + is_active = models.BooleanField(default=True) + + +class ShiftAssignment(LegacyTrackedModel): + shift_template = models.ForeignKey(ShiftTemplate, on_delete=models.CASCADE, related_name="assignments") + user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="shift_assignments") + occurrence_date = models.DateField() + start_override = models.DateTimeField(null=True, blank=True) + end_override = models.DateTimeField(null=True, blank=True) + status = models.CharField(max_length=32, default="assigned") + notes = models.TextField(blank=True) + notification_sent_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = ("shift_template", "user", "occurrence_date") + + +class WorkerAvailability(LegacyTrackedModel): + user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="availabilities") + business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="availabilities") + date = models.DateField() + status = models.CharField(max_length=32, default="available") + note = models.TextField(blank=True) + + class Meta: + unique_together = ("user", "date") diff --git a/backend/apps/operations/services.py b/backend/apps/operations/services.py new file mode 100644 index 0000000..81a58f9 --- /dev/null +++ b/backend/apps/operations/services.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP + +from django.db import transaction +from django.utils import timezone +from django.utils.dateparse import parse_date + +from apps.core.models import Category, Product +from apps.operations.models import Invoice, InvoiceCategory, InvoiceLineItem + +TWOPLACES = Decimal("0.01") + + +def _decimal(value: object, default: str = "0") -> Decimal: + if value in (None, ""): + return Decimal(default) + return Decimal(str(value)) + + +def _round_money(value: Decimal) -> Decimal: + return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP) + + +@dataclass +class InvoiceLineInput: + product_id: int + quantity: Decimal + unit_price: Decimal + + +def build_invoice_payload(invoice: Invoice) -> dict: + return { + "id": invoice.id, + "legacy_id": invoice.legacy_id, + "business_id": invoice.business_id, + "business_name": invoice.business.name if invoice.business_id else None, + "vendor_id": invoice.vendor_id, + "vendor_name": invoice.vendor.name, + "invoice_number": invoice.invoice_number, + "invoice_date": invoice.invoice_date.isoformat() if invoice.invoice_date else None, + "order_date": invoice.order_date.isoformat() if invoice.order_date else None, + "payment_status": invoice.payment_status, + "paid_date": invoice.paid_date.isoformat() if invoice.paid_date else None, + "due_date": invoice.due_date.isoformat() if invoice.due_date else None, + "subtotal": float(invoice.subtotal), + "discount_pct": float(invoice.discount_pct), + "discount_amount": float(invoice.discount_amount), + "total_after_discount": float(invoice.total_after_discount), + "vat_amount": float(invoice.vat_amount), + "gross_total": float(invoice.gross_total), + "currency": invoice.currency, + "goods_received_status": invoice.goods_received_status, + "goods_date": invoice.goods_date.isoformat() if invoice.goods_date else None, + "notes": invoice.notes, + "inventory_updated": invoice.inventory_updated, + "vat_exempt": invoice.vat_exempt, + "category_ids": list(invoice.category_links.values_list("category_id", flat=True)), + "line_items": [ + { + "id": line.id, + "product_id": line.product_id, + "product_name": line.product.name, + "quantity": float(line.quantity), + "unit_price": float(line.unit_price), + "total_price": float(line.total_price), + "vat_rate": float(line.vat_rate), + "vat_amount": float(line.vat_amount), + "line_order": line.line_order, + } + for line in invoice.line_items.all().order_by("line_order") + ], + } + + +def _resolve_lines(line_items: list[dict]) -> tuple[list[tuple[Product, InvoiceLineInput]], Decimal, Decimal]: + resolved: list[tuple[Product, InvoiceLineInput]] = [] + subtotal = Decimal("0") + total_vat = Decimal("0") + product_ids = [int(item["product_id"]) for item in line_items] + products = Product.objects.in_bulk(product_ids) + + for item in line_items: + product_id = int(item["product_id"]) + product = products.get(product_id) + if product is None: + raise ValueError(f"Unknown product_id: {product_id}") + line = InvoiceLineInput( + product_id=product_id, + quantity=_decimal(item.get("quantity"), "0"), + unit_price=_decimal(item.get("unit_price"), "0"), + ) + line_total = _round_money(line.quantity * line.unit_price) + line_vat = _round_money(line_total * _decimal(product.vat_rate) / Decimal("100")) + subtotal += line_total + total_vat += line_vat + resolved.append((product, line)) + + return resolved, _round_money(subtotal), _round_money(total_vat) + + +def _persist_invoice_payload(invoice: Invoice, data: dict, *, is_create: bool) -> Invoice: + line_items = data.get("line_items") or [] + if not line_items: + raise ValueError("At least one line item is required") + + resolved_lines, subtotal, computed_vat = _resolve_lines(line_items) + discount_pct = _decimal(data.get("discount_pct"), "0") + discount_amount = _decimal(data.get("discount_amount"), "0") + if discount_pct > 0 and discount_amount == 0: + discount_amount = _round_money(subtotal * discount_pct / Decimal("100")) + total_after_discount = _round_money(subtotal - discount_amount) + vat_exempt = bool(data.get("vat_exempt", False)) + total_vat = Decimal("0") if vat_exempt else computed_vat + gross_total = _round_money(total_after_discount + total_vat) + payment_status = data.get("payment_status") or "unpaid" + goods_received_status = data.get("goods_received_status") or "not_received" + today = timezone.localdate() + + invoice.business_id = data.get("business_id") or None + invoice.vendor_id = int(data["vendor_id"]) + invoice.vendor_not_configured = bool(data.get("vendor_not_configured", False)) + invoice.vendor_config_note = data.get("vendor_config_note", "") + invoice.invoice_number = data.get("invoice_number", "") + invoice.invoice_date = parse_date(data["invoice_date"]) if data.get("invoice_date") else None + invoice.order_date = parse_date(data["order_date"]) if data.get("order_date") else None + if is_create: + invoice.entered_at = timezone.now() + invoice.payment_status = payment_status + invoice.paid_date = parse_date(data["paid_date"]) if data.get("paid_date") else (today if payment_status == "paid" else None) + invoice.due_date = parse_date(data["due_date"]) if data.get("due_date") else None + invoice.subtotal = subtotal + invoice.discount_pct = discount_pct + invoice.discount_amount = discount_amount + invoice.total_after_discount = total_after_discount + invoice.vat_amount = total_vat + invoice.gross_total = gross_total + invoice.currency = data.get("currency", "CZK") + invoice.goods_received_status = goods_received_status + invoice.goods_date = ( + parse_date(data["goods_date"]) if data.get("goods_date") else (today if goods_received_status == "received" else None) + ) + invoice.notes = data.get("notes", "") + invoice.vat_exempt = vat_exempt + invoice.save() + + invoice_lines: list[InvoiceLineItem] = [] + for index, (product, line) in enumerate(resolved_lines): + total_price = _round_money(line.quantity * line.unit_price) + vat_amount = Decimal("0") if vat_exempt else _round_money(total_price * _decimal(product.vat_rate) / Decimal("100")) + invoice_lines.append( + InvoiceLineItem( + invoice=invoice, + product=product, + quantity=line.quantity, + unit_price=line.unit_price, + total_price=total_price, + vat_rate=product.vat_rate, + vat_amount=vat_amount, + line_order=index, + ) + ) + invoice.line_items.all().delete() + InvoiceLineItem.objects.bulk_create(invoice_lines) + + invoice.category_links.all().delete() + if category_ids := data.get("category_ids"): + categories = Category.objects.in_bulk(category_ids) + links = [ + InvoiceCategory(invoice=invoice, category=category) + for category_id in category_ids + if (category := categories.get(int(category_id))) is not None + ] + InvoiceCategory.objects.bulk_create(links, ignore_conflicts=True) + + return invoice + + +@transaction.atomic +def create_invoice_from_payload(data: dict) -> Invoice: + return _persist_invoice_payload(Invoice(), data, is_create=True) + + +@transaction.atomic +def update_invoice_from_payload(invoice: Invoice, data: dict) -> Invoice: + return _persist_invoice_payload(invoice, data, is_create=False) diff --git a/backend/apps/reporting/__init__.py b/backend/apps/reporting/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/reporting/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/reporting/admin.py b/backend/apps/reporting/admin.py new file mode 100644 index 0000000..7a6aefb --- /dev/null +++ b/backend/apps/reporting/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import DailyRevenueSummary + +admin.site.register(DailyRevenueSummary) diff --git a/backend/apps/reporting/apps.py b/backend/apps/reporting/apps.py new file mode 100644 index 0000000..3135ac7 --- /dev/null +++ b/backend/apps/reporting/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ReportingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.reporting" + label = "reporting" diff --git a/backend/apps/reporting/migrations/0001_initial.py b/backend/apps/reporting/migrations/0001_initial.py new file mode 100644 index 0000000..3c3ce3e --- /dev/null +++ b/backend/apps/reporting/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.12 on 2026-04-01 00:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DailyRevenueSummary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('business_date', models.DateField()), + ('sales_revenue', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('food_revenue', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('alcohol_revenue', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('tips_payable', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('card_receivable', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('cash', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('vat_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('vat_reduced_12', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('vat_standard_21', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('accounts_receivable_pending', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('ecom_payment_receivable', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_revenue', to='core.business')), + ], + options={ + 'unique_together': {('business', 'business_date')}, + }, + ), + ] diff --git a/backend/apps/reporting/migrations/__init__.py b/backend/apps/reporting/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/reporting/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/reporting/models.py b/backend/apps/reporting/models.py new file mode 100644 index 0000000..cebf962 --- /dev/null +++ b/backend/apps/reporting/models.py @@ -0,0 +1,20 @@ +from django.db import models + + +class DailyRevenueSummary(models.Model): + business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="daily_revenue") + business_date = models.DateField() + sales_revenue = models.DecimalField(max_digits=12, decimal_places=2, default=0) + food_revenue = models.DecimalField(max_digits=12, decimal_places=2, default=0) + alcohol_revenue = models.DecimalField(max_digits=12, decimal_places=2, default=0) + tips_payable = models.DecimalField(max_digits=12, decimal_places=2, default=0) + card_receivable = models.DecimalField(max_digits=12, decimal_places=2, default=0) + cash = models.DecimalField(max_digits=12, decimal_places=2, default=0) + vat_total = models.DecimalField(max_digits=12, decimal_places=2, default=0) + vat_reduced_12 = models.DecimalField(max_digits=12, decimal_places=2, default=0) + vat_standard_21 = models.DecimalField(max_digits=12, decimal_places=2, default=0) + accounts_receivable_pending = models.DecimalField(max_digits=12, decimal_places=2, default=0) + ecom_payment_receivable = models.DecimalField(max_digits=12, decimal_places=2, default=0) + + class Meta: + unique_together = ("business", "business_date") diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..3d6b080 --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,6 @@ +import os +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..5bca83d --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,114 @@ +from pathlib import Path +import os + +BASE_DIR = Path(__file__).resolve().parent.parent + +DEV_FRONTEND_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", +] + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me") +DEBUG = os.getenv("DJANGO_DEBUG", "1") == "1" +ALLOWED_HOSTS = [host for host in os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",") if host] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "corsheaders", + "apps.accounts", + "apps.core", + "apps.operations", + "apps.reporting", + "apps.notifications", + "apps.api", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + } +] + +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +AUTH_USER_MODEL = "accounts.User" + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Budapest") +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGIN_URL = "/api/auth/login/" +SESSION_COOKIE_NAME = "hub_sessionid" +SESSION_COOKIE_SAMESITE = "Lax" +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_NAME = "hub_csrftoken" +CSRF_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SECURE = False +APPEND_SLASH = True +CSRF_TRUSTED_ORIGINS = [ + origin + for origin in ( + [origin for origin in os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if origin] + or DEV_FRONTEND_ORIGINS + ) +] + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOWED_ORIGINS = [ + origin + for origin in ( + [origin for origin in os.getenv("DJANGO_CORS_ALLOWED_ORIGINS", "").split(",") if origin] + or DEV_FRONTEND_ORIGINS + ) +] + +LEGACY_CINCIN_DB = Path(os.getenv("LEGACY_CINCIN_DB", BASE_DIR.parent.parent / "cincin_phase1.sqlite")) +LEGACY_DALCORSO_DB = Path(os.getenv("LEGACY_DALCORSO_DB", BASE_DIR.parent.parent / "dalcorso.sqlite")) diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..fabe265 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/", include("apps.api.urls")), +] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..a2082e9 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,6 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..7f082c5 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..3c1455f --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "hub-django-port" +version = "0.1.0" +description = "Django rewrite of the hospitality operations hub" +requires-python = ">=3.12" +dependencies = [ + "Django>=5.1,<6", + "django-cors-headers>=4.4", +] + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["config", "apps"] + +[tool.setuptools.package-dir] +"" = "." + +[tool.ruff] +line-length = 100 +target-version = "py312" diff --git a/docs/port-plan.md b/docs/port-plan.md new file mode 100644 index 0000000..cf21fcf --- /dev/null +++ b/docs/port-plan.md @@ -0,0 +1,41 @@ +# Port Plan + +## Goals + +- Replace the custom FastAPI auth flow with Django sessions and `AUTH_USER_MODEL` +- Collapse the two legacy SQLite files into one relational schema owned by Django migrations +- Move domain logic out of route files into app models and services +- Replace the React monolith with a SvelteKit frontend structured by route and domain +- Prepare for `shadcn-svelte` instead of continuing with bespoke one-off UI patterns + +## Target backend split + +- `accounts`: user, role, permission, device, business-access concerns +- `core`: business, product, vendor, category master data +- `operations`: invoices, inventory, events, scheduling +- `reporting`: pre-aggregated dashboard tables and future ETL landing points +- `notifications`: reminders and inbox state +- `api`: thin JSON layer for the Svelte frontend + +## Legacy migration strategy + +1. Create Django migrations from the new model set. +2. Migrate the new database. +3. Run `import_legacy_data` to ingest `cincin_phase1.sqlite` and `dalcorso.sqlite`. +4. Rebuild auth credentials by forcing password resets for imported users. +5. Move ETL scripts into Django management commands later instead of repo-root scripts. + +## Frontend direction + +- SvelteKit routes mirror the existing user navigation +- app shell owns layout, not each page +- each large legacy page becomes a route plus smaller components +- fetch is centralized in `$lib/api/client.ts` +- visual primitives are compatible with `shadcn-svelte` conventions + +## Known gaps + +- Migrations were not generated, by design, because nothing heavy was run +- API coverage is partial; the high-risk domains are modeled first +- Scheduling and event recurrence still need dedicated service extraction +- ETL jobs have not been rewritten yet diff --git a/docs/session-handoff-2026-04-01.md b/docs/session-handoff-2026-04-01.md new file mode 100644 index 0000000..91d8644 --- /dev/null +++ b/docs/session-handoff-2026-04-01.md @@ -0,0 +1,1046 @@ +# Session Handoff + +Date: April 1, 2026 + +Workspace root: [`/home/sandy/HUB-master`](/home/sandy/HUB-master) + +Primary port target: [`/home/sandy/HUB-master/django-port`](/home/sandy/HUB-master/django-port) + +## 1. Purpose Of This Document + +This document is the full handoff for the current Django + Svelte porting session. + +It is written for another Codex instance that needs to continue the work without replaying the entire session. It captures: + +- what the user originally asked for +- what was actually built +- what the user ran locally +- what broke and how it was fixed +- the current architecture +- the current API and UI surface +- known gaps and risky areas +- the exact next priorities the user explicitly named + +The user said the session ends here and specifically requested a comprehensive pickup document before further refactoring. + +## 2. Original User Request + +The user asked to: + +- read `summary.md` +- port the existing FastAPI + manual migration project into: + - Django + - Svelte + - `shadcn-svelte` +- place the new implementation in [`/home/sandy/HUB-master/django-port`](/home/sandy/HUB-master/django-port) +- avoid heavy runtime work on the assistant side +- leave package installation and similar local setup tasks to the user + +Important constraint from the start: + +- do not try to run heavy commands unless absolutely necessary +- user would handle installs, dev servers, migrations, and manual validation + +## 3. Legacy App Context + +The old app is a hospitality/business operations system spanning multiple businesses and covering: + +- auth and role-based access +- dashboard reporting +- invoices +- vendors +- products and categories +- inventory +- events +- shifts and schedule +- users / roles / devices +- notifications + +Key legacy artifacts: + +- summary: [summary.md](/home/sandy/HUB-master/summary.md) +- old frontend root: [`/home/sandy/HUB-master/frontend`](/home/sandy/HUB-master/frontend) +- old backend root: [`/home/sandy/HUB-master/backend`](/home/sandy/HUB-master/backend) +- legacy databases: + - [`/home/sandy/HUB-master/cincin_phase1.sqlite`](/home/sandy/HUB-master/cincin_phase1.sqlite) + - [`/home/sandy/HUB-master/dalcorso.sqlite`](/home/sandy/HUB-master/dalcorso.sqlite) + +Primary structural problems in the legacy app: + +- backend logic was concentrated in route files +- migration/import logic lived inside runtime application code +- the frontend was a large route-heavy monolith +- business separation and multi-DB logic were brittle +- auth behavior was custom and not aligned with Django conventions the user wanted + +## 4. What Exists Now + +The port is not a blank scaffold anymore. It is a functioning Django backend plus a functioning Svelte frontend with usable CRUD/read flows for most major domains. + +Current broad state: + +- backend is Django, session-auth based, and uses Django ORM models +- frontend is the new Svelte app in `django-port/frontend` +- imported legacy data is in the Django database +- admin works +- invoice/vendor/inventory/dashboard/business/events/schedule/settings/devices screens exist and load data +- auth gating exists in the Svelte app +- permission checks exist on much of the API +- business access scoping exists in some places but is not complete + +The project is no longer at the “create the port” stage. It is now at the “tighten correctness, complete missing flows, and replace temporary UI layer” stage. + +## 5. Current Project Structure + +### 5.1 Root + +Main port root: + +- [`/home/sandy/HUB-master/django-port`](/home/sandy/HUB-master/django-port) + +Useful docs: + +- [`/home/sandy/HUB-master/django-port/docs/port-plan.md`](/home/sandy/HUB-master/django-port/docs/port-plan.md) +- [`/home/sandy/HUB-master/django-port/docs/session-handoff-2026-04-01.md`](/home/sandy/HUB-master/django-port/docs/session-handoff-2026-04-01.md) + +### 5.2 Backend + +Backend root: + +- [`/home/sandy/HUB-master/django-port/backend`](/home/sandy/HUB-master/django-port/backend) + +Key files: + +- Django entrypoint: + - [`/home/sandy/HUB-master/django-port/backend/manage.py`](/home/sandy/HUB-master/django-port/backend/manage.py) +- package config: + - [`/home/sandy/HUB-master/django-port/backend/pyproject.toml`](/home/sandy/HUB-master/django-port/backend/pyproject.toml) +- settings: + - [`/home/sandy/HUB-master/django-port/backend/config/settings.py`](/home/sandy/HUB-master/django-port/backend/config/settings.py) +- root URLs: + - [`/home/sandy/HUB-master/django-port/backend/config/urls.py`](/home/sandy/HUB-master/django-port/backend/config/urls.py) +- local database file: + - [`/home/sandy/HUB-master/django-port/backend/db.sqlite3`](/home/sandy/HUB-master/django-port/backend/db.sqlite3) + +Backend apps: + +- accounts: + - [`/home/sandy/HUB-master/django-port/backend/apps/accounts/models.py`](/home/sandy/HUB-master/django-port/backend/apps/accounts/models.py) + - [`/home/sandy/HUB-master/django-port/backend/apps/accounts/admin.py`](/home/sandy/HUB-master/django-port/backend/apps/accounts/admin.py) + - [`/home/sandy/HUB-master/django-port/backend/apps/accounts/management/commands/import_legacy_data.py`](/home/sandy/HUB-master/django-port/backend/apps/accounts/management/commands/import_legacy_data.py) +- core: + - [`/home/sandy/HUB-master/django-port/backend/apps/core/models.py`](/home/sandy/HUB-master/django-port/backend/apps/core/models.py) +- operations: + - [`/home/sandy/HUB-master/django-port/backend/apps/operations/models.py`](/home/sandy/HUB-master/django-port/backend/apps/operations/models.py) + - [`/home/sandy/HUB-master/django-port/backend/apps/operations/services.py`](/home/sandy/HUB-master/django-port/backend/apps/operations/services.py) +- reporting: + - [`/home/sandy/HUB-master/django-port/backend/apps/reporting/models.py`](/home/sandy/HUB-master/django-port/backend/apps/reporting/models.py) +- notifications: + - [`/home/sandy/HUB-master/django-port/backend/apps/notifications/models.py`](/home/sandy/HUB-master/django-port/backend/apps/notifications/models.py) +- API: + - [`/home/sandy/HUB-master/django-port/backend/apps/api/urls.py`](/home/sandy/HUB-master/django-port/backend/apps/api/urls.py) + - [`/home/sandy/HUB-master/django-port/backend/apps/api/views.py`](/home/sandy/HUB-master/django-port/backend/apps/api/views.py) + +### 5.3 Frontend + +Frontend root: + +- [`/home/sandy/HUB-master/django-port/frontend`](/home/sandy/HUB-master/django-port/frontend) + +Key config files: + +- [`/home/sandy/HUB-master/django-port/frontend/package.json`](/home/sandy/HUB-master/django-port/frontend/package.json) +- [`/home/sandy/HUB-master/django-port/frontend/package-lock.json`](/home/sandy/HUB-master/django-port/frontend/package-lock.json) +- [`/home/sandy/HUB-master/django-port/frontend/svelte.config.js`](/home/sandy/HUB-master/django-port/frontend/svelte.config.js) +- [`/home/sandy/HUB-master/django-port/frontend/vite.config.ts`](/home/sandy/HUB-master/django-port/frontend/vite.config.ts) +- [`/home/sandy/HUB-master/django-port/frontend/tsconfig.json`](/home/sandy/HUB-master/django-port/frontend/tsconfig.json) +- [`/home/sandy/HUB-master/django-port/frontend/components.json`](/home/sandy/HUB-master/django-port/frontend/components.json) + +Frontend shared files: + +- global styles: + - [`/home/sandy/HUB-master/django-port/frontend/src/app.css`](/home/sandy/HUB-master/django-port/frontend/src/app.css) +- API client: + - [`/home/sandy/HUB-master/django-port/frontend/src/lib/api/client.ts`](/home/sandy/HUB-master/django-port/frontend/src/lib/api/client.ts) +- auth store: + - [`/home/sandy/HUB-master/django-port/frontend/src/lib/stores/auth.ts`](/home/sandy/HUB-master/django-port/frontend/src/lib/stores/auth.ts) +- shared types: + - [`/home/sandy/HUB-master/django-port/frontend/src/lib/types/domain.ts`](/home/sandy/HUB-master/django-port/frontend/src/lib/types/domain.ts) +- shell/sidebar: + - [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/app-shell/sidebar.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/app-shell/sidebar.svelte) + +Temporary UI primitives still in use: + +- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/button.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/button.svelte) +- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card.svelte) + +Routes: + +- shell: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/+layout.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/+layout.svelte) + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/+page.ts`](/home/sandy/HUB-master/django-port/frontend/src/routes/+page.ts) +- login: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/login/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/login/+page.svelte) +- protected app: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.svelte) + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.ts`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.ts) +- dashboard: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/dashboard/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/dashboard/+page.svelte) +- business: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/business/[id]/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/business/[id]/+page.svelte) +- invoices: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte) +- vendors: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/vendors/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/vendors/+page.svelte) +- inventory: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/inventory/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/inventory/+page.svelte) +- events: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/events/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/events/+page.svelte) +- schedule: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/schedule/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/schedule/+page.svelte) +- settings: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte) +- devices: + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/devices/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/devices/+page.svelte) + +## 6. Backend Architecture + +### 6.1 Domain split + +Backend was intentionally split by business domain: + +- `accounts` + - custom user model + - roles + - permissions + - allowed business links + - allowed devices + - registration tokens +- `core` + - businesses + - vendors + - categories + - products +- `operations` + - invoices + - line items + - inventory + - events + - shifts/schedule +- `reporting` + - daily revenue summary table +- `notifications` + - inbox/reminder state +- `api` + - JSON endpoints consumed by the Svelte app + +### 6.2 Current API design + +API is function-based and centralized in one large file: + +- [`/home/sandy/HUB-master/django-port/backend/apps/api/views.py`](/home/sandy/HUB-master/django-port/backend/apps/api/views.py) + +This is one of the remaining structural issues. The file works, but it is too large and mixes: + +- auth/session handling +- permission checks +- business-scoping helpers +- serializers/payload builders +- endpoint implementations + +It was acceptable to get the port moving, but the next Codex should treat this as a cleanup candidate after correctness gaps are closed. + +### 6.3 Important backend helper patterns already present + +Notable helpers in `views.py`: + +- `_json_body` +- `_money` +- `_bad_request` +- `_forbidden` +- `api_login_required` +- `require_permissions` +- `_allowed_business_ids` +- `_ensure_business_access` + +These helpers are central to current auth and scoping behavior. + +## 7. Frontend Architecture + +### 7.1 General shape + +The frontend is now the Svelte app under `django-port/frontend`. It is not the old React app. + +Important note from the session: + +- the user accidentally launched the old frontend once from [`/home/sandy/HUB-master/frontend`](/home/sandy/HUB-master/frontend) +- when that happens, the browser console shows React/TanStack/Axios traces and `/api/auth/refresh` +- that is the wrong app + +The correct app is always: + +- [`/home/sandy/HUB-master/django-port/frontend`](/home/sandy/HUB-master/django-port/frontend) + +### 7.2 API client + +The frontend uses `fetch`, not axios, in: + +- [`/home/sandy/HUB-master/django-port/frontend/src/lib/api/client.ts`](/home/sandy/HUB-master/django-port/frontend/src/lib/api/client.ts) + +Key client behaviors: + +- `VITE_API_BASE` defaults to `http://localhost:8000/api` +- trailing slashes are normalized +- CSRF header is added automatically on non-GET/HEAD requests +- cookies are sent with `credentials: "include"` +- errors throw `ApiError(status, message)` + +### 7.3 Auth bootstrap + +Auth store: + +- [`/home/sandy/HUB-master/django-port/frontend/src/lib/stores/auth.ts`](/home/sandy/HUB-master/django-port/frontend/src/lib/stores/auth.ts) + +Key behaviors: + +- `bootstrapAuth()` loads current user via `/api/auth/me/` +- `authReady` gates rendering +- `hasPermission()` checks permission keys and superuser bypass +- `clearAuth()` clears local auth state on logout + +Protected app gating: + +- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.svelte) +- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.ts`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/+layout.ts) + +Login route: + +- [`/home/sandy/HUB-master/django-port/frontend/src/routes/login/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/login/+page.svelte) + +### 7.4 UI state + +The frontend is visually usable, but it is not yet the final `shadcn-svelte` implementation. + +Current reality: + +- route structure is real +- forms/data-loading logic are real +- layout is real +- styles/components are still mostly temporary + +Specifically: + +- `button.svelte` and `card.svelte` are custom placeholders +- `components.json` exists, but `shadcn-svelte` has not actually replaced those primitives yet + +This was one of the user’s explicit remaining tasks. + +## 8. What Was Implemented In This Session + +### 8.1 Django project scaffold and model base + +Built a Django project manually under `django-port/backend` with: + +- domain-split apps +- migrations +- admin registrations +- custom user model +- settings for local session auth and cross-origin frontend dev + +### 8.2 Legacy data import + +Implemented: + +- [`/home/sandy/HUB-master/django-port/backend/apps/accounts/management/commands/import_legacy_data.py`](/home/sandy/HUB-master/django-port/backend/apps/accounts/management/commands/import_legacy_data.py) + +Purpose: + +- import the two legacy SQLite databases into the Django schema +- merge master data across them where natural keys match + +Import behavior and fixes: + +- businesses matched by `short_code` +- categories matched by `name` +- vendors matched by `name` +- products matched by `(gtin, name)` +- users matched by `username` +- roles matched by `name` +- importer no longer forces legacy user/role primary keys into Django PKs +- optional legacy link tables are guarded with `_table_exists` +- actual `daily_revenue_summary` schema is respected +- operational data only imports from the primary CinCin DB +- in-memory maps are maintained per source DB to preserve FK wiring during import + +The importer was debugged through multiple user-reported tracebacks and is currently working well enough for the user to inspect data in admin. + +### 8.3 Auth/session flow + +Implemented backend auth API: + +- `POST /api/auth/login/` +- `POST /api/auth/logout/` +- `GET /api/auth/me/` +- `GET /api/auth/csrf/` + +Important changes made along the way: + +- unauthenticated API requests now return JSON `401`, not HTML/login redirects +- frontend login path now uses trailing slashes consistently +- local CORS/CSRF/session settings were added for port `5173` +- superusers are represented properly in frontend auth payload + +### 8.4 Invoice slice + +Backend: + +- invoice payload builder and create logic in: + - [`/home/sandy/HUB-master/django-port/backend/apps/operations/services.py`](/home/sandy/HUB-master/django-port/backend/apps/operations/services.py) + +Frontend: + +- invoice list +- invoice detail pane +- invoice create form +- lookup loading for vendors/products/categories/businesses + +Current API: + +- `GET /api/invoices/` +- `POST /api/invoices/` +- `GET /api/invoices//` + +Missing: + +- invoice update +- invoice delete + +Those are explicitly still on the to-do list. + +### 8.5 Vendor and inventory slice + +Implemented vendor list/create/update: + +- `GET /api/vendors/` +- `POST /api/vendors/` +- `GET /api/vendors//` +- `PUT /api/vendors//` + +Implemented inventory list: + +- `GET /api/inventory/` + +Frontend vendors page supports: + +- query filter +- business filter +- category filter +- create vendor +- update vendor + +Frontend inventory page supports: + +- query filter +- category filter +- stock summary cards +- inventory item list + +Important caveat: + +- inventory/business scoping is still one of the least trustworthy areas and should be revisited + +### 8.6 Dashboard and business reporting + +Implemented: + +- `GET /api/dashboard/overview/` +- `GET /api/dashboard/business-summary/` +- `GET /api/businesses//summary/` + +Frontend dashboard includes: + +- consolidated metrics +- per-business summary cards +- links into business detail pages + +Frontend business page includes: + +- summary metrics +- recent revenue rows +- recent invoices + +### 8.7 Events and schedule + +Implemented: + +- `GET /api/events/` +- `POST /api/events/` +- `GET /api/events//` +- `PUT /api/events//` +- `DELETE /api/events//` +- `GET /api/schedule/overview/` + +Frontend events page supports: + +- list +- create +- edit +- delete + +Frontend schedule page supports: + +- roles +- templates +- assignments +- business filter + +### 8.8 Settings and devices + +Implemented: + +- `GET /api/settings/overview/` +- `POST /api/settings/users/` +- `GET /api/devices/` +- `POST /api/devices/` +- `PUT /api/devices//` +- `DELETE /api/devices//` +- `GET /api/devices/tokens/` +- `POST /api/devices/tokens/` +- `DELETE /api/devices/tokens//` + +Frontend settings page supports: + +- roles list +- permissions list +- users list +- create-user form + +Frontend devices page supports: + +- device list +- registration token list +- create device +- create token +- delete device +- delete token + +Missing: + +- user update +- user delete + +Those are explicitly still on the to-do list. + +### 8.9 Permission enforcement + +Permission enforcement was added on many routes using legacy domain-style keys. + +Known permission keys in use: + +- `categories.manage` +- `dashboard.view` +- `events.create` +- `events.delete` +- `events.edit` +- `events.view` +- `inventory.adjust` +- `inventory.stock_count` +- `inventory.view` +- `invoices.create` +- `invoices.delete` +- `invoices.edit` +- `invoices.mark_paid` +- `invoices.view` +- `products.create` +- `products.edit` +- `products.view` +- `shifts.availability` +- `shifts.manage` +- `shifts.view` +- `users.manage` +- `vendors.create` +- `vendors.delete` +- `vendors.edit` +- `vendors.view` + +Frontend sidebar now hides routes by permission, and superusers are treated as allowed everywhere. + +### 8.10 Business access scoping + +Business access scoping was started but not completed. + +Helpers now exist: + +- `_allowed_business_ids(user)` +- `_ensure_business_access(request, business_id)` + +Scoping has been added to several routes, including: + +- businesses list +- business summary +- dashboard business rollup +- some vendor operations +- some invoice operations +- events +- schedule overview +- user creation business assignment checks + +This is incomplete and one of the top-priority remaining tasks. + +## 9. Exact API Surface At End Of Session + +Current API routes from: + +- [`/home/sandy/HUB-master/django-port/backend/apps/api/urls.py`](/home/sandy/HUB-master/django-port/backend/apps/api/urls.py) + +Routes: + +- `POST /api/auth/login/` +- `POST /api/auth/logout/` +- `GET /api/auth/me/` +- `GET /api/auth/csrf/` +- `GET /api/businesses/` +- `GET /api/businesses//summary/` +- `GET /api/products/` +- `GET /api/categories/` +- `GET /api/dashboard/overview/` +- `GET /api/dashboard/business-summary/` +- `GET/POST /api/vendors/` +- `GET/PUT /api/vendors//` +- `GET/POST /api/invoices/` +- `GET /api/invoices//` +- `GET /api/inventory/` +- `GET/POST /api/events/` +- `GET/PUT/DELETE /api/events//` +- `GET /api/schedule/overview/` +- `GET /api/settings/overview/` +- `POST /api/settings/users/` +- `GET/POST /api/devices/` +- `PUT/DELETE /api/devices//` +- `GET/POST /api/devices/tokens/` +- `DELETE /api/devices/tokens//` +- `GET /api/notifications/` + +## 10. User-Run Commands And Workflow + +The user handled runtime/bootstrap steps locally. + +### 10.1 Backend commands + +Run from: + +- [`/home/sandy/HUB-master/django-port/backend`](/home/sandy/HUB-master/django-port/backend) + +Commands used during the session: + +```bash +source ../venv/bin/activate +pip install -e . +python manage.py makemigrations +python manage.py migrate +python manage.py createsuperuser +python manage.py import_legacy_data +python manage.py runserver +``` + +The user also created at least one additional superuser while testing frontend login. + +### 10.2 Frontend commands + +Run from: + +- [`/home/sandy/HUB-master/django-port/frontend`](/home/sandy/HUB-master/django-port/frontend) + +Commands used: + +```bash +npm install +npm run dev +``` + +`shadcn-svelte` was discussed and prepared for, but not fully applied. + +### 10.3 Lightweight verification commands + +The assistant used lightweight backend verification only, primarily: + +```bash +python3 -m compileall django-port/backend +``` + +This passed after the latest changes. + +No full backend test suite exists yet. +No frontend build/test pass was run by the assistant. + +## 11. Problems Encountered And How They Were Fixed + +This section matters. The next Codex should not spend time rediscovering these. + +### 11.1 Wrong frontend was launched + +The user initially ran: + +- [`/home/sandy/HUB-master/frontend`](/home/sandy/HUB-master/frontend) + +This produced React/Axios/TanStack console output such as: + +- `react-dom_client.js` +- `@tanstack/react-query` +- `axios` +- `/api/auth/refresh` + +That was the old app, not the port. + +Correct frontend: + +- [`/home/sandy/HUB-master/django-port/frontend`](/home/sandy/HUB-master/django-port/frontend) + +### 11.2 Backend editable install failed + +Problem: + +- `pip install -e .` failed because setuptools found multiple top-level packages in a flat layout + +Fix: + +- [`/home/sandy/HUB-master/django-port/backend/pyproject.toml`](/home/sandy/HUB-master/django-port/backend/pyproject.toml) was patched with explicit package discovery/build config + +### 11.3 Frontend install failed on Lucide version + +Problem: + +- `npm install` failed with: + - no matching version for `@lucide/svelte@^0.475.0` + +Fix: + +- removed the bad dependency from [`/home/sandy/HUB-master/django-port/frontend/package.json`](/home/sandy/HUB-master/django-port/frontend/package.json) + +### 11.4 Importer product uniqueness failure + +Problem: + +- import hit `UNIQUE constraint failed: core_product.gtin, core_product.name` + +Cause: + +- duplicate products across the two legacy DBs + +Fix: + +- import logic now matches products by natural key `(gtin, name)` and maintains per-DB in-memory maps + +### 11.5 Importer user primary key collision + +Problem: + +- import hit `UNIQUE constraint failed: accounts_user.id` + +Cause: + +- importer was effectively trying to preserve legacy IDs in a way that collided with already-created Django users + +Fix: + +- importer no longer forces legacy PKs onto Django user/role PKs +- users/roles are matched by natural keys instead + +### 11.6 Importer reporting schema assumption was wrong + +Problem: + +- import crashed on `daily_revenue_summary` because expected key was missing + +Cause: + +- actual legacy reporting schema did not match the initial assumption + +Fix: + +- importer was patched to use the actual table shape + +### 11.7 Auth failed due to redirect/405/trailing slash issues + +Problem symptoms: + +- `GET /api/auth/login/` got `405` +- `POST /api/auth/login` hit `APPEND_SLASH` runtime error +- API requests redirected to login instead of returning JSON + +Fixes: + +- API auth endpoints now use correct slash-normalized URLs +- frontend client normalizes paths +- unauthenticated API access now returns JSON `401` + +### 11.8 CSRF/CORS/session local dev issues + +Problem: + +- local frontend auth still failed after path fixes + +Fix: + +- dev settings were patched to trust local frontend origins and support local cookie behavior + +Primary file: + +- [`/home/sandy/HUB-master/django-port/backend/config/settings.py`](/home/sandy/HUB-master/django-port/backend/config/settings.py) + +### 11.9 Sign-out button did not work + +Problem: + +- custom `Button` component was not forwarding click events properly + +Fix: + +- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/button.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/button.svelte) was patched to dispatch click events + +### 11.10 One syntax error during recent scoping work + +Problem: + +- a walrus assignment inside a boolean expression caused Python syntax trouble in `event_detail_view` + +Fix: + +- rewritten as separate assignment and check + +`compileall` passed afterward. + +## 12. What The User Already Validated + +The user reported that the following looked good enough to continue: + +- Django admin loads +- legacy import succeeded sufficiently for inspection +- frontend displays the new Svelte app +- invoices are populated +- sign out works after the button fix +- vendor/inventory/dashboard/business/events/settings/devices work well enough to continue + +This matters because the port is past the initial bootstrapping phase. + +## 13. Known Gaps And Risk Areas + +### 13.1 Business access scoping is incomplete + +This is the highest correctness gap and one of the three explicit remaining tasks from the user. + +Areas that still need review or stronger implementation: + +- `products_view` + - currently permission-scoped, but not clearly business-scoped + - products are global master data today, but the app likely expects business-aware visibility in some contexts +- `categories_view` + - same concern if categories should effectively be filtered by accessible business relationships +- `vendors_view` + - GET filtering only checks explicit `business_id` filter + - if no business filter is passed, user may still see vendors unrelated to allowed businesses +- `vendor_detail_view` + - direct vendor fetch does not currently deny access based on linked businesses +- `invoices_view` + - GET does not currently auto-scope invoice list to allowed businesses when no explicit business filter is passed +- `dashboard_overview_view` + - currently aggregates global invoices/revenue/vendor counts, not allowed-business-only counts +- `inventory_view` + - current scoping is a crude relationship chain through categories/vendors/businesses and should be treated as suspect + - likely needs a cleaner data model or explicit business linkage logic +- `notifications_view` + - verify whether notifications should be per-user only or also business-scoped +- `devices/settings` + - probably global admin surfaces by design, but confirm intended restrictions + +Recommendation: + +- audit every endpoint in [`/home/sandy/HUB-master/django-port/backend/apps/api/views.py`](/home/sandy/HUB-master/django-port/backend/apps/api/views.py) +- decide endpoint-by-endpoint whether the resource is: + - global admin-only + - business-scoped + - user-scoped +- then apply scoping consistently for both list and detail endpoints + +### 13.2 Update/delete flows are incomplete + +Explicit user-requested remaining work: + +- user update/delete +- invoice update/delete + +Current state: + +- events update/delete exist +- devices delete exists +- token delete exists +- vendors update exists +- users are create-only +- invoices are create + list + detail only + +### 13.3 UI layer is still temporary + +Explicit user-requested remaining work: + +- replace temporary UI primitives with real `shadcn-svelte` + +Current state: + +- visual layer is a usable stopgap +- actual generated `shadcn-svelte` primitives are not in place yet +- `components.json` exists, but the temporary primitives remain the active UI basis + +### 13.4 API module is too large + +`apps/api/views.py` is currently doing too much. + +Not urgent compared with the three user-named priorities, but still true: + +- payload serialization should move out +- route handlers should be slimmer +- business access logic should be centralized further +- per-domain modules would reduce risk + +### 13.5 No robust automated verification + +Current verification state: + +- backend syntax check passed +- user manually inspected admin and major frontend pages + +Missing: + +- backend tests +- API tests +- frontend tests +- end-to-end checks + +## 14. The Three Explicit Remaining Tasks From The User + +The user explicitly said the next Codex still needs to: + +1. finish business access scoping everywhere, especially inventory/product/vendor edge cases +2. add update/delete flows for users and invoices +3. replace the temporary UI primitives with real `shadcn-svelte` + +Those should be treated as the authoritative next priorities. + +## 15. Recommended Order For The Next Codex + +Recommended order: + +1. finish business access scoping first +2. add user/invoice update/delete flows second +3. replace temporary UI layer with `shadcn-svelte` third +4. only after that, consider refactoring `apps/api/views.py` + +Reasoning: + +- scoping is a correctness/security issue +- CRUD completion is functional completeness +- UI primitive replacement is important but less risky than wrong data exposure + +## 16. Concrete Starting Points For The Next Codex + +### 16.1 Start with backend scoping audit + +Primary file: + +- [`/home/sandy/HUB-master/django-port/backend/apps/api/views.py`](/home/sandy/HUB-master/django-port/backend/apps/api/views.py) + +Suggested first checks: + +- make `dashboard_overview_view` scoped to allowed businesses +- make `vendors_view` default to allowed-business-only results when user is not superuser +- make `vendor_detail_view` deny access for vendors outside allowed businesses +- make `invoices_view` default to allowed-business-only results when user is not superuser +- rework `inventory_view` scoping so it is deterministic and understandable +- decide whether `products_view` and `categories_view` are global lookup tables or business-filtered lookups + +### 16.2 Then add user update/delete + +Likely backend addition: + +- `GET/PUT/DELETE /api/settings/users//` + +Likely frontend target: + +- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte) + +### 16.3 Then add invoice update/delete + +Likely backend additions: + +- `PUT /api/invoices//` +- `DELETE /api/invoices//` + +Potential need: + +- extend [`/home/sandy/HUB-master/django-port/backend/apps/operations/services.py`](/home/sandy/HUB-master/django-port/backend/apps/operations/services.py) so invoice create/update share calculation logic + +Likely frontend target: + +- [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte) + +### 16.4 Finally replace temporary UI with shadcn-svelte + +Files to revisit: + +- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/button.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/button.svelte) +- [`/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card.svelte`](/home/sandy/HUB-master/django-port/frontend/src/lib/components/ui/card.svelte) +- route pages listed above +- [`/home/sandy/HUB-master/django-port/frontend/src/app.css`](/home/sandy/HUB-master/django-port/frontend/src/app.css) +- [`/home/sandy/HUB-master/django-port/frontend/components.json`](/home/sandy/HUB-master/django-port/frontend/components.json) + +The user was willing to run local install/init steps, so the next Codex can assume: + +- any actual `npx shadcn-svelte@latest init` +- any `add` command + +should be handed to the user if needed instead of run blindly. + +## 17. Verification State At End Of Session + +Current confidence level: + +- medium for basic functionality +- lower for permission + scoping correctness + +Verified enough to continue: + +- Django project boots +- migrations/import succeeded locally +- admin is usable +- frontend is the new Svelte app +- major pages load +- invoice data shows up +- sign out works + +Not verified: + +- all CRUD edge cases +- all permission edge cases +- all business-scoping edge cases +- production-grade UI replacement + +## 18. Short Pickup Summary + +If another Codex needs the shortest possible summary: + +- The Django + Svelte port in [`/home/sandy/HUB-master/django-port`](/home/sandy/HUB-master/django-port) is real and functional, not just scaffolded. +- Legacy data has been imported and admin works. +- The Svelte frontend has working routes for dashboard, business, invoices, vendors, inventory, events, schedule, settings, and devices. +- Auth, sessions, and permission-based sidebar gating are in place. +- The biggest unfinished work is: + - complete business access scoping + - add user/invoice update/delete + - replace temporary UI primitives with real `shadcn-svelte` +- Primary files to continue from are: + - [`/home/sandy/HUB-master/django-port/backend/apps/api/views.py`](/home/sandy/HUB-master/django-port/backend/apps/api/views.py) + - [`/home/sandy/HUB-master/django-port/backend/apps/api/urls.py`](/home/sandy/HUB-master/django-port/backend/apps/api/urls.py) + - [`/home/sandy/HUB-master/django-port/backend/apps/operations/services.py`](/home/sandy/HUB-master/django-port/backend/apps/operations/services.py) + - [`/home/sandy/HUB-master/django-port/frontend/src/lib/api/client.ts`](/home/sandy/HUB-master/django-port/frontend/src/lib/api/client.ts) + - [`/home/sandy/HUB-master/django-port/frontend/src/lib/stores/auth.ts`](/home/sandy/HUB-master/django-port/frontend/src/lib/stores/auth.ts) + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/settings/+page.svelte) + - [`/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte`](/home/sandy/HUB-master/django-port/frontend/src/routes/app/invoices/+page.svelte) + diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..5ab5262 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "style": "default", + "typescript": true, + "tailwind": { + "config": "", + "css": "src/app.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui" + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..278d692 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2568 @@ +{ + "name": "hub-svelte-port", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hub-svelte-port", + "version": "0.1.0", + "dependencies": { + "bits-ui": "^0.22.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^2.5.5" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^4.0.0", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.1.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz", + "integrity": "sha512-kmuYSQdD2AwThymQF0haQhM8rE5rhutQXG4LNbnbShwhMO4qQGnKaaTy+88DuNSuoQDi58+thpq8XpHc1+oEKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-meta-resolve": "^4.1.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz", + "integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bits-ui": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.22.0.tgz", + "integrity": "sha512-r7Fw1HNgA4YxZBRcozl7oP0bheQ8EHh+kfMBZJgyFISix8t4p/nqDcHLmBgIiJ3T5XjYnJRorYDjIWaCfhb5fw==", + "license": "MIT", + "dependencies": { + "@internationalized/date": "^3.5.1", + "@melt-ui/svelte": "0.76.2", + "nanoid": "^5.0.5" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/bits-ui/node_modules/@melt-ui/svelte": { + "version": "0.76.2", + "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz", + "integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.3.1", + "@floating-ui/dom": "^1.4.5", + "@internationalized/date": "^3.5.0", + "dequal": "^2.0.3", + "focus-trap": "^7.5.2", + "nanoid": "^5.0.4" + }, + "peerDependencies": { + "svelte": ">=3 <5" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", + "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b634e65 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "hub-svelte-port", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^4.0.0", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.1.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + }, + "dependencies": { + "bits-ui": "^0.22.0", + "clsx": "^2.1.1", + "class-variance-authority": "^0.7.1", + "tailwind-merge": "^2.5.5" + } +} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..3fb6701 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,38 @@ +@import "tailwindcss"; + +:root { + --background: #f5f1e8; + --foreground: #201916; + --card: #fffcf6; + --card-foreground: #201916; + --popover: #fff9ef; + --popover-foreground: #201916; + --primary: #16302b; + --primary-foreground: #f6efe4; + --secondary: #d8c3a8; + --secondary-foreground: #2d241e; + --muted: #ece2d3; + --muted-foreground: #615246; + --accent: #ba6c46; + --accent-foreground: #fff7ef; + --border: #cdbca5; + --input: #e7dbca; + --ring: #16302b; +} + +body { + background: + radial-gradient(circle at top left, rgba(186, 108, 70, 0.18), transparent 30%), + radial-gradient(circle at bottom right, rgba(22, 48, 43, 0.18), transparent 35%), + var(--background); + color: var(--foreground); + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + min-height: 100vh; +} + +.panel { + background: color-mix(in srgb, var(--card) 92%, white); + border: 1px solid var(--border); + border-radius: 1rem; + box-shadow: 0 18px 60px rgba(32, 25, 22, 0.08); +} diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..adf8bd8 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..2c3e391 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,197 @@ +const RAW_API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8000/api"; +const API_BASE = RAW_API_BASE.replace(/\/+$/, ""); + +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +function getCookie(name: string): string | null { + const cookie = document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)); + return cookie ? decodeURIComponent(cookie.split("=")[1]) : null; +} + +async function request(path: string, init: RequestInit = {}): Promise { + const method = init.method ?? "GET"; + const headers = new Headers(init.headers ?? {}); + if (method !== "GET" && method !== "HEAD") { + const csrf = getCookie("hub_csrftoken"); + if (csrf) headers.set("X-CSRFToken", csrf); + } + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + const normalizedPath = `/${path.replace(/^\/+/, "")}`; + const response = await fetch(`${API_BASE}${normalizedPath}`, { + credentials: "include", + headers, + ...init + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({ detail: "Request failed" })); + throw new ApiError(response.status, payload.detail ?? "Request failed"); + } + + return response.json() as Promise; +} + +export const api = { + csrf: () => request<{ detail: string }>("/auth/csrf/"), + me: () => request<{ user: import("$lib/types/domain").AuthUser }>("/auth/me/"), + login: (username: string, password: string) => + request<{ user: import("$lib/types/domain").AuthUser }>("/auth/login/", { + method: "POST", + body: JSON.stringify({ username, password }) + }), + logout: () => request<{ detail: string }>("/auth/logout/", { method: "POST" }), + businesses: () => request<{ results: import("$lib/types/domain").Business[] }>("/businesses/"), + businessSummary: (businessId: number) => + request(`/businesses/${businessId}/summary/`), + products: (q = "") => + request<{ results: import("$lib/types/domain").Product[] }>(`/products/${q ? `?q=${encodeURIComponent(q)}` : ""}`), + categories: () => request<{ results: import("$lib/types/domain").Category[] }>("/categories/"), + dashboardOverview: () => request("/dashboard/overview/"), + dashboardBusinessSummary: () => + request<{ results: import("$lib/types/domain").DashboardBusinessSummary[] }>("/dashboard/business-summary/"), + invoices: (params?: { q?: string; payment_status?: string; vendor_id?: number; business_id?: number }) => { + const search = new URLSearchParams(); + if (params?.q) search.set("q", params.q); + if (params?.payment_status) search.set("payment_status", params.payment_status); + if (params?.vendor_id) search.set("vendor_id", String(params.vendor_id)); + if (params?.business_id) search.set("business_id", String(params.business_id)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return request<{ results: import("$lib/types/domain").Invoice[] }>(`/invoices/${suffix}`); + }, + createInvoice: (payload: import("$lib/types/domain").InvoiceCreatePayload) => + request("/invoices/", { + method: "POST", + body: JSON.stringify(payload) + }), + invoice: (invoiceId: number) => request(`/invoices/${invoiceId}/`), + updateInvoice: (invoiceId: number, payload: import("$lib/types/domain").InvoiceCreatePayload) => + request(`/invoices/${invoiceId}/`, { + method: "PUT", + body: JSON.stringify(payload) + }), + deleteInvoice: (invoiceId: number) => + request<{ detail: string }>(`/invoices/${invoiceId}/`, { method: "DELETE" }), + vendors: (params?: { q?: string; business_id?: number; category_id?: number }) => { + const search = new URLSearchParams(); + if (params?.q) search.set("q", params.q); + if (params?.business_id) search.set("business_id", String(params.business_id)); + if (params?.category_id) search.set("category_id", String(params.category_id)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return request<{ results: import("$lib/types/domain").Vendor[] }>(`/vendors/${suffix}`); + }, + createVendor: (payload: Partial & { name: string }) => + request("/vendors/", { + method: "POST", + body: JSON.stringify(payload) + }), + updateVendor: (vendorId: number, payload: Partial) => + request(`/vendors/${vendorId}/`, { + method: "PUT", + body: JSON.stringify(payload) + }), + inventory: (params?: { q?: string; category_id?: number }) => { + const search = new URLSearchParams(); + if (params?.q) search.set("q", params.q); + if (params?.category_id) search.set("category_id", String(params.category_id)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return request<{ results: import("$lib/types/domain").InventoryItem[] }>(`/inventory/${suffix}`); + }, + events: (params?: { business_id?: number; event_type?: string }) => { + const search = new URLSearchParams(); + if (params?.business_id) search.set("business_id", String(params.business_id)); + if (params?.event_type) search.set("event_type", params.event_type); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return request<{ results: import("$lib/types/domain").EventItem[] }>(`/events/${suffix}`); + }, + createEvent: ( + payload: Pick< + import("$lib/types/domain").EventItem, + "business_id" | "title" | "description" | "event_type" | "start_datetime" | "end_datetime" | "all_day" | "location" | "color" | "recurrence_type" | "recurrence_end_date" + > + ) => + request("/events/", { + method: "POST", + body: JSON.stringify(payload) + }), + updateEvent: (eventId: number, payload: Partial) => + request(`/events/${eventId}/`, { + method: "PUT", + body: JSON.stringify(payload) + }), + deleteEvent: (eventId: number) => + request<{ detail: string }>(`/events/${eventId}/`, { method: "DELETE" }), + scheduleOverview: (params?: { business_id?: number }) => { + const search = new URLSearchParams(); + if (params?.business_id) search.set("business_id", String(params.business_id)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return request(`/schedule/overview/${suffix}`); + }, + settingsOverview: () => request("/settings/overview/"), + createUser: (payload: { + username: string; + password: string; + display_name?: string; + email?: string; + role_id?: number | null; + is_active?: boolean; + business_ids?: number[]; + }) => + request("/settings/users/", { + method: "POST", + body: JSON.stringify(payload) + }), + updateUser: ( + userId: number, + payload: { + username?: string; + password?: string; + display_name?: string; + email?: string; + role_id?: number | null; + is_active?: boolean; + is_superuser?: boolean; + business_ids?: number[]; + } + ) => + request(`/settings/users/${userId}/`, { + method: "PUT", + body: JSON.stringify(payload) + }), + deleteUser: (userId: number) => + request<{ detail: string }>(`/settings/users/${userId}/`, { method: "DELETE" }), + devices: () => request<{ results: import("$lib/types/domain").DeviceItem[] }>("/devices/"), + createDevice: (payload: { + ip_address: string; + label?: string; + user_agent?: string; + is_active?: boolean; + ipv6_prefix?: string; + known_ips?: string[]; + }) => + request("/devices/", { + method: "POST", + body: JSON.stringify(payload) + }), + deleteDevice: (deviceId: number) => + request<{ detail: string }>(`/devices/${deviceId}/`, { method: "DELETE" }), + deviceTokens: () => request<{ results: import("$lib/types/domain").DeviceRegistrationTokenItem[] }>("/devices/tokens/"), + createDeviceToken: (payload: { label?: string; expires_in_days?: number }) => + request("/devices/tokens/", { + method: "POST", + body: JSON.stringify(payload) + }), + deleteDeviceToken: (tokenId: number) => + request<{ detail: string }>(`/devices/tokens/${tokenId}/`, { method: "DELETE" }), + notifications: () => request<{ results: unknown[] }>("/notifications/") +}; diff --git a/frontend/src/lib/components/app-shell/sidebar.svelte b/frontend/src/lib/components/app-shell/sidebar.svelte new file mode 100644 index 0000000..aa89ecd --- /dev/null +++ b/frontend/src/lib/components/app-shell/sidebar.svelte @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/lib/components/ui/button.svelte b/frontend/src/lib/components/ui/button.svelte new file mode 100644 index 0000000..5bbaf83 --- /dev/null +++ b/frontend/src/lib/components/ui/button.svelte @@ -0,0 +1,42 @@ + + +{#if href} + + + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/card.svelte b/frontend/src/lib/components/ui/card.svelte new file mode 100644 index 0000000..534899c --- /dev/null +++ b/frontend/src/lib/components/ui/card.svelte @@ -0,0 +1,3 @@ +
+ +
diff --git a/frontend/src/lib/stores/auth.ts b/frontend/src/lib/stores/auth.ts new file mode 100644 index 0000000..6f3dea6 --- /dev/null +++ b/frontend/src/lib/stores/auth.ts @@ -0,0 +1,51 @@ +import { get, writable } from "svelte/store"; +import type { AuthUser } from "$lib/types/domain"; +import { ApiError, api } from "$lib/api/client"; + +export const authUser = writable(null); +export const authReady = writable(false); + +let bootstrapPromise: Promise | null = null; + +export function clearAuth() { + authUser.set(null); + authReady.set(true); +} + +export function hasPermission(permissionKey: string): boolean { + const user = get(authUser); + if (!user) return false; + if (user.is_superuser) return true; + return user.permission_keys.includes(permissionKey); +} + +export async function bootstrapAuth(force = false): Promise { + if (!force && get(authReady)) { + return get(authUser); + } + if (!force && bootstrapPromise) { + return bootstrapPromise; + } + + authReady.set(false); + bootstrapPromise = (async () => { + try { + const data = await api.me(); + authUser.set(data.user); + authReady.set(true); + return data.user; + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + authUser.set(null); + authReady.set(true); + return null; + } + authReady.set(true); + throw error; + } finally { + bootstrapPromise = null; + } + })(); + + return bootstrapPromise; +} diff --git a/frontend/src/lib/types/domain.ts b/frontend/src/lib/types/domain.ts new file mode 100644 index 0000000..18a9ceb --- /dev/null +++ b/frontend/src/lib/types/domain.ts @@ -0,0 +1,306 @@ +export interface AuthUser { + id: number; + username: string; + display_name: string; + role: string | null; + is_superuser: boolean; + permission_keys: string[]; + allowed_business_ids: number[]; +} + +export interface DashboardOverview { + total_revenue: number; + total_expenses: number; + outstanding_invoices: number; + business_count: number; + vendor_count: number; + invoice_count: number; + total_vat: number; + unread_notifications: number; +} + +export interface Business { + id: number; + legacy_id: number | null; + name: string; + short_code: string; + currency: string; +} + +export interface Vendor { + id: number; + legacy_id: number | null; + name: string; + vat_number: string; + registration_id: string; + contact_email: string; + contact_phone: string; + address: string; + notes: string; + is_active: boolean; + business_ids: number[]; + category_ids: number[]; +} + +export interface Product { + id: number; + legacy_id: number | null; + name: string; + gtin: string; + vat_rate: number; + uom: string; + currency_code: string; + category_ids: number[]; + net_purchase_price: number; + display_sales_price: number; +} + +export interface InventoryItem { + product_id: number; + legacy_product_id: number | null; + product_name: string; + gtin: string; + quantity_on_hand: number; + uom: string; + vat_rate: number; + net_purchase_price: number; + display_sales_price: number; + category_count: number; + category_ids: number[]; + category_names: string[]; +} + +export interface DashboardBusinessSummary { + business_id: number; + business_name: string; + short_code: string; + currency: string; + total_revenue: number; + total_expenses: number; + outstanding_invoices: number; + invoice_count: number; +} + +export interface BusinessSummaryPayload { + business: Business; + stats: { + invoice_count: number; + outstanding_invoices: number; + total_expenses: number; + total_revenue: number; + total_vat: number; + inventory_items: number; + }; + recent_revenue: Array<{ + business_date: string; + sales_revenue: number; + food_revenue: number; + alcohol_revenue: number; + tips_payable: number; + vat_total: number; + }>; + recent_invoices: Array<{ + id: number; + vendor_name: string; + invoice_number: string; + gross_total: number; + payment_status: string; + due_date: string | null; + }>; +} + +export interface EventItem { + id: number; + legacy_id: number | null; + business_id: number; + business_name: string; + title: string; + description: string; + event_type: string; + start_datetime: string; + end_datetime: string; + all_day: boolean; + location: string; + color: string; + recurrence_type: string; + recurrence_end_date: string | null; + created_by: string | null; + is_active: boolean; +} + +export interface ScheduleRole { + id: number; + legacy_id: number | null; + business_id: number; + business_name: string; + name: string; + color: string; + sort_order: number; +} + +export interface ScheduleTemplate { + id: number; + legacy_id: number | null; + business_id: number; + business_name: string; + name: string; + start_datetime: string; + end_datetime: string; + min_staff: number; + max_staff: number; + color: string; + recurrence_type: string; + recurrence_end_date: string | null; + shift_role_name: string | null; + assignment_count: number; +} + +export interface ScheduleAssignment { + id: number; + legacy_id: number | null; + shift_template_id: number; + shift_name: string; + user_name: string; + occurrence_date: string; + status: string; + start_override: string | null; + end_override: string | null; + notes: string; +} + +export interface ScheduleOverview { + roles: ScheduleRole[]; + templates: ScheduleTemplate[]; + assignments: ScheduleAssignment[]; +} + +export interface SettingsRole { + id: number; + name: string; + description: string; + is_system: boolean; + permission_keys: string[]; + user_count: number; +} + +export interface SettingsPermission { + id: number; + key: string; + label: string; + group: string; +} + +export interface SettingsUser { + id: number; + username: string; + display_name: string; + email: string; + is_active: boolean; + is_superuser: boolean; + role_id: number | null; + role_name: string | null; + last_login: string | null; + last_login_ip: string | null; + business_ids: number[]; +} + +export interface SettingsOverview { + roles: SettingsRole[]; + users: SettingsUser[]; + permissions: SettingsPermission[]; +} + +export interface DeviceItem { + id: number; + ip_address: string; + label: string; + user_agent: string; + registered_at: string | null; + last_seen_at: string | null; + is_active: boolean; + ipv6_prefix: string; + device_token: string; + known_ips: string[]; +} + +export interface DeviceRegistrationTokenItem { + id: number; + token: string; + label: string; + created_at: string; + expires_at: string; + used_at: string | null; + used_by_ip: string | null; + created_by: string | null; +} + +export interface Category { + id: number; + legacy_id: number | null; + name: string; +} + +export interface InvoiceLineItem { + id?: number; + product_id: number; + product_name?: string; + quantity: number; + unit_price: number; + total_price: number; + vat_rate: number; + vat_amount: number; + line_order: number; +} + +export interface Invoice { + id: number; + legacy_id: number | null; + business_id: number | null; + business_name: string | null; + vendor_id: number; + vendor_name: string; + invoice_number: string; + invoice_date: string | null; + order_date: string | null; + payment_status: string; + paid_date: string | null; + due_date: string | null; + subtotal: number; + discount_pct: number; + discount_amount: number; + total_after_discount: number; + vat_amount: number; + gross_total: number; + currency: string; + goods_received_status: string; + goods_date: string | null; + notes: string; + inventory_updated: boolean; + vat_exempt: boolean; + category_ids: number[]; + line_items: InvoiceLineItem[]; +} + +export interface InvoiceCreatePayload { + business_id: number | null; + vendor_id: number; + invoice_number: string; + invoice_date?: string; + order_date?: string; + payment_status: "paid" | "unpaid"; + paid_date?: string; + due_date?: string; + discount_pct?: number; + discount_amount?: number; + goods_received_status: "received" | "not_received"; + goods_date?: string; + currency?: string; + vat_exempt?: boolean; + notes?: string; + category_ids: number[]; + line_items: Array<{ + product_id: number; + quantity: number; + unit_price: number; + }>; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..cd1e494 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts new file mode 100644 index 0000000..cbb4e8f --- /dev/null +++ b/frontend/src/routes/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from "@sveltejs/kit"; + +export const load = () => { + throw redirect(302, "/app/dashboard"); +}; diff --git a/frontend/src/routes/app/+layout.svelte b/frontend/src/routes/app/+layout.svelte new file mode 100644 index 0000000..c8748f7 --- /dev/null +++ b/frontend/src/routes/app/+layout.svelte @@ -0,0 +1,45 @@ + + +{#if loading || !$authReady} +
+
+

Session

+

Checking authentication…

+
+
+{:else if error} +
+
+

{error}

+
+
+{:else} +
+ +
+ +
+
+{/if} diff --git a/frontend/src/routes/app/+layout.ts b/frontend/src/routes/app/+layout.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/frontend/src/routes/app/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/frontend/src/routes/app/business/[id]/+page.svelte b/frontend/src/routes/app/business/[id]/+page.svelte new file mode 100644 index 0000000..79114d2 --- /dev/null +++ b/frontend/src/routes/app/business/[id]/+page.svelte @@ -0,0 +1,84 @@ + + +
+ {#if error} +

{error}

+ {:else if summary} +
+

{summary.business.short_code}

+

{summary.business.name}

+
+ +
+

Revenue

{summary.stats.total_revenue.toFixed(2)} {summary.business.currency}

+

Expenses

{summary.stats.total_expenses.toFixed(2)} {summary.business.currency}

+

Outstanding invoices

{summary.stats.outstanding_invoices}

+

Invoices

{summary.stats.invoice_count}

+

VAT tracked

{summary.stats.total_vat.toFixed(2)} {summary.business.currency}

+

Inventory items

{summary.stats.inventory_items}

+
+ +
+ +
+

Recent revenue

+

Latest imported revenue summary rows for this business.

+
+
+ {#each summary.recent_revenue as row} +
+
+

{row.business_date}

+

Food {row.food_revenue.toFixed(2)} • Alcohol {row.alcohol_revenue.toFixed(2)}

+
+
+

{row.sales_revenue.toFixed(2)}

+

VAT {row.vat_total.toFixed(2)}

+
+
+ {/each} +
+
+ + +
+

Recent invoices

+

Most recent invoice activity linked to this business.

+
+
+ {#each summary.recent_invoices as invoice} +
+
+

{invoice.vendor_name}

+

{invoice.invoice_number || "Draft / unnumbered"}

+
+
+

{invoice.gross_total.toFixed(2)}

+

{invoice.payment_status}

+
+
+ {/each} +
+
+
+ {:else} + Loading business summary… + {/if} +
diff --git a/frontend/src/routes/app/dashboard/+page.svelte b/frontend/src/routes/app/dashboard/+page.svelte new file mode 100644 index 0000000..c5ee577 --- /dev/null +++ b/frontend/src/routes/app/dashboard/+page.svelte @@ -0,0 +1,74 @@ + + +
+
+

Overview

+

Consolidated dashboard

+
+ + {#if error} +

{error}

+ {:else if overview} +
+

Revenue

{overview.total_revenue.toFixed(2)}

+

Expenses

{overview.total_expenses.toFixed(2)}

+

Outstanding invoices

{overview.outstanding_invoices}

+

VAT tracked

{overview.total_vat.toFixed(2)}

+

Vendors

{overview.vendor_count}

+

Unread notifications

{overview.unread_notifications}

+
+ + +
+
+

Business roll-up

+

Per-business revenue, expenses, and invoice pressure.

+
+
+
+ {#each businesses as business} +
+
+
+

{business.business_name}

+

{business.short_code}

+
+ +
+
+

Revenue

{business.total_revenue.toFixed(2)} {business.currency}

+

Expenses

{business.total_expenses.toFixed(2)} {business.currency}

+

Outstanding

{business.outstanding_invoices}

+

Invoices

{business.invoice_count}

+
+
+ {/each} +
+
+ {:else} + Loading dashboard… + {/if} +
diff --git a/frontend/src/routes/app/devices/+page.svelte b/frontend/src/routes/app/devices/+page.svelte new file mode 100644 index 0000000..17af2e6 --- /dev/null +++ b/frontend/src/routes/app/devices/+page.svelte @@ -0,0 +1,209 @@ + + +
+
+

Administration

+

Device access

+
+ + {#if error} +

{error}

+ {/if} + + {#if loading} + Loading devices… + {:else} +
+

Allowed devices

{devices.length}

+

Active devices

{devices.filter((device) => device.is_active).length}

+

Registration tokens

{tokens.length}

+
+ +
+
+ +
+

Whitelisted devices

+

Imported device/IP allowlist plus new Django-managed entries.

+
+
+ {#each devices as device} +
+
+
+

{device.label || device.ip_address}

+

{device.ip_address} {device.ipv6_prefix ? `• ${device.ipv6_prefix}` : ""}

+

+ {device.known_ips.length} known IPs • {device.last_seen_at ? "seen" : "never seen"} +

+
+
+

{device.is_active ? "Active" : "Inactive"}

+ +
+
+
+ {/each} +
+
+ + +
+

Registration tokens

+

Manual tokens for controlled device onboarding.

+
+
+ {#each tokens as token} +
+

{token.label || "Untitled token"}

+

{token.token}

+

+ Expires {new Date(token.expires_at).toLocaleString()} {token.used_at ? `• used ${new Date(token.used_at).toLocaleString()}` : "• unused"} +

+ +
+ {/each} +
+
+
+ +
+ +
+

Add device

+

Create a whitelist entry directly in the new backend.

+
+
+ + + + + + + +
+
+ + +
+

Create registration token

+

Generate a fresh onboarding token with an expiry window.

+
+
+ + + +
+
+
+
+ {/if} +
diff --git a/frontend/src/routes/app/events/+page.svelte b/frontend/src/routes/app/events/+page.svelte new file mode 100644 index 0000000..268ea50 --- /dev/null +++ b/frontend/src/routes/app/events/+page.svelte @@ -0,0 +1,209 @@ + + +
+
+
+

Operations

+

Event planner

+
+
+ + +
+
+ + {#if error} +

{error}

+ {/if} + +
+ +
+

Upcoming events

+

Imported events plus newly created Django-side events.

+
+ {#if loading} +

Loading events…

+ {:else} +
+ {#each events as event} +
+
+
+

{event.title}

+

{event.business_name} • {event.event_type}

+

{new Date(event.start_datetime).toLocaleString()} to {new Date(event.end_datetime).toLocaleString()}

+

{event.location || "No location"} {event.recurrence_type !== "none" ? `• ${event.recurrence_type}` : ""}

+
+
+ + +
+
+
+
+ {/each} +
+ {/if} +
+ + +
+

Create event

+

{editingEventId ? "Update an existing event." : "A lean replacement for the old calendar modal stack."}

+
+
+ + + +
+ + + + + + +
+
+ + +
+ +
+
+
+
diff --git a/frontend/src/routes/app/inventory/+page.svelte b/frontend/src/routes/app/inventory/+page.svelte new file mode 100644 index 0000000..276b546 --- /dev/null +++ b/frontend/src/routes/app/inventory/+page.svelte @@ -0,0 +1,93 @@ + + +
+
+
+

Operations

+

Inventory health

+
+
+ + + +
+
+ + {#if error} +

{error}

+ {/if} + +
+

Tracked products

{rows.length}

+

Zero stock

{rows.filter((row) => row.quantity_on_hand <= 0).length}

+

Low stock

{rows.filter((row) => row.quantity_on_hand > 0 && row.quantity_on_hand < 5).length}

+
+ + + {#if loading} +

Loading inventory…

+ {:else} +
+ {#each rows as row} +
+
+
+

{row.product_name}

+

{row.gtin || "No GTIN"} • {row.category_names.join(", ") || "Uncategorized"}

+

+ VAT {row.vat_rate.toFixed(2)} • Purchase {row.net_purchase_price.toFixed(2)} • Sales {row.display_sales_price.toFixed(2)} +

+
+
+

+ {row.quantity_on_hand.toFixed(3)} +

+

{row.uom}

+
+
+
+ {/each} +
+ {/if} +
+
diff --git a/frontend/src/routes/app/invoices/+page.svelte b/frontend/src/routes/app/invoices/+page.svelte new file mode 100644 index 0000000..88f8b0f --- /dev/null +++ b/frontend/src/routes/app/invoices/+page.svelte @@ -0,0 +1,382 @@ + + +
+
+
+

Operations

+

Invoice tracker

+
+
+ loadData(query)} + placeholder="Search vendor, note, or invoice number" + /> +
+
+ + {#if error} +

{error}

+ {/if} + +
+
+ +
+
+

Recent invoices

+

Consolidated imported data plus new Django-backed invoices.

+
+
{invoices.length} loaded
+
+ + {#if loading} +

Loading invoices…

+ {:else} +
+ {#each invoices as invoice} + + {/each} +
+ {/if} +
+ + {#if selectedInvoice} + +
+
+

{selectedInvoice.vendor_name}

+

{selectedInvoice.invoice_number || "Draft / unnumbered"}

+
+
+

{selectedInvoice.gross_total.toFixed(2)} {selectedInvoice.currency}

+

VAT {selectedInvoice.vat_amount.toFixed(2)}

+
+
+ +
+

Business

{selectedInvoice.business_name ?? "Unassigned"}

+

Status

{selectedInvoice.payment_status}

+

Due

{selectedInvoice.due_date ?? "Not set"}

+
+ +
+ {#each selectedInvoice.line_items as line} +
+
+

{line.product_name}

+

{line.quantity} × {line.unit_price.toFixed(2)}

+
+

{line.total_price.toFixed(2)}

+
+ {/each} +
+
+ {/if} +
+ + +
+
+

{editingInvoiceId ? "Edit invoice" : "New invoice"}

+

This writes to the new Django model layer, not the old FastAPI route logic.

+
+ {#if editingInvoiceId} +
+ + +
+ {/if} +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ {#each categories as category} + + {/each} +
+
+ +
+
+ + +
+ {#each form.line_items as line, index} +
+ + + +
+ {(Number(line.quantity || 0) * Number(line.unit_price || 0)).toFixed(2)} +
+
+ {/each} +
+ +
+ + +
+ +
+
+

Draft subtotal

+

{subtotal.toFixed(2)} {form.currency}

+
+ +
+
+
+
+
diff --git a/frontend/src/routes/app/schedule/+page.svelte b/frontend/src/routes/app/schedule/+page.svelte new file mode 100644 index 0000000..21bf939 --- /dev/null +++ b/frontend/src/routes/app/schedule/+page.svelte @@ -0,0 +1,131 @@ + + +
+
+
+

Operations

+

Schedule overview

+
+
+ + +
+
+ + {#if error} +

{error}

+ {:else if loading || !overview} + Loading schedule… + {:else} +
+

Roles

{overview.roles.length}

+

Templates

{overview.templates.length}

+

Assignments

{overview.assignments.length}

+
+ +
+ +
+

Roles

+

Shift role catalogue by business.

+
+
+ {#each overview.roles as role} +
+
+

{role.name}

+

{role.business_name}

+
+
+
+ {/each} +
+
+ + +
+

Upcoming templates

+

Recurring shift definitions and staffing targets.

+
+
+ {#each overview.templates as template} +
+
+
+

{template.name}

+

{template.business_name} {template.shift_role_name ? `• ${template.shift_role_name}` : ""}

+

{new Date(template.start_datetime).toLocaleString()} to {new Date(template.end_datetime).toLocaleString()}

+

+ Staff {template.min_staff} to {template.max_staff} • {template.recurrence_type} +

+
+
+

{template.assignment_count}

+

assignments

+
+
+
+ {/each} +
+
+ + +
+

Assignments

+

Upcoming user allocations against templates.

+
+
+ {#each overview.assignments as assignment} +
+
+

{assignment.user_name}

+

{assignment.shift_name}

+

{assignment.occurrence_date}

+
+
+

{assignment.status}

+

{assignment.notes || "No note"}

+
+
+ {/each} +
+
+
+ {/if} +
diff --git a/frontend/src/routes/app/settings/+page.svelte b/frontend/src/routes/app/settings/+page.svelte new file mode 100644 index 0000000..2794c50 --- /dev/null +++ b/frontend/src/routes/app/settings/+page.svelte @@ -0,0 +1,253 @@ + + +
+
+

Administration

+

Settings and access

+
+ + {#if error} +

{error}

+ {/if} + + {#if loading || !overview} + Loading settings… + {:else} +
+

Users

{overview.users.length}

+

Roles

{overview.roles.length}

+

Permissions

{overview.permissions.length}

+
+ +
+
+ +
+

Users

+

Current account state imported into Django auth.

+
+
+ {#each overview.users as user} + + {/each} +
+
+ + +
+

Roles

+

Domain roles and assigned permission keys.

+
+
+ {#each overview.roles as role} +
+
+
+

{role.name}

+

{role.description || "No description"}

+

+ {role.user_count} users • {role.permission_keys.length} permissions +

+
+

{role.is_system ? "System" : "Custom"}

+
+
+ {/each} +
+
+
+ + +
+
+

{editingUserId ? "Edit user" : "Create user"}

+

Minimal replacement for the old settings page bulk CRUD.

+
+ {#if editingUserId} +
+ + +
+ {/if} +
+
+
+ + + + +
+ + +
+

Business access

+
+ {#each businesses as business} + + {/each} +
+
+ + + + + + +
+
+
+ {/if} +
diff --git a/frontend/src/routes/app/vendors/+page.svelte b/frontend/src/routes/app/vendors/+page.svelte new file mode 100644 index 0000000..7219d28 --- /dev/null +++ b/frontend/src/routes/app/vendors/+page.svelte @@ -0,0 +1,230 @@ + + +
+
+
+

Operations

+

Vendor management

+
+
+ + +
+
+ + {#if error} +

{error}

+ {/if} + +
+ +
+ + +
+ + {#if loading} +

Loading vendors…

+ {:else} +
+ {#each vendors as vendor} + + {/each} +
+ {/if} +
+ + +
+
+

{editingVendorId ? "Edit vendor" : "Create vendor"}

+

Manage linkage to businesses and invoice categories.

+
+ {#if editingVendorId} + + {/if} +
+ +
+
+ + + + + +
+ + + +
+

Businesses

+
+ {#each businesses as business} + + {/each} +
+
+ +
+

Categories

+
+ {#each categories as category} + + {/each} +
+
+ + + + +
+
+
+
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..045f368 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,84 @@ + + +{#if checkingSession} +
+
+

Session

+

Checking authentication…

+
+
+{:else} +
+
+
+

Django + Svelte port

+

+ Replace the brittle FastAPI tangle with a session-based operations platform. +

+

+ The new frontend is organized around stable domains, smaller route modules, and a backend that uses Django ORM and admin instead of runtime schema patching. +

+
+ + +
+
+ + +
+
+ + +
+ {#if error} +

{error}

+ {/if} + +
+
+
+
+{/if} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..b42b17b --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,10 @@ +import adapter from '@sveltejs/adapter-auto'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..10a03cd --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..70f41f8 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +});