Initial commit
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -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/
|
||||||
17
README.md
Normal file
17
README.md
Normal file
@@ -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.
|
||||||
27
backend/README.md
Normal file
27
backend/README.md
Normal file
@@ -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.
|
||||||
1
backend/apps/__init__.py
Normal file
1
backend/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/apps/accounts/__init__.py
Normal file
1
backend/apps/accounts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
30
backend/apps/accounts/admin.py
Normal file
30
backend/apps/accounts/admin.py
Normal file
@@ -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)
|
||||||
7
backend/apps/accounts/apps.py
Normal file
7
backend/apps/accounts/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.accounts"
|
||||||
|
label = "accounts"
|
||||||
1
backend/apps/accounts/management/__init__.py
Normal file
1
backend/apps/accounts/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/apps/accounts/management/commands/__init__.py
Normal file
1
backend/apps/accounts/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
541
backend/apps/accounts/management/commands/import_legacy_data.py
Normal file
541
backend/apps/accounts/management/commands/import_legacy_data.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
131
backend/apps/accounts/migrations/0001_initial.py
Normal file
131
backend/apps/accounts/migrations/0001_initial.py
Normal file
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/apps/accounts/migrations/__init__.py
Normal file
1
backend/apps/accounts/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
86
backend/apps/accounts/models.py
Normal file
86
backend/apps/accounts/models.py
Normal file
@@ -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)
|
||||||
1
backend/apps/api/__init__.py
Normal file
1
backend/apps/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
7
backend/apps/api/apps.py
Normal file
7
backend/apps/api/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.api"
|
||||||
|
label = "api"
|
||||||
1
backend/apps/api/migrations/__init__.py
Normal file
1
backend/apps/api/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
32
backend/apps/api/urls.py
Normal file
32
backend/apps/api/urls.py
Normal file
@@ -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/<int:business_id>/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/<int:vendor_id>/", views.vendor_detail_view),
|
||||||
|
path("invoices/", views.invoices_view),
|
||||||
|
path("invoices/<int:invoice_id>/", views.invoice_detail_view),
|
||||||
|
path("inventory/", views.inventory_view),
|
||||||
|
path("events/", views.events_view),
|
||||||
|
path("events/<int:event_id>/", 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/<int:user_id>/", views.user_detail_view),
|
||||||
|
path("devices/", views.devices_view),
|
||||||
|
path("devices/<int:device_id>/", views.device_detail_view),
|
||||||
|
path("devices/tokens/", views.device_tokens_view),
|
||||||
|
path("devices/tokens/<int:token_id>/", views.device_token_detail_view),
|
||||||
|
path("notifications/", views.notifications_view),
|
||||||
|
]
|
||||||
1081
backend/apps/api/views.py
Normal file
1081
backend/apps/api/views.py
Normal file
File diff suppressed because it is too large
Load Diff
1
backend/apps/core/__init__.py
Normal file
1
backend/apps/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
19
backend/apps/core/admin.py
Normal file
19
backend/apps/core/admin.py
Normal file
@@ -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)
|
||||||
7
backend/apps/core/apps.py
Normal file
7
backend/apps/core/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.core"
|
||||||
|
label = "core"
|
||||||
124
backend/apps/core/migrations/0001_initial.py
Normal file
124
backend/apps/core/migrations/0001_initial.py
Normal file
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/apps/core/migrations/__init__.py
Normal file
1
backend/apps/core/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
91
backend/apps/core/models.py
Normal file
91
backend/apps/core/models.py
Normal file
@@ -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")
|
||||||
1
backend/apps/notifications/__init__.py
Normal file
1
backend/apps/notifications/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
backend/apps/notifications/admin.py
Normal file
5
backend/apps/notifications/admin.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Notification
|
||||||
|
|
||||||
|
admin.site.register(Notification)
|
||||||
7
backend/apps/notifications/apps.py
Normal file
7
backend/apps/notifications/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.notifications"
|
||||||
|
label = "notifications"
|
||||||
30
backend/apps/notifications/migrations/0001_initial.py
Normal file
30
backend/apps/notifications/migrations/0001_initial.py
Normal file
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/apps/notifications/migrations/__init__.py
Normal file
1
backend/apps/notifications/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
14
backend/apps/notifications/models.py
Normal file
14
backend/apps/notifications/models.py
Normal file
@@ -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)
|
||||||
1
backend/apps/operations/__init__.py
Normal file
1
backend/apps/operations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
31
backend/apps/operations/admin.py
Normal file
31
backend/apps/operations/admin.py
Normal file
@@ -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)
|
||||||
7
backend/apps/operations/apps.py
Normal file
7
backend/apps/operations/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class OperationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.operations"
|
||||||
|
label = "operations"
|
||||||
274
backend/apps/operations/migrations/0001_initial.py
Normal file
274
backend/apps/operations/migrations/0001_initial.py
Normal file
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/apps/operations/migrations/__init__.py
Normal file
1
backend/apps/operations/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
169
backend/apps/operations/models.py
Normal file
169
backend/apps/operations/models.py
Normal file
@@ -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")
|
||||||
187
backend/apps/operations/services.py
Normal file
187
backend/apps/operations/services.py
Normal file
@@ -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)
|
||||||
1
backend/apps/reporting/__init__.py
Normal file
1
backend/apps/reporting/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
backend/apps/reporting/admin.py
Normal file
5
backend/apps/reporting/admin.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import DailyRevenueSummary
|
||||||
|
|
||||||
|
admin.site.register(DailyRevenueSummary)
|
||||||
7
backend/apps/reporting/apps.py
Normal file
7
backend/apps/reporting/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.reporting"
|
||||||
|
label = "reporting"
|
||||||
38
backend/apps/reporting/migrations/0001_initial.py
Normal file
38
backend/apps/reporting/migrations/0001_initial.py
Normal file
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/apps/reporting/migrations/__init__.py
Normal file
1
backend/apps/reporting/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
20
backend/apps/reporting/models.py
Normal file
20
backend/apps/reporting/models.py
Normal file
@@ -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")
|
||||||
1
backend/config/__init__.py
Normal file
1
backend/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
6
backend/config/asgi.py
Normal file
6
backend/config/asgi.py
Normal file
@@ -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()
|
||||||
114
backend/config/settings.py
Normal file
114
backend/config/settings.py
Normal file
@@ -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"))
|
||||||
7
backend/config/urls.py
Normal file
7
backend/config/urls.py
Normal file
@@ -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")),
|
||||||
|
]
|
||||||
6
backend/config/wsgi.py
Normal file
6
backend/config/wsgi.py
Normal file
@@ -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()
|
||||||
14
backend/manage.py
Normal file
14
backend/manage.py
Normal file
@@ -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()
|
||||||
23
backend/pyproject.toml
Normal file
23
backend/pyproject.toml
Normal file
@@ -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"
|
||||||
41
docs/port-plan.md
Normal file
41
docs/port-plan.md
Normal file
@@ -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
|
||||||
1046
docs/session-handoff-2026-04-01.md
Normal file
1046
docs/session-handoff-2026-04-01.md
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/components.json
Normal file
15
frontend/components.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
2568
frontend/package-lock.json
generated
Normal file
2568
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
frontend/src/app.css
Normal file
38
frontend/src/app.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
11
frontend/src/app.html
Normal file
11
frontend/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
197
frontend/src/lib/api/client.ts
Normal file
197
frontend/src/lib/api/client.ts
Normal file
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<import("$lib/types/domain").BusinessSummaryPayload>(`/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<import("$lib/types/domain").DashboardOverview>("/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<import("$lib/types/domain").Invoice>("/invoices/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
|
invoice: (invoiceId: number) => request<import("$lib/types/domain").Invoice>(`/invoices/${invoiceId}/`),
|
||||||
|
updateInvoice: (invoiceId: number, payload: import("$lib/types/domain").InvoiceCreatePayload) =>
|
||||||
|
request<import("$lib/types/domain").Invoice>(`/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<import("$lib/types/domain").Vendor> & { name: string }) =>
|
||||||
|
request<import("$lib/types/domain").Vendor>("/vendors/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
|
updateVendor: (vendorId: number, payload: Partial<import("$lib/types/domain").Vendor>) =>
|
||||||
|
request<import("$lib/types/domain").Vendor>(`/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<import("$lib/types/domain").EventItem>("/events/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
|
updateEvent: (eventId: number, payload: Partial<import("$lib/types/domain").EventItem>) =>
|
||||||
|
request<import("$lib/types/domain").EventItem>(`/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<import("$lib/types/domain").ScheduleOverview>(`/schedule/overview/${suffix}`);
|
||||||
|
},
|
||||||
|
settingsOverview: () => request<import("$lib/types/domain").SettingsOverview>("/settings/overview/"),
|
||||||
|
createUser: (payload: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
display_name?: string;
|
||||||
|
email?: string;
|
||||||
|
role_id?: number | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
business_ids?: number[];
|
||||||
|
}) =>
|
||||||
|
request<import("$lib/types/domain").SettingsUser>("/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<import("$lib/types/domain").SettingsUser>(`/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<import("$lib/types/domain").DeviceItem>("/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<import("$lib/types/domain").DeviceRegistrationTokenItem>("/devices/tokens/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
|
deleteDeviceToken: (tokenId: number) =>
|
||||||
|
request<{ detail: string }>(`/devices/tokens/${tokenId}/`, { method: "DELETE" }),
|
||||||
|
notifications: () => request<{ results: unknown[] }>("/notifications/")
|
||||||
|
};
|
||||||
52
frontend/src/lib/components/app-shell/sidebar.svelte
Normal file
52
frontend/src/lib/components/app-shell/sidebar.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import { authUser, clearAuth, hasPermission } from "$lib/stores/auth";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ href: "/app/dashboard", label: "Dashboard", permission: "dashboard.view" },
|
||||||
|
{ href: "/app/invoices", label: "Invoices", permission: "invoices.view" },
|
||||||
|
{ href: "/app/vendors", label: "Vendors", permission: "vendors.view" },
|
||||||
|
{ href: "/app/inventory", label: "Inventory", permission: "inventory.view" },
|
||||||
|
{ href: "/app/events", label: "Events", permission: "events.view" },
|
||||||
|
{ href: "/app/schedule", label: "Schedule", permission: "shifts.view" },
|
||||||
|
{ href: "/app/settings", label: "Settings", permission: "users.manage" },
|
||||||
|
{ href: "/app/devices", label: "Devices", permission: "users.manage" }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
try {
|
||||||
|
await api.logout();
|
||||||
|
} catch {
|
||||||
|
// Ignore logout transport failures and clear local session state anyway.
|
||||||
|
}
|
||||||
|
clearAuth();
|
||||||
|
goto("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
$: visibleItems = items.filter((item) => !$authUser || hasPermission(item.permission));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="panel flex w-full max-w-64 flex-col gap-3 p-4">
|
||||||
|
<div class="border-b border-[var(--border)] pb-4">
|
||||||
|
<p class="text-xs uppercase tracking-[0.25em] text-[var(--muted-foreground)]">Central Hub</p>
|
||||||
|
<h1 class="mt-2 text-xl font-semibold">Hospitality Ops</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="flex flex-col gap-2">
|
||||||
|
{#each visibleItems as item}
|
||||||
|
<a class="rounded-xl px-3 py-2 text-sm text-[var(--foreground)] transition hover:bg-black/5" href={item.href}>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
<div class="mt-auto border-t border-[var(--border)] pt-4">
|
||||||
|
<p class="text-sm font-medium">{$authUser?.display_name || $authUser?.username || "Unknown user"}</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||||
|
{$authUser?.role || "No role"}
|
||||||
|
</p>
|
||||||
|
<div class="mt-3">
|
||||||
|
<Button type="button" variant="ghost" on:click={signOut}>Sign out</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
42
frontend/src/lib/components/ui/button.svelte
Normal file
42
frontend/src/lib/components/ui/button.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
|
export let href: string | undefined = undefined;
|
||||||
|
export let type: "button" | "submit" | "reset" = "button";
|
||||||
|
export let variant: "primary" | "secondary" | "ghost" | "destructive" = "primary";
|
||||||
|
export let disabled = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ click: MouseEvent }>();
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: "bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-95",
|
||||||
|
secondary: "bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-95",
|
||||||
|
ghost: "bg-transparent text-[var(--foreground)] hover:bg-black/5",
|
||||||
|
destructive: "bg-red-700 text-white hover:bg-red-800"
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
dispatch("click", event);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
class={`inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-medium transition ${variants[variant]}`}
|
||||||
|
{href}
|
||||||
|
{...$$restProps}
|
||||||
|
on:click={handleClick}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class={`inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-medium transition disabled:opacity-50 ${variants[variant]}`}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...$$restProps}
|
||||||
|
on:click={handleClick}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
3
frontend/src/lib/components/ui/card.svelte
Normal file
3
frontend/src/lib/components/ui/card.svelte
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="panel p-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
51
frontend/src/lib/stores/auth.ts
Normal file
51
frontend/src/lib/stores/auth.ts
Normal file
@@ -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<AuthUser | null>(null);
|
||||||
|
export const authReady = writable(false);
|
||||||
|
|
||||||
|
let bootstrapPromise: Promise<AuthUser | null> | 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<AuthUser | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
306
frontend/src/lib/types/domain.ts
Normal file
306
frontend/src/lib/types/domain.ts
Normal file
@@ -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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
5
frontend/src/routes/+layout.svelte
Normal file
5
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import "../app.css";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot />
|
||||||
5
frontend/src/routes/+page.ts
Normal file
5
frontend/src/routes/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = () => {
|
||||||
|
throw redirect(302, "/app/dashboard");
|
||||||
|
};
|
||||||
45
frontend/src/routes/app/+layout.svelte
Normal file
45
frontend/src/routes/app/+layout.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import Sidebar from "$lib/components/app-shell/sidebar.svelte";
|
||||||
|
import { authReady, bootstrapAuth } from "$lib/stores/auth";
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const user = await bootstrapAuth();
|
||||||
|
if (!user) {
|
||||||
|
goto("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to initialize session";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading || !$authReady}
|
||||||
|
<div class="mx-auto flex min-h-screen max-w-7xl items-center justify-center px-6 py-10">
|
||||||
|
<div class="panel p-6 text-center">
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Session</p>
|
||||||
|
<p class="mt-2 text-lg font-medium">Checking authentication…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="mx-auto flex min-h-screen max-w-7xl items-center justify-center px-6 py-10">
|
||||||
|
<div class="panel p-6 text-center">
|
||||||
|
<p class="text-sm text-red-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mx-auto flex min-h-screen max-w-7xl gap-6 px-4 py-4 lg:px-6">
|
||||||
|
<Sidebar />
|
||||||
|
<main class="min-w-0 flex-1">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
1
frontend/src/routes/app/+layout.ts
Normal file
1
frontend/src/routes/app/+layout.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
84
frontend/src/routes/app/business/[id]/+page.svelte
Normal file
84
frontend/src/routes/app/business/[id]/+page.svelte
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import type { BusinessSummaryPayload } from "$lib/types/domain";
|
||||||
|
|
||||||
|
let summary: BusinessSummaryPayload | null = null;
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
summary = await api.businessSummary(Number(page.params.id));
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load business summary";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{#if error}
|
||||||
|
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||||
|
{:else if summary}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">{summary.business.short_code}</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold">{summary.business.name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_revenue.toFixed(2)} {summary.business.currency}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_expenses.toFixed(2)} {summary.business.currency}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.outstanding_invoices}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.invoice_count}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_vat.toFixed(2)} {summary.business.currency}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Inventory items</p><p class="mt-2 text-3xl font-semibold">{summary.stats.inventory_items}</p></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Recent revenue</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Latest imported revenue summary rows for this business.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each summary.recent_revenue as row}
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{row.business_date}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Food {row.food_revenue.toFixed(2)} • Alcohol {row.alcohol_revenue.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-semibold">{row.sales_revenue.toFixed(2)}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">VAT {row.vat_total.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Recent invoices</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Most recent invoice activity linked to this business.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each summary.recent_invoices as invoice}
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{invoice.vendor_name}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{invoice.invoice_number || "Draft / unnumbered"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-semibold">{invoice.gross_total.toFixed(2)}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{invoice.payment_status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Card>Loading business summary…</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
74
frontend/src/routes/app/dashboard/+page.svelte
Normal file
74
frontend/src/routes/app/dashboard/+page.svelte
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import type { DashboardBusinessSummary, DashboardOverview } from "$lib/types/domain";
|
||||||
|
|
||||||
|
let overview: DashboardOverview | null = null;
|
||||||
|
let businesses: DashboardBusinessSummary[] = [];
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [overviewData, businessData] = await Promise.all([
|
||||||
|
api.dashboardOverview(),
|
||||||
|
api.dashboardBusinessSummary()
|
||||||
|
]);
|
||||||
|
overview = overviewData;
|
||||||
|
businesses = businessData.results;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load dashboard";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Overview</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold">Consolidated dashboard</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||||
|
{:else if overview}
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{overview.total_revenue.toFixed(2)}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{overview.total_expenses.toFixed(2)}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{overview.outstanding_invoices}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{overview.total_vat.toFixed(2)}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Vendors</p><p class="mt-2 text-3xl font-semibold">{overview.vendor_count}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Unread notifications</p><p class="mt-2 text-3xl font-semibold">{overview.unread_notifications}</p></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold">Business roll-up</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Per-business revenue, expenses, and invoice pressure.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each businesses as business}
|
||||||
|
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{business.business_name}</p>
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">{business.short_code}</p>
|
||||||
|
</div>
|
||||||
|
<Button href={`/app/business/${business.business_id}`} variant="secondary">Open</Button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-4">
|
||||||
|
<div><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Revenue</p><p class="mt-1 font-semibold">{business.total_revenue.toFixed(2)} {business.currency}</p></div>
|
||||||
|
<div><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Expenses</p><p class="mt-1 font-semibold">{business.total_expenses.toFixed(2)} {business.currency}</p></div>
|
||||||
|
<div><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Outstanding</p><p class="mt-1 font-semibold">{business.outstanding_invoices}</p></div>
|
||||||
|
<div><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Invoices</p><p class="mt-1 font-semibold">{business.invoice_count}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<Card>Loading dashboard…</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
209
frontend/src/routes/app/devices/+page.svelte
Normal file
209
frontend/src/routes/app/devices/+page.svelte
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import type { DeviceItem, DeviceRegistrationTokenItem } from "$lib/types/domain";
|
||||||
|
|
||||||
|
let devices: DeviceItem[] = [];
|
||||||
|
let tokens: DeviceRegistrationTokenItem[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let savingDevice = false;
|
||||||
|
let savingToken = false;
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
let deviceForm = {
|
||||||
|
ip_address: "",
|
||||||
|
label: "",
|
||||||
|
user_agent: "",
|
||||||
|
ipv6_prefix: "",
|
||||||
|
known_ips: "",
|
||||||
|
is_active: true
|
||||||
|
};
|
||||||
|
|
||||||
|
let tokenForm = {
|
||||||
|
label: "",
|
||||||
|
expires_in_days: 7
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [deviceData, tokenData] = await Promise.all([api.devices(), api.deviceTokens()]);
|
||||||
|
devices = deviceData.results;
|
||||||
|
tokens = tokenData.results;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load devices";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadData);
|
||||||
|
|
||||||
|
async function saveDevice() {
|
||||||
|
savingDevice = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await api.createDevice({
|
||||||
|
ip_address: deviceForm.ip_address,
|
||||||
|
label: deviceForm.label,
|
||||||
|
user_agent: deviceForm.user_agent,
|
||||||
|
ipv6_prefix: deviceForm.ipv6_prefix,
|
||||||
|
known_ips: deviceForm.known_ips.split(",").map((value) => value.trim()).filter(Boolean),
|
||||||
|
is_active: deviceForm.is_active
|
||||||
|
});
|
||||||
|
deviceForm = {
|
||||||
|
ip_address: "",
|
||||||
|
label: "",
|
||||||
|
user_agent: "",
|
||||||
|
ipv6_prefix: "",
|
||||||
|
known_ips: "",
|
||||||
|
is_active: true
|
||||||
|
};
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to create device";
|
||||||
|
} finally {
|
||||||
|
savingDevice = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveToken() {
|
||||||
|
savingToken = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await api.createDeviceToken(tokenForm);
|
||||||
|
tokenForm = { label: "", expires_in_days: 7 };
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to create token";
|
||||||
|
} finally {
|
||||||
|
savingToken = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDevice(deviceId: number) {
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await api.deleteDevice(deviceId);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to delete device";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeToken(tokenId: number) {
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await api.deleteDeviceToken(tokenId);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to delete token";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Administration</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold">Device access</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<Card>Loading devices…</Card>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Allowed devices</p><p class="mt-2 text-3xl font-semibold">{devices.length}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Active devices</p><p class="mt-2 text-3xl font-semibold">{devices.filter((device) => device.is_active).length}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Registration tokens</p><p class="mt-2 text-3xl font-semibold">{tokens.length}</p></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Whitelisted devices</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Imported device/IP allowlist plus new Django-managed entries.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each devices as device}
|
||||||
|
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{device.label || device.ip_address}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{device.ip_address} {device.ipv6_prefix ? `• ${device.ipv6_prefix}` : ""}</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||||
|
{device.known_ips.length} known IPs • {device.last_seen_at ? "seen" : "never seen"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class={`text-sm ${device.is_active ? "text-green-700" : "text-red-700"}`}>{device.is_active ? "Active" : "Inactive"}</p>
|
||||||
|
<button class="mt-2 text-sm text-red-700 hover:opacity-80" on:click={() => removeDevice(device.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Registration tokens</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Manual tokens for controlled device onboarding.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each tokens as token}
|
||||||
|
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
|
||||||
|
<p class="font-medium">{token.label || "Untitled token"}</p>
|
||||||
|
<p class="mt-1 break-all text-sm text-[var(--muted-foreground)]">{token.token}</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||||
|
Expires {new Date(token.expires_at).toLocaleString()} {token.used_at ? `• used ${new Date(token.used_at).toLocaleString()}` : "• unused"}
|
||||||
|
</p>
|
||||||
|
<button class="mt-3 text-sm text-red-700 hover:opacity-80" on:click={() => removeToken(token.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Add device</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Create a whitelist entry directly in the new backend.</p>
|
||||||
|
</div>
|
||||||
|
<form class="space-y-4" on:submit|preventDefault={saveDevice}>
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.ip_address} placeholder="IP address" required />
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.label} placeholder="Label" />
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.user_agent} placeholder="User agent" />
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.ipv6_prefix} placeholder="IPv6 prefix" />
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.known_ips} placeholder="Known IPs, comma separated" />
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" bind:checked={deviceForm.is_active} />
|
||||||
|
Active device
|
||||||
|
</label>
|
||||||
|
<Button type="submit" disabled={savingDevice}>{savingDevice ? "Saving…" : "Add device"}</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Create registration token</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Generate a fresh onboarding token with an expiry window.</p>
|
||||||
|
</div>
|
||||||
|
<form class="space-y-4" on:submit|preventDefault={saveToken}>
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={tokenForm.label} placeholder="Label" />
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="number" min="1" bind:value={tokenForm.expires_in_days} />
|
||||||
|
<Button type="submit" disabled={savingToken}>{savingToken ? "Saving…" : "Create token"}</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
209
frontend/src/routes/app/events/+page.svelte
Normal file
209
frontend/src/routes/app/events/+page.svelte
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import type { Business, EventItem } from "$lib/types/domain";
|
||||||
|
|
||||||
|
let events: EventItem[] = [];
|
||||||
|
let businesses: Business[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let saving = false;
|
||||||
|
let error = "";
|
||||||
|
let selectedBusiness = "";
|
||||||
|
let editingEventId: number | null = null;
|
||||||
|
|
||||||
|
let form = {
|
||||||
|
business_id: 0,
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
event_type: "other",
|
||||||
|
start_datetime: "",
|
||||||
|
end_datetime: "",
|
||||||
|
all_day: false,
|
||||||
|
location: "",
|
||||||
|
color: "#ba6c46",
|
||||||
|
recurrence_type: "none",
|
||||||
|
recurrence_end_date: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [eventData, businessData] = await Promise.all([
|
||||||
|
api.events({ business_id: selectedBusiness ? Number(selectedBusiness) : undefined }),
|
||||||
|
api.businesses()
|
||||||
|
]);
|
||||||
|
events = eventData.results;
|
||||||
|
businesses = businessData.results;
|
||||||
|
if (!form.business_id && businesses[0]) form.business_id = businesses[0].id;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load events";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadData);
|
||||||
|
|
||||||
|
async function saveEvent() {
|
||||||
|
saving = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
business_id: Number(form.business_id),
|
||||||
|
recurrence_end_date: form.recurrence_end_date || null
|
||||||
|
};
|
||||||
|
if (editingEventId) {
|
||||||
|
await api.updateEvent(editingEventId, payload);
|
||||||
|
} else {
|
||||||
|
await api.createEvent(payload);
|
||||||
|
}
|
||||||
|
form = {
|
||||||
|
business_id: form.business_id,
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
event_type: "other",
|
||||||
|
start_datetime: "",
|
||||||
|
end_datetime: "",
|
||||||
|
all_day: false,
|
||||||
|
location: "",
|
||||||
|
color: "#ba6c46",
|
||||||
|
recurrence_type: "none",
|
||||||
|
recurrence_end_date: ""
|
||||||
|
};
|
||||||
|
editingEventId = null;
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to save event";
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editEvent(event: EventItem) {
|
||||||
|
editingEventId = event.id;
|
||||||
|
form = {
|
||||||
|
business_id: event.business_id,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
event_type: event.event_type,
|
||||||
|
start_datetime: event.start_datetime.slice(0, 16),
|
||||||
|
end_datetime: event.end_datetime.slice(0, 16),
|
||||||
|
all_day: event.all_day,
|
||||||
|
location: event.location,
|
||||||
|
color: event.color || "#ba6c46",
|
||||||
|
recurrence_type: event.recurrence_type,
|
||||||
|
recurrence_end_date: event.recurrence_end_date || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEvent(eventId: number) {
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await api.deleteEvent(eventId);
|
||||||
|
if (editingEventId === eventId) editingEventId = null;
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to delete event";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold">Event planner</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedBusiness}>
|
||||||
|
<option value="">All businesses</option>
|
||||||
|
{#each businesses as business}
|
||||||
|
<option value={business.id}>{business.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<Button type="button" on:click={loadData}>Filter</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Upcoming events</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Imported events plus newly created Django-side events.</p>
|
||||||
|
</div>
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading events…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each events as event}
|
||||||
|
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{event.title}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{event.business_name} • {event.event_type}</p>
|
||||||
|
<p class="mt-1 text-sm">{new Date(event.start_datetime).toLocaleString()} to {new Date(event.end_datetime).toLocaleString()}</p>
|
||||||
|
<p class="mt-1 text-sm text-[var(--muted-foreground)]">{event.location || "No location"} {event.recurrence_type !== "none" ? `• ${event.recurrence_type}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]" on:click={() => editEvent(event)}>Edit</button>
|
||||||
|
<button class="text-sm text-red-700 hover:opacity-80" on:click={() => removeEvent(event.id)}>Delete</button>
|
||||||
|
<div class="h-4 w-4 rounded-full border border-black/10" style={`background:${event.color || "#ba6c46"}`}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Create event</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{editingEventId ? "Update an existing event." : "A lean replacement for the old calendar modal stack."}</p>
|
||||||
|
</div>
|
||||||
|
<form class="space-y-4" on:submit|preventDefault={saveEvent}>
|
||||||
|
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.business_id}>
|
||||||
|
{#each businesses as business}
|
||||||
|
<option value={business.id}>{business.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.title} placeholder="Event title" required />
|
||||||
|
<textarea class="min-h-24 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.description} placeholder="Description"></textarea>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.event_type}>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
<option value="service">Service</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
<option value="maintenance">Maintenance</option>
|
||||||
|
</select>
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.location} placeholder="Location" />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="datetime-local" bind:value={form.start_datetime} required />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="datetime-local" bind:value={form.end_datetime} required />
|
||||||
|
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.recurrence_type}>
|
||||||
|
<option value="none">No recurrence</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</select>
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="date" bind:value={form.recurrence_end_date} />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input class="h-10 w-16 rounded-xl border border-[var(--input)] bg-white/70 px-2 py-1" type="color" bind:value={form.color} />
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" bind:checked={form.all_day} />
|
||||||
|
All day
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? "Saving…" : editingEventId ? "Update event" : "Create event"}</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
93
frontend/src/routes/app/inventory/+page.svelte
Normal file
93
frontend/src/routes/app/inventory/+page.svelte
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import type { Category, InventoryItem } from "$lib/types/domain";
|
||||||
|
|
||||||
|
let rows: InventoryItem[] = [];
|
||||||
|
let categories: Category[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let error = "";
|
||||||
|
let query = "";
|
||||||
|
let selectedCategory = "";
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [inventoryData, categoryData] = await Promise.all([
|
||||||
|
api.inventory({
|
||||||
|
q: query || undefined,
|
||||||
|
category_id: selectedCategory ? Number(selectedCategory) : undefined
|
||||||
|
}),
|
||||||
|
api.categories()
|
||||||
|
]);
|
||||||
|
rows = inventoryData.results;
|
||||||
|
categories = categoryData.results;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load inventory";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadData);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold">Inventory health</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={query} placeholder="Search product or GTIN" />
|
||||||
|
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedCategory}>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{#each categories as category}
|
||||||
|
<option value={category.id}>{category.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<Button type="button" on:click={loadData}>Filter</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Tracked products</p><p class="mt-2 text-3xl font-semibold">{rows.length}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Zero stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand <= 0).length}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Low stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand > 0 && row.quantity_on_hand < 5).length}</p></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading inventory…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each rows as row}
|
||||||
|
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{row.product_name}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{row.gtin || "No GTIN"} • {row.category_names.join(", ") || "Uncategorized"}</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||||
|
VAT {row.vat_rate.toFixed(2)} • Purchase {row.net_purchase_price.toFixed(2)} • Sales {row.display_sales_price.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class={`text-2xl font-semibold ${row.quantity_on_hand <= 0 ? "text-red-700" : row.quantity_on_hand < 5 ? "text-amber-700" : ""}`}>
|
||||||
|
{row.quantity_on_hand.toFixed(3)}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{row.uom}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
382
frontend/src/routes/app/invoices/+page.svelte
Normal file
382
frontend/src/routes/app/invoices/+page.svelte
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import type { Business, Category, Invoice, Product, Vendor } from "$lib/types/domain";
|
||||||
|
|
||||||
|
let invoices: Invoice[] = [];
|
||||||
|
let vendors: Vendor[] = [];
|
||||||
|
let products: Product[] = [];
|
||||||
|
let businesses: Business[] = [];
|
||||||
|
let categories: Category[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let error = "";
|
||||||
|
let saving = false;
|
||||||
|
let query = "";
|
||||||
|
let selectedInvoice: Invoice | null = null;
|
||||||
|
let editingInvoiceId: number | null = null;
|
||||||
|
|
||||||
|
let form = {
|
||||||
|
business_id: null as number | null,
|
||||||
|
vendor_id: 0,
|
||||||
|
invoice_number: "",
|
||||||
|
invoice_date: "",
|
||||||
|
due_date: "",
|
||||||
|
payment_status: "unpaid" as "paid" | "unpaid",
|
||||||
|
goods_received_status: "not_received" as "received" | "not_received",
|
||||||
|
discount_pct: 0,
|
||||||
|
currency: "CZK",
|
||||||
|
notes: "",
|
||||||
|
category_ids: [] as number[],
|
||||||
|
line_items: [{ product_id: 0, quantity: 1, unit_price: 0 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadData(search = "") {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [invoiceData, vendorData, productData, businessData, categoryData] = await Promise.all([
|
||||||
|
api.invoices(search ? { q: search } : undefined),
|
||||||
|
api.vendors(),
|
||||||
|
api.products(),
|
||||||
|
api.businesses(),
|
||||||
|
api.categories()
|
||||||
|
]);
|
||||||
|
invoices = invoiceData.results;
|
||||||
|
vendors = vendorData.results;
|
||||||
|
products = productData.results;
|
||||||
|
businesses = businessData.results;
|
||||||
|
categories = categoryData.results;
|
||||||
|
selectedInvoice = invoices[0] ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load invoices";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
form.line_items = [...form.line_items, { product_id: 0, quantity: 1, unit_price: 0 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startInvoiceDraft(invoice?: Invoice | null) {
|
||||||
|
selectedInvoice = invoice ?? null;
|
||||||
|
editingInvoiceId = invoice?.id ?? null;
|
||||||
|
if (!invoice) {
|
||||||
|
form = {
|
||||||
|
business_id: null,
|
||||||
|
vendor_id: 0,
|
||||||
|
invoice_number: "",
|
||||||
|
invoice_date: "",
|
||||||
|
due_date: "",
|
||||||
|
payment_status: "unpaid",
|
||||||
|
goods_received_status: "not_received",
|
||||||
|
discount_pct: 0,
|
||||||
|
currency: "CZK",
|
||||||
|
notes: "",
|
||||||
|
category_ids: [],
|
||||||
|
line_items: [{ product_id: 0, quantity: 1, unit_price: 0 }]
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form = {
|
||||||
|
business_id: invoice.business_id,
|
||||||
|
vendor_id: invoice.vendor_id,
|
||||||
|
invoice_number: invoice.invoice_number,
|
||||||
|
invoice_date: invoice.invoice_date ?? "",
|
||||||
|
due_date: invoice.due_date ?? "",
|
||||||
|
payment_status: invoice.payment_status as "paid" | "unpaid",
|
||||||
|
goods_received_status: invoice.goods_received_status as "received" | "not_received",
|
||||||
|
discount_pct: invoice.discount_pct,
|
||||||
|
currency: invoice.currency,
|
||||||
|
notes: invoice.notes,
|
||||||
|
category_ids: [...invoice.category_ids],
|
||||||
|
line_items:
|
||||||
|
invoice.line_items.map((line) => ({
|
||||||
|
product_id: line.product_id,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unit_price: line.unit_price
|
||||||
|
})) || [{ product_id: 0, quantity: 1, unit_price: 0 }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCategory(categoryId: number) {
|
||||||
|
form.category_ids = form.category_ids.includes(categoryId)
|
||||||
|
? form.category_ids.filter((id) => id !== categoryId)
|
||||||
|
: [...form.category_ids, categoryId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInvoice() {
|
||||||
|
saving = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const normalizedLineItems = form.line_items
|
||||||
|
.filter((line) => line.product_id && line.quantity && line.unit_price)
|
||||||
|
.map((line) => ({
|
||||||
|
product_id: Number(line.product_id),
|
||||||
|
quantity: Number(line.quantity),
|
||||||
|
unit_price: Number(line.unit_price)
|
||||||
|
}));
|
||||||
|
if (!Number(form.vendor_id)) {
|
||||||
|
throw new Error("Vendor is required");
|
||||||
|
}
|
||||||
|
if (!normalizedLineItems.length) {
|
||||||
|
throw new Error("Add at least one complete line item");
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
business_id: form.business_id ? Number(form.business_id) : null,
|
||||||
|
vendor_id: Number(form.vendor_id),
|
||||||
|
discount_pct: Number(form.discount_pct || 0),
|
||||||
|
category_ids: form.category_ids.map((id) => Number(id)),
|
||||||
|
line_items: normalizedLineItems
|
||||||
|
};
|
||||||
|
const saved = editingInvoiceId
|
||||||
|
? await api.updateInvoice(editingInvoiceId, payload)
|
||||||
|
: await api.createInvoice(payload);
|
||||||
|
await loadData(query);
|
||||||
|
startInvoiceDraft(invoices.find((invoice) => invoice.id === saved.id) ?? saved);
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to save invoice";
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInvoice() {
|
||||||
|
if (!editingInvoiceId) return;
|
||||||
|
saving = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await api.deleteInvoice(editingInvoiceId);
|
||||||
|
await loadData(query);
|
||||||
|
startInvoiceDraft(invoices[0] ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to delete invoice";
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: subtotal = form.line_items.reduce((sum, line) => sum + Number(line.quantity || 0) * Number(line.unit_price || 0), 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold">Invoice tracker</h2>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<input
|
||||||
|
class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2"
|
||||||
|
bind:value={query}
|
||||||
|
on:change={() => loadData(query)}
|
||||||
|
placeholder="Search vendor, note, or invoice number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold">Recent invoices</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Consolidated imported data plus new Django-backed invoices.</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-[var(--muted-foreground)]">{invoices.length} loaded</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading invoices…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each invoices as invoice}
|
||||||
|
<button
|
||||||
|
class="w-full rounded-2xl border border-[var(--border)] bg-white/60 p-4 text-left transition hover:bg-white"
|
||||||
|
on:click={() => startInvoiceDraft(invoice)}
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{invoice.vendor_name}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{invoice.invoice_number || "Draft / unnumbered"}</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||||
|
{invoice.payment_status} {invoice.business_name ? `• ${invoice.business_name}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-semibold">{invoice.gross_total.toFixed(2)} {invoice.currency}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{invoice.due_date ?? "No due date"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{#if selectedInvoice}
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold">{selectedInvoice.vendor_name}</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{selectedInvoice.invoice_number || "Draft / unnumbered"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-lg font-semibold">{selectedInvoice.gross_total.toFixed(2)} {selectedInvoice.currency}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">VAT {selectedInvoice.vat_amount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
|
<div class="rounded-xl bg-black/5 p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Business</p><p class="mt-1">{selectedInvoice.business_name ?? "Unassigned"}</p></div>
|
||||||
|
<div class="rounded-xl bg-black/5 p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Status</p><p class="mt-1">{selectedInvoice.payment_status}</p></div>
|
||||||
|
<div class="rounded-xl bg-black/5 p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Due</p><p class="mt-1">{selectedInvoice.due_date ?? "Not set"}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
{#each selectedInvoice.line_items as line}
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{line.product_name}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{line.quantity} × {line.unit_price.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<p>{line.total_price.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold">{editingInvoiceId ? "Edit invoice" : "New invoice"}</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">This writes to the new Django model layer, not the old FastAPI route logic.</p>
|
||||||
|
</div>
|
||||||
|
{#if editingInvoiceId}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" on:click={() => startInvoiceDraft(null)}>Reset</Button>
|
||||||
|
<Button type="button" variant="destructive" on:click={deleteInvoice} disabled={saving}>Delete</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" on:submit|preventDefault={saveInvoice}>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Business</label>
|
||||||
|
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.business_id}>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{#each businesses as business}
|
||||||
|
<option value={business.id}>{business.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Vendor</label>
|
||||||
|
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.vendor_id} required>
|
||||||
|
<option value={0}>Select vendor</option>
|
||||||
|
{#each vendors as vendor}
|
||||||
|
<option value={vendor.id}>{vendor.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Invoice number</label>
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.invoice_number} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Invoice date</label>
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="date" bind:value={form.invoice_date} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Due date</label>
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="date" bind:value={form.due_date} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Currency</label>
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.currency} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Payment status</label>
|
||||||
|
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.payment_status}>
|
||||||
|
<option value="unpaid">Unpaid</option>
|
||||||
|
<option value="paid">Paid</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Goods received</label>
|
||||||
|
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.goods_received_status}>
|
||||||
|
<option value="not_received">Not received</option>
|
||||||
|
<option value="received">Received</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Categories</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each categories as category}
|
||||||
|
<button
|
||||||
|
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
|
||||||
|
type="button"
|
||||||
|
on:click={() => toggleCategory(category.id)}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="block text-sm font-medium">Line items</label>
|
||||||
|
<Button type="button" variant="secondary" on:click={addLine}>Add line</Button>
|
||||||
|
</div>
|
||||||
|
{#each form.line_items as line, index}
|
||||||
|
<div class="grid gap-3 md:grid-cols-[1.5fr_0.7fr_0.8fr_auto]">
|
||||||
|
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={line.product_id}>
|
||||||
|
<option value={0}>Select product</option>
|
||||||
|
{#each products as product}
|
||||||
|
<option value={product.id}>{product.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="number" min="0" step="0.001" bind:value={line.quantity} />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="number" min="0" step="0.01" bind:value={line.unit_price} />
|
||||||
|
<div class="flex items-center justify-end text-sm text-[var(--muted-foreground)]">
|
||||||
|
{(Number(line.quantity || 0) * Number(line.unit_price || 0)).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Notes</label>
|
||||||
|
<textarea class="min-h-28 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.notes}></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-2xl bg-black/5 p-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Draft subtotal</p>
|
||||||
|
<p class="text-2xl font-semibold">{subtotal.toFixed(2)} {form.currency}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? "Saving…" : editingInvoiceId ? "Update invoice" : "Create invoice"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
131
frontend/src/routes/app/schedule/+page.svelte
Normal file
131
frontend/src/routes/app/schedule/+page.svelte
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import type { Business, ScheduleOverview } from "$lib/types/domain";
|
||||||
|
|
||||||
|
let overview: ScheduleOverview | null = null;
|
||||||
|
let businesses: Business[] = [];
|
||||||
|
let selectedBusiness = "";
|
||||||
|
let loading = true;
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [scheduleData, businessData] = await Promise.all([
|
||||||
|
api.scheduleOverview({ business_id: selectedBusiness ? Number(selectedBusiness) : undefined }),
|
||||||
|
api.businesses()
|
||||||
|
]);
|
||||||
|
overview = scheduleData;
|
||||||
|
businesses = businessData.results;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load schedule";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadData);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold">Schedule overview</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedBusiness}>
|
||||||
|
<option value="">All businesses</option>
|
||||||
|
{#each businesses as business}
|
||||||
|
<option value={business.id}>{business.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<Button type="button" on:click={loadData}>Filter</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||||
|
{:else if loading || !overview}
|
||||||
|
<Card>Loading schedule…</Card>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Roles</p><p class="mt-2 text-3xl font-semibold">{overview.roles.length}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Templates</p><p class="mt-2 text-3xl font-semibold">{overview.templates.length}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Assignments</p><p class="mt-2 text-3xl font-semibold">{overview.assignments.length}</p></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[0.7fr_1.2fr_1.1fr]">
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Roles</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Shift role catalogue by business.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each overview.roles as role}
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{role.name}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{role.business_name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="h-4 w-4 rounded-full border border-black/10" style={`background:${role.color || "#16302b"}`}></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Upcoming templates</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Recurring shift definitions and staffing targets.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each overview.templates as template}
|
||||||
|
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{template.name}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{template.business_name} {template.shift_role_name ? `• ${template.shift_role_name}` : ""}</p>
|
||||||
|
<p class="mt-1 text-sm">{new Date(template.start_datetime).toLocaleString()} to {new Date(template.end_datetime).toLocaleString()}</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||||
|
Staff {template.min_staff} to {template.max_staff} • {template.recurrence_type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-semibold">{template.assignment_count}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">assignments</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Assignments</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Upcoming user allocations against templates.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each overview.assignments as assignment}
|
||||||
|
<div class="flex items-start justify-between border-b border-[var(--border)] py-2 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{assignment.user_name}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{assignment.shift_name}</p>
|
||||||
|
<p class="mt-1 text-sm">{assignment.occurrence_date}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-semibold">{assignment.status}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{assignment.notes || "No note"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
253
frontend/src/routes/app/settings/+page.svelte
Normal file
253
frontend/src/routes/app/settings/+page.svelte
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import type { Business, SettingsOverview, SettingsUser } from "$lib/types/domain";
|
||||||
|
|
||||||
|
let overview: SettingsOverview | null = null;
|
||||||
|
let businesses: Business[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let saving = false;
|
||||||
|
let error = "";
|
||||||
|
let editingUserId: number | null = null;
|
||||||
|
|
||||||
|
let form = {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
display_name: "",
|
||||||
|
email: "",
|
||||||
|
role_id: "" as string | number,
|
||||||
|
business_ids: [] as number[],
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [settingsData, businessData] = await Promise.all([api.settingsOverview(), api.businesses()]);
|
||||||
|
overview = settingsData;
|
||||||
|
businesses = businessData.results;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load settings";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadData);
|
||||||
|
|
||||||
|
function toggleBusiness(id: number) {
|
||||||
|
form.business_ids = form.business_ids.includes(id)
|
||||||
|
? form.business_ids.filter((value) => value !== id)
|
||||||
|
: [...form.business_ids, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditUser(user: SettingsUser) {
|
||||||
|
editingUserId = user.id;
|
||||||
|
form = {
|
||||||
|
username: user.username,
|
||||||
|
password: "",
|
||||||
|
display_name: user.display_name,
|
||||||
|
email: user.email,
|
||||||
|
role_id: user.role_id ?? "",
|
||||||
|
business_ids: [...user.business_ids],
|
||||||
|
is_active: user.is_active,
|
||||||
|
is_superuser: user.is_superuser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingUserId = null;
|
||||||
|
form = {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
display_name: "",
|
||||||
|
email: "",
|
||||||
|
role_id: "",
|
||||||
|
business_ids: [],
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
saving = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const basePayload = {
|
||||||
|
username: form.username,
|
||||||
|
display_name: form.display_name,
|
||||||
|
email: form.email,
|
||||||
|
role_id: form.role_id ? Number(form.role_id) : null,
|
||||||
|
business_ids: form.business_ids,
|
||||||
|
is_active: form.is_active,
|
||||||
|
is_superuser: form.is_superuser
|
||||||
|
};
|
||||||
|
if (editingUserId) {
|
||||||
|
await api.updateUser(editingUserId, {
|
||||||
|
...basePayload,
|
||||||
|
password: form.password || undefined
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!form.password) {
|
||||||
|
throw new Error("Password is required");
|
||||||
|
}
|
||||||
|
await api.createUser({
|
||||||
|
...basePayload,
|
||||||
|
password: form.password
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to save user";
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser() {
|
||||||
|
if (!editingUserId) return;
|
||||||
|
saving = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await api.deleteUser(editingUserId);
|
||||||
|
resetForm();
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to delete user";
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Administration</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold">Settings and access</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading || !overview}
|
||||||
|
<Card>Loading settings…</Card>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Users</p><p class="mt-2 text-3xl font-semibold">{overview.users.length}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Roles</p><p class="mt-2 text-3xl font-semibold">{overview.roles.length}</p></Card>
|
||||||
|
<Card><p class="text-sm text-[var(--muted-foreground)]">Permissions</p><p class="mt-2 text-3xl font-semibold">{overview.permissions.length}</p></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Users</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Current account state imported into Django auth.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each overview.users as user}
|
||||||
|
<button class="w-full rounded-2xl border border-[var(--border)] bg-white/60 p-4 text-left transition hover:bg-white" on:click={() => startEditUser(user)}>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{user.display_name || user.username}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{user.username} {user.email ? `• ${user.email}` : ""}</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||||
|
{user.role_name || "No role"} • {user.business_ids.length} businesses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class={`text-sm ${user.is_active ? "text-green-700" : "text-red-700"}`}>{user.is_active ? "Active" : "Inactive"}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Roles</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Domain roles and assigned permission keys.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each overview.roles as role}
|
||||||
|
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{role.name}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{role.description || "No description"}</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||||
|
{role.user_count} users • {role.permission_keys.length} permissions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{role.is_system ? "System" : "Custom"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold">{editingUserId ? "Edit user" : "Create user"}</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Minimal replacement for the old settings page bulk CRUD.</p>
|
||||||
|
</div>
|
||||||
|
{#if editingUserId}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" on:click={resetForm}>Reset</Button>
|
||||||
|
<Button type="button" variant="destructive" on:click={deleteUser} disabled={saving}>Delete</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<form class="space-y-4" on:submit|preventDefault={saveUser}>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.username} placeholder="Username" required />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.password} type="password" placeholder={editingUserId ? "Leave blank to keep password" : "Password"} required={!editingUserId} />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.display_name} placeholder="Display name" />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.email} type="email" placeholder="Email" />
|
||||||
|
</div>
|
||||||
|
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.role_id}>
|
||||||
|
<option value="">No role</option>
|
||||||
|
{#each overview.roles as role}
|
||||||
|
<option value={role.id}>{role.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-medium">Business access</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each businesses as business}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
|
||||||
|
on:click={() => toggleBusiness(business.id)}
|
||||||
|
>
|
||||||
|
{business.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" bind:checked={form.is_active} />
|
||||||
|
Active user
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" bind:checked={form.is_superuser} />
|
||||||
|
Superuser
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? "Saving…" : editingUserId ? "Update user" : "Create user"}</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
230
frontend/src/routes/app/vendors/+page.svelte
vendored
Normal file
230
frontend/src/routes/app/vendors/+page.svelte
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import type { Business, Category, Vendor } from "$lib/types/domain";
|
||||||
|
|
||||||
|
let vendors: Vendor[] = [];
|
||||||
|
let businesses: Business[] = [];
|
||||||
|
let categories: Category[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let saving = false;
|
||||||
|
let error = "";
|
||||||
|
let query = "";
|
||||||
|
let selectedBusiness = "";
|
||||||
|
let selectedCategory = "";
|
||||||
|
let editingVendorId: number | null = null;
|
||||||
|
|
||||||
|
let form = {
|
||||||
|
name: "",
|
||||||
|
vat_number: "",
|
||||||
|
registration_id: "",
|
||||||
|
contact_email: "",
|
||||||
|
contact_phone: "",
|
||||||
|
address: "",
|
||||||
|
notes: "",
|
||||||
|
is_active: true,
|
||||||
|
business_ids: [] as number[],
|
||||||
|
category_ids: [] as number[]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [vendorData, businessData, categoryData] = await Promise.all([
|
||||||
|
api.vendors({
|
||||||
|
q: query || undefined,
|
||||||
|
business_id: selectedBusiness ? Number(selectedBusiness) : undefined,
|
||||||
|
category_id: selectedCategory ? Number(selectedCategory) : undefined
|
||||||
|
}),
|
||||||
|
api.businesses(),
|
||||||
|
api.categories()
|
||||||
|
]);
|
||||||
|
vendors = vendorData.results;
|
||||||
|
businesses = businessData.results;
|
||||||
|
categories = categoryData.results;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load vendors";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadData);
|
||||||
|
|
||||||
|
function toggleId(list: number[], id: number) {
|
||||||
|
return list.includes(id) ? list.filter((item) => item !== id) : [...list, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(vendor: Vendor) {
|
||||||
|
editingVendorId = vendor.id;
|
||||||
|
form = {
|
||||||
|
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: [...vendor.business_ids],
|
||||||
|
category_ids: [...vendor.category_ids]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingVendorId = null;
|
||||||
|
form = {
|
||||||
|
name: "",
|
||||||
|
vat_number: "",
|
||||||
|
registration_id: "",
|
||||||
|
contact_email: "",
|
||||||
|
contact_phone: "",
|
||||||
|
address: "",
|
||||||
|
notes: "",
|
||||||
|
is_active: true,
|
||||||
|
business_ids: [],
|
||||||
|
category_ids: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveVendor() {
|
||||||
|
saving = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
if (editingVendorId) {
|
||||||
|
await api.updateVendor(editingVendorId, form);
|
||||||
|
} else {
|
||||||
|
await api.createVendor(form);
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to save vendor";
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold">Vendor management</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={query} placeholder="Search vendors" />
|
||||||
|
<Button type="button" on:click={loadData}>Filter</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedBusiness}>
|
||||||
|
<option value="">All businesses</option>
|
||||||
|
{#each businesses as business}
|
||||||
|
<option value={business.id}>{business.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedCategory}>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{#each categories as category}
|
||||||
|
<option value={category.id}>{category.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading vendors…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each vendors as vendor}
|
||||||
|
<button class="w-full rounded-2xl border border-[var(--border)] bg-white/60 p-4 text-left transition hover:bg-white" on:click={() => startEdit(vendor)}>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{vendor.name}</p>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">{vendor.contact_email || vendor.contact_phone || "No primary contact"}</p>
|
||||||
|
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||||
|
{vendor.business_ids.length} businesses • {vendor.category_ids.length} categories
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class={`text-sm ${vendor.is_active ? "text-green-700" : "text-red-700"}`}>{vendor.is_active ? "Active" : "Inactive"}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold">{editingVendorId ? "Edit vendor" : "Create vendor"}</h3>
|
||||||
|
<p class="text-sm text-[var(--muted-foreground)]">Manage linkage to businesses and invoice categories.</p>
|
||||||
|
</div>
|
||||||
|
{#if editingVendorId}
|
||||||
|
<Button type="button" variant="ghost" on:click={resetForm}>Reset</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" on:submit|preventDefault={saveVendor}>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.name} placeholder="Vendor name" required />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.vat_number} placeholder="VAT number" />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.registration_id} placeholder="Registration ID" />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.contact_email} placeholder="Email" />
|
||||||
|
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2 md:col-span-2" bind:value={form.contact_phone} placeholder="Phone" />
|
||||||
|
</div>
|
||||||
|
<textarea class="min-h-20 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.address} placeholder="Address"></textarea>
|
||||||
|
<textarea class="min-h-24 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.notes} placeholder="Notes"></textarea>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-medium">Businesses</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each businesses as business}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
|
||||||
|
on:click={() => (form.business_ids = toggleId(form.business_ids, business.id))}
|
||||||
|
>
|
||||||
|
{business.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-medium">Categories</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each categories as category}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
|
||||||
|
on:click={() => (form.category_ids = toggleId(form.category_ids, category.id))}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" bind:checked={form.is_active} />
|
||||||
|
Active vendor
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? "Saving…" : editingVendorId ? "Update vendor" : "Create vendor"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
84
frontend/src/routes/login/+page.svelte
Normal file
84
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import Button from "$lib/components/ui/button.svelte";
|
||||||
|
import Card from "$lib/components/ui/card.svelte";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
|
import { authUser, authReady, bootstrapAuth } from "$lib/stores/auth";
|
||||||
|
|
||||||
|
let username = "";
|
||||||
|
let password = "";
|
||||||
|
let error = "";
|
||||||
|
let submitting = false;
|
||||||
|
let checkingSession = true;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const user = await bootstrapAuth();
|
||||||
|
if (user) {
|
||||||
|
goto("/app/dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
checkingSession = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
submitting = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await api.csrf();
|
||||||
|
const data = await api.login(username, password);
|
||||||
|
authUser.set(data.user);
|
||||||
|
authReady.set(true);
|
||||||
|
goto("/app/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Login failed";
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if checkingSession}
|
||||||
|
<div class="mx-auto flex min-h-screen max-w-6xl items-center justify-center px-6 py-10">
|
||||||
|
<div class="panel p-6 text-center">
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Session</p>
|
||||||
|
<p class="mt-2 text-lg font-medium">Checking authentication…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mx-auto flex min-h-screen max-w-6xl items-center px-6 py-10">
|
||||||
|
<div class="grid w-full gap-8 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<p class="text-sm uppercase tracking-[0.35em] text-[var(--muted-foreground)]">Django + Svelte port</p>
|
||||||
|
<h1 class="max-w-2xl text-5xl font-semibold leading-tight">
|
||||||
|
Replace the brittle FastAPI tangle with a session-based operations platform.
|
||||||
|
</h1>
|
||||||
|
<p class="max-w-xl text-lg text-[var(--muted-foreground)]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<form class="space-y-4" on:submit|preventDefault={submit}>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Username</label>
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={username} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium">Password</label>
|
||||||
|
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="password" bind:value={password} />
|
||||||
|
</div>
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-red-700">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
10
frontend/svelte.config.js
Normal file
10
frontend/svelte.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
kit: {
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -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()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user