diff --git a/.env.example b/.env.example
index 7211161..cb1da34 100644
--- a/.env.example
+++ b/.env.example
@@ -64,6 +64,20 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
+
+# =====================================================
+# FedEx Integration (Optional)
+# =====================================================
+FEDEX_ENABLED=false
+FEDEX_API_KEY=
+FEDEX_API_SECRET=
+FEDEX_ACCOUNT_NUMBER=
+FEDEX_BASE_URL=
+FEDEX_TIMEOUT_SECONDS=20
+
+# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede forsendelser
+FEDEX_READ_ONLY=true
+FEDEX_DRY_RUN=true
# =====================================================
# Nextcloud Integration (Optional)
# =====================================================
diff --git a/.env.prod.example b/.env.prod.example
index ebbad72..2f636c4 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -82,6 +82,21 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here
ECONOMIC_READ_ONLY=true
ECONOMIC_DRY_RUN=true
+# =====================================================
+# FedEx Integration - Production
+# =====================================================
+FEDEX_ENABLED=false
+FEDEX_API_KEY=
+FEDEX_API_SECRET=
+FEDEX_ACCOUNT_NUMBER=
+FEDEX_BASE_URL=
+FEDEX_TIMEOUT_SECONDS=20
+
+# 🚨 SAFETY SWITCHES
+# Start ALTID med begge sat til true i ny production deployment!
+FEDEX_READ_ONLY=true
+FEDEX_DRY_RUN=true
+
# =====================================================
# Links / Endpoints Module - Production (Optional)
# =====================================================
diff --git a/app/anydesk/frontend/sessions.html b/app/anydesk/frontend/sessions.html
index d5760b1..ba6a39f 100644
--- a/app/anydesk/frontend/sessions.html
+++ b/app/anydesk/frontend/sessions.html
@@ -648,7 +648,7 @@ function renderTable(sessions) {
: `–`;
const sagCell = s.sag
- ? `${s.sag.titel||'Sag #'+s.sag.id}`
+ ? `${s.sag.titel||'Sag #'+s.sag.id}`
: `–`;
const statusBadge = isUnreg
diff --git a/app/core/config.py b/app/core/config.py
index e105dd5..65a1f1f 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -288,6 +288,16 @@ class Settings(BaseSettings):
SMS_SENDER: str = "BMC Networks"
SMS_WEBHOOK_SECRET: str = ""
+ # FedEx Integration
+ FEDEX_ENABLED: bool = False
+ FEDEX_READ_ONLY: bool = True
+ FEDEX_DRY_RUN: bool = True
+ FEDEX_API_KEY: str = ""
+ FEDEX_API_SECRET: str = ""
+ FEDEX_ACCOUNT_NUMBER: str = ""
+ FEDEX_BASE_URL: str = ""
+ FEDEX_TIMEOUT_SECONDS: int = 20
+
# Bottom bar module
BOTTOM_BAR_ENABLED: bool = False
diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html
index 1dd76a4..a2090b5 100644
--- a/app/customers/frontend/customer_detail.html
+++ b/app/customers/frontend/customer_detail.html
@@ -2651,13 +2651,13 @@ async function loadCustomerCases() {
return `
- | #${id} |
+ #${id} |
${title} |
${statusLabel} |
${priority} |
${created} |
-
+
|
diff --git a/app/customers/frontend/pipeline.html b/app/customers/frontend/pipeline.html
index 03d7e94..01fc629 100644
--- a/app/customers/frontend/pipeline.html
+++ b/app/customers/frontend/pipeline.html
@@ -289,7 +289,7 @@ async function createOpportunity() {
}
function goToDetail(id) {
- window.location.href = `/sag/${id}`;
+ window.location.href = `/sag/${id}/v3`;
}
function formatCurrency(value, currency) {
diff --git a/app/dashboard/frontend/mission_control.html b/app/dashboard/frontend/mission_control.html
index 066c24c..f83d5d8 100644
--- a/app/dashboard/frontend/mission_control.html
+++ b/app/dashboard/frontend/mission_control.html
@@ -786,7 +786,7 @@
function getCaseHref(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return '/sag';
- return `/sag/${id}`;
+ return `/sag/${id}/v3`;
}
function getEmailHref(emailId) {
diff --git a/app/dashboard/frontend/sales.html b/app/dashboard/frontend/sales.html
index beec233..610a702 100644
--- a/app/dashboard/frontend/sales.html
+++ b/app/dashboard/frontend/sales.html
@@ -81,7 +81,7 @@
{{ item.pipeline_stage or '-' }} |
{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr. |
{{ "%.0f"|format((item.pipeline_probability or 0)|float) }}% |
- Ă…bn |
+ Ă…bn |
{% else %}
| Ingen opportunities fundet. |
@@ -102,7 +102,7 @@
{{ item.titel }}
{{ item.customer_name }} · {{ item.owner_name }}
Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}
- Ă…bn
+ Ă…bn
{% else %}
Ingen deadlines de næste 14 dage.
diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html
index 72985a6..2472835 100644
--- a/app/emails/frontend/emails.html
+++ b/app/emails/frontend/emails.html
@@ -2194,7 +2194,7 @@ function renderEmailDetail(email) {
${email.linked_case_id ? `
@@ -2232,7 +2232,7 @@ function renderEmailDetail(email) {
Workflows
${email.linked_case_id ? `
-
+
SAG-${email.linked_case_id}
` : 'Ingen sag linket'}
@@ -3768,7 +3768,7 @@ function getCaseBadge(email) {
}
const title = email.linked_case_title ? ` title="${escapeHtml(email.linked_case_title)}"` : '';
- return `SAG-${email.linked_case_id}`;
+ return `SAG-${email.linked_case_id}`;
}
function getPriorityBadge(email) {
diff --git a/app/fixed_price/frontend/detail.html b/app/fixed_price/frontend/detail.html
index 7dd1a07..232008a 100644
--- a/app/fixed_price/frontend/detail.html
+++ b/app/fixed_price/frontend/detail.html
@@ -243,7 +243,7 @@
{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }} |
-
+
Vis
|
@@ -284,7 +284,7 @@
{{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }} |
{% if entry.sag_id %}
- #{{ entry.sag_id }}
+ #{{ entry.sag_id }}
{% if entry.sag_titel %}
{{ entry.sag_titel[:30] }}
{% endif %}
diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py
index 040e22b..20f6116 100644
--- a/app/modules/bottom_bar/backend/service.py
+++ b/app/modules/bottom_bar/backend/service.py
@@ -560,7 +560,7 @@ def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]
"sag_id": row.get("sag_id"),
"case_title": row.get("case_title"),
"customer_name": row.get("customer_name"),
- "action": f"/sag/{row.get('sag_id')}" if row.get("sag_id") else "/sag",
+ "action": f"/sag/{row.get('sag_id')}/v3" if row.get("sag_id") else "/sag",
"created_at": row.get("next_check_at"),
}
)
diff --git a/app/modules/calendar/backend/router.py b/app/modules/calendar/backend/router.py
index 189cfe5..5a02e1e 100644
--- a/app/modules/calendar/backend/router.py
+++ b/app/modules/calendar/backend/router.py
@@ -127,7 +127,7 @@ def _get_calendar_events(
"case_deadline",
title,
start_value,
- f"/sag/{row.get('id')}",
+ f"/sag/{row.get('id')}/v3",
{
"reference_id": row.get("id"),
"reference_type": "case",
@@ -170,7 +170,7 @@ def _get_calendar_events(
"case_deferred",
title,
start_value,
- f"/sag/{row.get('id')}",
+ f"/sag/{row.get('id')}/v3",
{
"reference_id": row.get("id"),
"reference_type": "case",
@@ -224,7 +224,7 @@ def _get_calendar_events(
"case_reminder",
title,
start_value,
- f"/sag/{row.get('sag_id')}",
+ f"/sag/{row.get('sag_id')}/v3",
{
"reference_id": row.get("id"),
"reference_type": "reminder",
diff --git a/app/modules/fedex/__init__.py b/app/modules/fedex/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/modules/fedex/backend/__init__.py b/app/modules/fedex/backend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/modules/fedex/backend/api_client.py b/app/modules/fedex/backend/api_client.py
new file mode 100644
index 0000000..60861b2
--- /dev/null
+++ b/app/modules/fedex/backend/api_client.py
@@ -0,0 +1,87 @@
+import logging
+from typing import Any, Dict, List
+
+import aiohttp
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class FedExApiClient:
+ def __init__(self) -> None:
+ self.base_url = (settings.FEDEX_BASE_URL or "").rstrip("/")
+ self.timeout_seconds = max(5, int(settings.FEDEX_TIMEOUT_SECONDS or 20))
+
+ def _headers(self) -> Dict[str, str]:
+ return {
+ "Content-Type": "application/json",
+ "X-API-KEY": settings.FEDEX_API_KEY or "",
+ "X-API-SECRET": settings.FEDEX_API_SECRET or "",
+ "X-FEDEX-ACCOUNT": settings.FEDEX_ACCOUNT_NUMBER or "",
+ }
+
+ async def create_shipment(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not self.base_url:
+ raise RuntimeError("FedEx base URL is not configured")
+
+ url = f"{self.base_url}/shipments"
+ logger.info("🚀 FedEx create shipment request sent")
+
+ timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.post(url, json=payload, headers=self._headers()) as response:
+ body = await response.text()
+ if response.status >= 400:
+ logger.error("❌ FedEx create shipment failed (%s): %s", response.status, body)
+ raise RuntimeError(f"FedEx create shipment failed: HTTP {response.status}")
+ return await response.json()
+
+ async def get_tracking(self, tracking_number: str) -> Dict[str, Any]:
+ if not self.base_url:
+ raise RuntimeError("FedEx base URL is not configured")
+
+ url = f"{self.base_url}/tracking/{tracking_number}"
+ timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.get(url, headers=self._headers()) as response:
+ body = await response.text()
+ if response.status >= 400:
+ logger.error("❌ FedEx tracking failed (%s): %s", response.status, body)
+ raise RuntimeError(f"FedEx tracking failed: HTTP {response.status}")
+ return await response.json()
+
+ async def cancel_shipment(self, tracking_number: str) -> Dict[str, Any]:
+ if not self.base_url:
+ raise RuntimeError("FedEx base URL is not configured")
+
+ url = f"{self.base_url}/shipments/{tracking_number}/cancel"
+ timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.post(url, headers=self._headers()) as response:
+ body = await response.text()
+ if response.status >= 400:
+ logger.error("❌ FedEx cancel failed (%s): %s", response.status, body)
+ raise RuntimeError(f"FedEx cancel failed: HTTP {response.status}")
+ return await response.json()
+
+
+def parse_tracking_events(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
+ raw_events = payload.get("events") or []
+ if not isinstance(raw_events, list):
+ return []
+
+ normalized: List[Dict[str, Any]] = []
+ for event in raw_events:
+ if not isinstance(event, dict):
+ continue
+ normalized.append(
+ {
+ "status": str(event.get("status") or "unknown"),
+ "description": event.get("description"),
+ "event_timestamp": event.get("event_timestamp") or event.get("timestamp"),
+ "location_city": event.get("location_city") or event.get("city"),
+ "location_country": event.get("location_country") or event.get("country"),
+ }
+ )
+ return normalized
diff --git a/app/modules/fedex/backend/router.py b/app/modules/fedex/backend/router.py
new file mode 100644
index 0000000..c7d1ef1
--- /dev/null
+++ b/app/modules/fedex/backend/router.py
@@ -0,0 +1,66 @@
+from typing import Optional
+
+from fastapi import APIRouter, Query, Request
+
+from app.modules.fedex.backend.service import fedex_service
+from app.modules.fedex.models.schemas import (
+ FedExBookingCreate,
+ FedExBookingListResponse,
+ FedExBookingResponse,
+ FedExBookingSubmitResponse,
+ FedExCancelRequest,
+ FedExCancelResponse,
+ FedExTrackingResponse,
+)
+
+router = APIRouter()
+
+
+def _user_id_from_request(request: Request) -> Optional[int]:
+ raw_user_id = getattr(request.state, "user_id", None)
+ if raw_user_id is None:
+ return None
+ try:
+ return int(raw_user_id)
+ except (TypeError, ValueError):
+ return None
+
+
+@router.get("/fedex/config")
+async def fedex_config() -> dict:
+ return {
+ "enabled": fedex_service.enabled,
+ "read_only": fedex_service.read_only,
+ "dry_run": fedex_service.dry_run,
+ }
+
+
+@router.post("/fedex/bookings", response_model=FedExBookingResponse)
+async def create_booking(payload: FedExBookingCreate, request: Request):
+ booking = fedex_service.create_booking_draft(payload, _user_id_from_request(request))
+ return booking
+
+
+@router.get("/fedex/bookings", response_model=FedExBookingListResponse)
+async def list_bookings(case_id: Optional[int] = Query(default=None, gt=0)):
+ return {"items": fedex_service.list_bookings(case_id=case_id)}
+
+
+@router.get("/fedex/bookings/{booking_ref}", response_model=FedExBookingResponse)
+async def get_booking(booking_ref: str):
+ return fedex_service.get_booking(booking_ref)
+
+
+@router.post("/fedex/bookings/{booking_ref}/submit", response_model=FedExBookingSubmitResponse)
+async def submit_booking(booking_ref: str, request: Request):
+ return await fedex_service.submit_booking(booking_ref, _user_id_from_request(request))
+
+
+@router.get("/fedex/bookings/{booking_ref}/tracking", response_model=FedExTrackingResponse)
+async def get_tracking(booking_ref: str):
+ return await fedex_service.get_tracking(booking_ref)
+
+
+@router.post("/fedex/bookings/{booking_ref}/cancel", response_model=FedExCancelResponse)
+async def cancel_booking(booking_ref: str, payload: FedExCancelRequest, request: Request):
+ return await fedex_service.cancel_booking(booking_ref, payload.reason, _user_id_from_request(request))
diff --git a/app/modules/fedex/backend/service.py b/app/modules/fedex/backend/service.py
new file mode 100644
index 0000000..2eb49ff
--- /dev/null
+++ b/app/modules/fedex/backend/service.py
@@ -0,0 +1,439 @@
+import json
+import logging
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional
+from uuid import uuid4
+
+from fastapi import HTTPException
+
+from app.core.config import settings
+from app.core.database import execute_query, execute_query_single
+from app.modules.fedex.backend.api_client import FedExApiClient, parse_tracking_events
+from app.modules.fedex.models.schemas import FedExBookingCreate
+
+logger = logging.getLogger(__name__)
+
+
+class FedExService:
+ def __init__(self) -> None:
+ self.client = FedExApiClient()
+
+ @property
+ def enabled(self) -> bool:
+ return bool(settings.FEDEX_ENABLED)
+
+ @property
+ def read_only(self) -> bool:
+ return bool(settings.FEDEX_READ_ONLY)
+
+ @property
+ def dry_run(self) -> bool:
+ return bool(settings.FEDEX_DRY_RUN)
+
+ def _assert_enabled(self) -> None:
+ if not self.enabled:
+ raise HTTPException(status_code=503, detail="FedEx integration is disabled")
+
+ def _booking_ref(self) -> str:
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
+ return f"FDX-{stamp}-{uuid4().hex[:8].upper()}"
+
+ def _validate_relations(self, payload: FedExBookingCreate) -> None:
+ case_exists = execute_query_single("SELECT id FROM sag_sager WHERE id = %s", (payload.case_id,))
+ if not case_exists:
+ raise HTTPException(status_code=404, detail="Case not found")
+
+ if payload.customer_id:
+ customer_exists = execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,))
+ if not customer_exists:
+ raise HTTPException(status_code=404, detail="Customer not found")
+
+ if payload.contact_id:
+ contact_exists = execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,))
+ if not contact_exists:
+ raise HTTPException(status_code=404, detail="Contact not found")
+
+ def _fetch_packages(self, shipment_id: int) -> List[Dict[str, Any]]:
+ rows = execute_query(
+ """
+ SELECT weight_kg, length_cm, width_cm, height_cm, description
+ FROM fedex_shipment_packages
+ WHERE shipment_id = %s
+ ORDER BY id ASC
+ """,
+ (shipment_id,),
+ ) or []
+ return [dict(row) for row in rows]
+
+ def _shipment_row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
+ mapped = dict(row)
+ mapped["packages"] = self._fetch_packages(int(row["id"]))
+ return mapped
+
+ def create_booking_draft(self, payload: FedExBookingCreate, created_by_user_id: Optional[int]) -> Dict[str, Any]:
+ self._assert_enabled()
+ self._validate_relations(payload)
+
+ if payload.pickup_window_end <= payload.pickup_window_start:
+ raise HTTPException(status_code=400, detail="pickup_window_end must be after pickup_window_start")
+
+ booking_ref = self._booking_ref()
+ shipment_row = execute_query_single(
+ """
+ INSERT INTO fedex_shipments (
+ booking_ref,
+ case_id,
+ customer_id,
+ contact_id,
+ service_type,
+ shipment_status,
+ pickup_window_start,
+ pickup_window_end,
+ recipient_name,
+ company_name,
+ address_line1,
+ address_line2,
+ postal_code,
+ city,
+ country_code,
+ phone,
+ email,
+ dry_run,
+ created_by_user_id,
+ updated_by_user_id,
+ api_payload,
+ api_response
+ ) VALUES (
+ %s, %s, %s, %s, %s, 'draft',
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, %s, %s, %s::jsonb, %s::jsonb
+ )
+ RETURNING *
+ """,
+ (
+ booking_ref,
+ payload.case_id,
+ payload.customer_id,
+ payload.contact_id,
+ payload.service_type,
+ payload.pickup_window_start,
+ payload.pickup_window_end,
+ payload.address.recipient_name,
+ payload.address.company_name,
+ payload.address.address_line1,
+ payload.address.address_line2,
+ payload.address.postal_code,
+ payload.address.city,
+ payload.address.country_code.upper(),
+ payload.address.phone,
+ payload.address.email,
+ self.dry_run,
+ created_by_user_id,
+ created_by_user_id,
+ json.dumps(payload.model_dump(mode="json"), ensure_ascii=False),
+ json.dumps({"status": "draft_created"}, ensure_ascii=False),
+ ),
+ )
+ if not shipment_row:
+ raise HTTPException(status_code=500, detail="Failed to create booking draft")
+
+ shipment_id = int(shipment_row["id"])
+ for package in payload.packages:
+ execute_query(
+ """
+ INSERT INTO fedex_shipment_packages (
+ shipment_id, weight_kg, length_cm, width_cm, height_cm, description
+ ) VALUES (%s, %s, %s, %s, %s, %s)
+ """,
+ (
+ shipment_id,
+ package.weight_kg,
+ package.length_cm,
+ package.width_cm,
+ package.height_cm,
+ package.description,
+ ),
+ )
+
+ logger.info("âś… FedEx draft created: %s (case=%s)", booking_ref, payload.case_id)
+ return self._shipment_row_to_dict(dict(shipment_row))
+
+ def list_bookings(self, case_id: Optional[int] = None) -> List[Dict[str, Any]]:
+ params: List[Any] = []
+ where_sql = "WHERE deleted_at IS NULL"
+ if case_id is not None:
+ where_sql += " AND case_id = %s"
+ params.append(case_id)
+
+ rows = execute_query(
+ f"""
+ SELECT *
+ FROM fedex_shipments
+ {where_sql}
+ ORDER BY created_at DESC
+ LIMIT 200
+ """,
+ tuple(params),
+ ) or []
+ return [self._shipment_row_to_dict(dict(row)) for row in rows]
+
+ def get_booking(self, booking_ref: str) -> Dict[str, Any]:
+ row = execute_query_single(
+ """
+ SELECT *
+ FROM fedex_shipments
+ WHERE booking_ref = %s
+ AND deleted_at IS NULL
+ """,
+ (booking_ref,),
+ )
+ if not row:
+ raise HTTPException(status_code=404, detail="Booking not found")
+ return self._shipment_row_to_dict(dict(row))
+
+ async def submit_booking(self, booking_ref: str, user_id: Optional[int]) -> Dict[str, Any]:
+ self._assert_enabled()
+ shipment = self.get_booking(booking_ref)
+
+ if shipment["shipment_status"] not in {"draft", "failed"}:
+ raise HTTPException(status_code=409, detail="Only draft/failed bookings can be submitted")
+
+ if self.read_only:
+ raise HTTPException(status_code=403, detail="FedEx write actions are disabled (read-only mode)")
+
+ payload = {
+ "booking_ref": shipment["booking_ref"],
+ "service_type": shipment["service_type"],
+ "pickup_window_start": shipment["pickup_window_start"].isoformat() if shipment.get("pickup_window_start") else None,
+ "pickup_window_end": shipment["pickup_window_end"].isoformat() if shipment.get("pickup_window_end") else None,
+ "recipient": {
+ "recipient_name": shipment["recipient_name"],
+ "company_name": shipment.get("company_name"),
+ "address_line1": shipment["address_line1"],
+ "address_line2": shipment.get("address_line2"),
+ "postal_code": shipment["postal_code"],
+ "city": shipment["city"],
+ "country_code": shipment["country_code"],
+ "phone": shipment.get("phone"),
+ "email": shipment.get("email"),
+ },
+ "packages": shipment["packages"],
+ }
+
+ if self.dry_run:
+ tracking_number = f"DRYRUN-{uuid4().hex[:12].upper()}"
+ label_url = None
+ api_response = {"dry_run": True, "tracking_number": tracking_number}
+ new_status = "submitted"
+ else:
+ try:
+ api_response = await self.client.create_shipment(payload)
+ except Exception as exc:
+ execute_query(
+ """
+ UPDATE fedex_shipments
+ SET shipment_status = 'failed',
+ api_response = %s::jsonb,
+ updated_by_user_id = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE booking_ref = %s
+ """,
+ (json.dumps({"error": str(exc)}, ensure_ascii=False), user_id, booking_ref),
+ )
+ raise HTTPException(status_code=502, detail="FedEx booking failed") from exc
+
+ tracking_number = str(api_response.get("tracking_number") or "").strip() or None
+ label_url = api_response.get("label_url")
+ new_status = "booked"
+
+ updated = execute_query_single(
+ """
+ UPDATE fedex_shipments
+ SET shipment_status = %s,
+ tracking_number = COALESCE(%s, tracking_number),
+ label_url = COALESCE(%s, label_url),
+ submitted_at = CURRENT_TIMESTAMP,
+ api_payload = %s::jsonb,
+ api_response = %s::jsonb,
+ updated_by_user_id = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE booking_ref = %s
+ RETURNING *
+ """,
+ (
+ new_status,
+ tracking_number,
+ label_url,
+ json.dumps(payload, ensure_ascii=False),
+ json.dumps(api_response, ensure_ascii=False),
+ user_id,
+ booking_ref,
+ ),
+ )
+
+ if tracking_number:
+ execute_query(
+ """
+ INSERT INTO fedex_tracking_events (
+ shipment_id, status, description, event_timestamp
+ ) VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
+ """,
+ (
+ updated["id"],
+ "submitted" if self.dry_run else "booked",
+ "Shipment submitted from BMC Hub",
+ ),
+ )
+
+ return {
+ "booking_ref": booking_ref,
+ "status": new_status,
+ "dry_run": self.dry_run,
+ "tracking_number": tracking_number,
+ "label_url": label_url,
+ }
+
+ async def get_tracking(self, booking_ref: str) -> Dict[str, Any]:
+ self._assert_enabled()
+ shipment = self.get_booking(booking_ref)
+
+ tracking_number = shipment.get("tracking_number")
+ if not tracking_number:
+ events = execute_query(
+ """
+ SELECT status, event_timestamp, description, location_city, location_country
+ FROM fedex_tracking_events
+ WHERE shipment_id = %s
+ ORDER BY event_timestamp DESC
+ """,
+ (shipment["id"],),
+ ) or []
+ return {
+ "booking_ref": booking_ref,
+ "shipment_status": shipment["shipment_status"],
+ "tracking_number": None,
+ "events": [dict(row) for row in events],
+ }
+
+ if self.dry_run:
+ events = execute_query(
+ """
+ SELECT status, event_timestamp, description, location_city, location_country
+ FROM fedex_tracking_events
+ WHERE shipment_id = %s
+ ORDER BY event_timestamp DESC
+ """,
+ (shipment["id"],),
+ ) or []
+ return {
+ "booking_ref": booking_ref,
+ "shipment_status": shipment["shipment_status"],
+ "tracking_number": tracking_number,
+ "events": [dict(row) for row in events],
+ }
+
+ try:
+ provider_payload = await self.client.get_tracking(tracking_number)
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail="Failed to fetch FedEx tracking") from exc
+
+ events = parse_tracking_events(provider_payload)
+ if events:
+ execute_query(
+ "DELETE FROM fedex_tracking_events WHERE shipment_id = %s",
+ (shipment["id"],),
+ )
+ for event in events:
+ execute_query(
+ """
+ INSERT INTO fedex_tracking_events (
+ shipment_id,
+ status,
+ description,
+ event_timestamp,
+ location_city,
+ location_country
+ ) VALUES (
+ %s, %s, %s,
+ COALESCE(%s::timestamp, CURRENT_TIMESTAMP),
+ %s, %s
+ )
+ """,
+ (
+ shipment["id"],
+ event.get("status") or "unknown",
+ event.get("description"),
+ event.get("event_timestamp"),
+ event.get("location_city"),
+ event.get("location_country"),
+ ),
+ )
+
+ status = str(provider_payload.get("shipment_status") or shipment["shipment_status"])
+ execute_query(
+ """
+ UPDATE fedex_shipments
+ SET shipment_status = %s,
+ api_response = %s::jsonb,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """,
+ (status, json.dumps(provider_payload, ensure_ascii=False), shipment["id"]),
+ )
+
+ current_events = execute_query(
+ """
+ SELECT status, event_timestamp, description, location_city, location_country
+ FROM fedex_tracking_events
+ WHERE shipment_id = %s
+ ORDER BY event_timestamp DESC
+ """,
+ (shipment["id"],),
+ ) or []
+
+ return {
+ "booking_ref": booking_ref,
+ "shipment_status": status,
+ "tracking_number": tracking_number,
+ "events": [dict(row) for row in current_events],
+ }
+
+ async def cancel_booking(self, booking_ref: str, reason: Optional[str], user_id: Optional[int]) -> Dict[str, Any]:
+ self._assert_enabled()
+ if self.read_only:
+ raise HTTPException(status_code=403, detail="FedEx write actions are disabled (read-only mode)")
+
+ shipment = self.get_booking(booking_ref)
+ if shipment["shipment_status"] == "cancelled":
+ return {"booking_ref": booking_ref, "status": "cancelled", "cancelled": True}
+
+ if not self.dry_run and shipment.get("tracking_number"):
+ try:
+ await self.client.cancel_shipment(str(shipment["tracking_number"]))
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail="Failed to cancel shipment at FedEx") from exc
+
+ execute_query(
+ """
+ UPDATE fedex_shipments
+ SET shipment_status = 'cancelled',
+ cancel_reason = %s,
+ updated_by_user_id = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE booking_ref = %s
+ """,
+ (reason, user_id, booking_ref),
+ )
+
+ execute_query(
+ """
+ INSERT INTO fedex_tracking_events (shipment_id, status, description, event_timestamp)
+ VALUES (%s, 'cancelled', %s, CURRENT_TIMESTAMP)
+ """,
+ (shipment["id"], reason or "Cancelled from BMC Hub"),
+ )
+
+ return {"booking_ref": booking_ref, "status": "cancelled", "cancelled": True}
+
+
+fedex_service = FedExService()
diff --git a/app/modules/fedex/models/__init__.py b/app/modules/fedex/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/modules/fedex/models/schemas.py b/app/modules/fedex/models/schemas.py
new file mode 100644
index 0000000..86241dc
--- /dev/null
+++ b/app/modules/fedex/models/schemas.py
@@ -0,0 +1,105 @@
+from datetime import datetime
+from typing import List, Optional, Literal
+
+from pydantic import BaseModel, Field
+
+
+ShipmentStatus = Literal[
+ "draft",
+ "submitted",
+ "booked",
+ "in_transit",
+ "delivered",
+ "cancelled",
+ "failed",
+]
+
+
+class FedExAddress(BaseModel):
+ recipient_name: str = Field(min_length=2, max_length=150)
+ company_name: Optional[str] = Field(default=None, max_length=150)
+ address_line1: str = Field(min_length=2, max_length=200)
+ address_line2: Optional[str] = Field(default=None, max_length=200)
+ postal_code: str = Field(min_length=2, max_length=20)
+ city: str = Field(min_length=2, max_length=120)
+ country_code: str = Field(min_length=2, max_length=2)
+ phone: Optional[str] = Field(default=None, max_length=50)
+ email: Optional[str] = Field(default=None, max_length=150)
+
+
+class FedExPackageInput(BaseModel):
+ weight_kg: float = Field(gt=0, le=2000)
+ length_cm: float = Field(gt=0, le=400)
+ width_cm: float = Field(gt=0, le=400)
+ height_cm: float = Field(gt=0, le=400)
+ description: str = Field(min_length=1, max_length=255)
+
+
+class FedExBookingCreate(BaseModel):
+ case_id: int = Field(gt=0)
+ customer_id: Optional[int] = Field(default=None, gt=0)
+ contact_id: Optional[int] = Field(default=None, gt=0)
+ service_type: Literal["PRIORITY", "ECONOMY"] = "PRIORITY"
+ pickup_window_start: datetime
+ pickup_window_end: datetime
+ address: FedExAddress
+ packages: List[FedExPackageInput] = Field(min_length=1, max_length=30)
+
+
+class FedExBookingSubmitResponse(BaseModel):
+ booking_ref: str
+ status: ShipmentStatus
+ dry_run: bool
+ tracking_number: Optional[str] = None
+ label_url: Optional[str] = None
+
+
+class FedExTrackingEvent(BaseModel):
+ status: str
+ event_timestamp: datetime
+ description: Optional[str] = None
+ location_city: Optional[str] = None
+ location_country: Optional[str] = None
+
+
+class FedExBookingResponse(BaseModel):
+ id: int
+ booking_ref: str
+ case_id: int
+ customer_id: Optional[int] = None
+ contact_id: Optional[int] = None
+ service_type: str
+ shipment_status: ShipmentStatus
+ recipient_name: str
+ city: str
+ country_code: str
+ tracking_number: Optional[str] = None
+ label_url: Optional[str] = None
+ dry_run: bool
+ pickup_window_start: datetime
+ pickup_window_end: datetime
+ submitted_at: Optional[datetime] = None
+ created_at: datetime
+ updated_at: datetime
+ packages: List[FedExPackageInput] = Field(default_factory=list)
+
+
+class FedExBookingListResponse(BaseModel):
+ items: List[FedExBookingResponse]
+
+
+class FedExTrackingResponse(BaseModel):
+ booking_ref: str
+ shipment_status: ShipmentStatus
+ tracking_number: Optional[str] = None
+ events: List[FedExTrackingEvent] = Field(default_factory=list)
+
+
+class FedExCancelRequest(BaseModel):
+ reason: Optional[str] = Field(default=None, max_length=400)
+
+
+class FedExCancelResponse(BaseModel):
+ booking_ref: str
+ status: ShipmentStatus
+ cancelled: bool
diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html
index a198623..42011bd 100644
--- a/app/modules/hardware/templates/detail.html
+++ b/app/modules/hardware/templates/detail.html
@@ -541,7 +541,7 @@
{% if cases and cases|length > 0 %}
{% for case in cases[:5] %}
-
+
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index df9fadb..12240a6 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -2415,7 +2415,7 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True):
"message": row.get("message"),
"event_kind": row.get("event_type") or "reminder",
"start": start_value.isoformat(),
- "url": f"/sag/{row['sag_id']}"
+ "url": f"/sag/{row['sag_id']}/v3"
})
for row in case_dates:
@@ -2425,7 +2425,7 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True):
"title": f"Deadline: {row.get('titel')}",
"event_kind": "deadline",
"start": row["deadline"].isoformat(),
- "url": f"/sag/{row['id']}"
+ "url": f"/sag/{row['id']}/v3"
})
if row.get("deferred_until"):
events_by_case[row["id"]].append({
@@ -2433,7 +2433,7 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True):
"title": f"Deferred: {row.get('titel')}",
"event_kind": "deferred",
"start": row["deferred_until"].isoformat(),
- "url": f"/sag/{row['id']}"
+ "url": f"/sag/{row['id']}/v3"
})
current_events = events_by_case.get(sag_id, [])
diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html
index ba5dfa9..659542f 100644
--- a/app/modules/sag/templates/create.html
+++ b/app/modules/sag/templates/create.html
@@ -1035,7 +1035,7 @@
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
setTimeout(() => {
- window.location.href = `/sag/${result.id}`;
+ window.location.href = `/sag/${result.id}/v3`;
}, 1000);
} else {
const errorText = await response.text();
diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html
index bbeeaba..9246c37 100644
--- a/app/modules/sag/templates/detail.html
+++ b/app/modules/sag/templates/detail.html
@@ -3508,7 +3508,7 @@
{{ node.case.titel }}
Aktuel
{% else %}
- {{ node.case.titel }}
+ {{ node.case.titel }}
{% endif %}
|
@@ -14611,7 +14611,7 @@
// Filter: hide pipeline item if case already has one; show "Se pipeline" link instead
const items = QA_ITEMS.filter(i => i.action !== 'pipeline' || !hasPipeline);
const extra = hasPipeline
- ? ` Pipeline (se sagen) `
+ ? `Pipeline (se sagen) `
: '';
if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned
@@ -14660,7 +14660,7 @@
else if (action === 'solution') openRelSolutionModal(caseId, caseTitle);
else if (action === 'sales') openRelSalesModal(caseId, caseTitle);
else if (action === 'subscription') openRelSubscriptionModal(caseId, caseTitle);
- else window.open(`/sag/${caseId}`, '_blank');
+ else window.open(`/sag/${caseId}/v3`, '_blank');
};
// ── Quick Pipeline modal ──────────────────────────────────────────
diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html
index 01f4a92..1cefe68 100644
--- a/app/modules/sag/templates/detail_v3.html
+++ b/app/modules/sag/templates/detail_v3.html
@@ -3693,6 +3693,12 @@
+
+
+
|
@@ -4746,6 +4752,8 @@
} else if (tabId === 'reminders') {
if (typeof loadReminders === 'function') await loadReminders();
if (typeof loadCaseCalendar === 'function') await loadCaseCalendar();
+ } else if (tabId === 'shipping' && typeof loadCaseShippingTab === 'function') {
+ await loadCaseShippingTab();
}
} catch (tabLoadError) {
console.error('Tab data reload failed:', tabLoadError);
@@ -7988,6 +7996,99 @@
+
+
+
@@ -10732,6 +10833,175 @@
});
+
+
@@ -15442,7 +15712,7 @@
// Filter: hide pipeline item if case already has one; show "Se pipeline" link instead
const items = QA_ITEMS.filter(i => i.action !== 'pipeline' || !hasPipeline);
const extra = hasPipeline
- ? ` Pipeline (se sagen) `
+ ? ` Pipeline (se sagen) `
: '';
if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned
@@ -15491,7 +15761,7 @@
else if (action === 'solution') openRelSolutionModal(caseId, caseTitle);
else if (action === 'sales') openRelSalesModal(caseId, caseTitle);
else if (action === 'subscription') openRelSubscriptionModal(caseId, caseTitle);
- else window.open(`/sag/${caseId}`, '_blank');
+ else window.open(`/sag/${caseId}/v3`, '_blank');
};
// ── Quick Pipeline modal ──────────────────────────────────────────
diff --git a/app/modules/sag/templates/edit.html b/app/modules/sag/templates/edit.html
index db243a7..680e03d 100644
--- a/app/modules/sag/templates/edit.html
+++ b/app/modules/sag/templates/edit.html
@@ -238,7 +238,7 @@
@@ -317,7 +317,7 @@
document.getElementById('success').textContent = `âś… Sag opdateret! Omdirigerer...`;
document.getElementById('success').style.display = 'block';
setTimeout(() => {
- window.location.href = `/sag/${caseId}`;
+ window.location.href = `/sag/${caseId}/v3`;
}, 1000);
} else {
const errorText = await response.text();
diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html
index c98692f..8f4f906 100644
--- a/app/modules/sag/templates/index.html
+++ b/app/modules/sag/templates/index.html
@@ -507,22 +507,22 @@
{% endif %}
|
-
+ |
{{ sag.customer_name if sag.customer_name else '-' }}
|
-
+ |
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
|
-
+ |
{{ sag.titel }}
|
-
+ |
{{ sag.template_key or sag.type or 'ticket' }}
|
-
+ |
{{ sag.priority if sag.priority else 'normal' }}
|
-
+ |
{% if sag.ansvarlig_navn %}
{% set owner_name = sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
@@ -536,10 +536,10 @@
-
{% endif %}
|
-
+ |
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
-
+ |
{% if sag.next_todo_title %}
{{ sag.next_todo_title }}
{% if sag.next_todo_due_date %}
@@ -549,16 +549,16 @@
-
{% endif %}
|
-
+ |
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
-
+ |
{{ sag.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
|
-
+ |
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
|
-
+ |
{{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }}
|
@@ -579,25 +579,25 @@
{% endif %}
-
+ |
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
-
+ |
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
|
-
+ |
{% for rt in all_rel_types %}
{{ rt }}
{% endfor %}
{{ related_sag.titel }}
|
-
+ |
{{ related_sag.template_key or related_sag.type or 'ticket' }}
|
-
+ |
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
-
+ |
{% if related_sag.ansvarlig_navn %}
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
@@ -611,10 +611,10 @@
-
{% endif %}
|
-
+ |
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
-
+ |
{% if related_sag.next_todo_title %}
{{ related_sag.next_todo_title }}
{% if related_sag.next_todo_due_date %}
@@ -624,16 +624,16 @@
-
{% endif %}
|
-
+ |
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
-
+ |
{{ related_sag.start_date.strftime('%d/%m-%Y') if related_sag.start_date else '-' }}
|
-
+ |
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
|
-
+ |
{{ related_sag.deadline.strftime('%d/%m-%Y') if related_sag.deadline else '-' }}
|
diff --git a/app/modules/sag/templates/index_old.html b/app/modules/sag/templates/index_old.html
index 7559a5c..c3a059a 100644
--- a/app/modules/sag/templates/index_old.html
+++ b/app/modules/sag/templates/index_old.html
@@ -304,7 +304,7 @@
{% if sager %}
{% for sag in sager %}
-
+
{{ sag.titel }}
{% if sag.beskrivelse %}
{{ sag.beskrivelse[:100] }}{% if sag.beskrivelse|length > 100 %}...{% endif %}
diff --git a/app/modules/sag/templates/varekob_salg.html b/app/modules/sag/templates/varekob_salg.html
index dc6c4f8..eb9f008 100644
--- a/app/modules/sag/templates/varekob_salg.html
+++ b/app/modules/sag/templates/varekob_salg.html
@@ -263,7 +263,7 @@
// Case header row (clickable to expand/collapse)
const caseLink = group.sag_id
- ? `${group.sag_titel} Sag ${group.sag_id}`
+ ? `
${group.sag_titel} Sag ${group.sag_id}`
: `
${group.sag_titel}`;
html += `
diff --git a/app/modules/telefoni/templates/log.html b/app/modules/telefoni/templates/log.html
index ad518c5..b9876d5 100644
--- a/app/modules/telefoni/templates/log.html
+++ b/app/modules/telefoni/templates/log.html
@@ -419,7 +419,7 @@ async function loadCalls() {
const sagHtml = r.sag_id
? `
`
diff --git a/app/opportunities/frontend/opportunities.html b/app/opportunities/frontend/opportunities.html
index 9e447de..2a00990 100644
--- a/app/opportunities/frontend/opportunities.html
+++ b/app/opportunities/frontend/opportunities.html
@@ -272,7 +272,7 @@ function renderTable(data) {
: '
Ingen beskrivelse';
return `
-
+
| ${escapeHtml(o.titel)} |
${escapeHtml(o.customer_name)} |
${stage} |
diff --git a/app/prepaid/frontend/detail.html b/app/prepaid/frontend/detail.html
index 0c1253c..cae40c9 100644
--- a/app/prepaid/frontend/detail.html
+++ b/app/prepaid/frontend/detail.html
@@ -308,7 +308,7 @@ function renderTimelogs(timelogs) {
const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-';
let sourceHtml = '-';
if (t.source === 'sag' && t.source_id) {
- sourceHtml = `Sag #${t.source_id}${t.source_title ? ' - ' + t.source_title : ''}`;
+ sourceHtml = `Sag #${t.source_id}${t.source_title ? ' - ' + t.source_title : ''}`;
} else if (t.source === 'ticket' && t.source_id) {
const ticketLabel = t.ticket_number ? `#${t.ticket_number}` : `#${t.source_id}`;
sourceHtml = `${ticketLabel} - ${t.source_title || 'Ticket'}`;
diff --git a/app/services/reminder_notification_service.py b/app/services/reminder_notification_service.py
index ee1e98d..d561831 100644
--- a/app/services/reminder_notification_service.py
+++ b/app/services/reminder_notification_service.py
@@ -265,7 +265,7 @@ class ReminderNotificationService:
'text': f'đź”” **{title}**',
'attachments': [{
'title': case_title,
- 'title_link': f"http://localhost:8000/sag/{case_id}",
+ 'title_link': f"http://localhost:8001/sag/{case_id}/v3",
'text': message or additional_info or 'Se reminder i systemet',
'color': color_map.get(priority, color_map['normal']),
'fields': [
@@ -284,7 +284,7 @@ class ReminderNotificationService:
'name': 'Ă…bn sag',
'type': 'button',
'text': 'Se mere',
- 'url': f"http://localhost:8000/sag/{case_id}"
+ 'url': f"http://localhost:8001/sag/{case_id}/v3"
}]
}]
}
@@ -337,7 +337,7 @@ class ReminderNotificationService:
'deadline': deadline,
'assigned_user': assigned_user or 'Ikke tildelt',
'additional_info': additional_info or '',
- 'action_url': f"http://localhost:8000/sag/{case_id}",
+ 'action_url': f"http://localhost:8001/sag/{case_id}/v3",
'footer_date': datetime.now().strftime("%d. %B %Y")
}
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 469f945..65593e5 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -1568,7 +1568,7 @@ window.addEventListener('unhandledrejection', function(event) {
return false;
}
- window.location.href = `/sag/${sagId}`;
+ window.location.href = `/sag/${sagId}/v3`;
return true;
}
diff --git a/app/shared/frontend/quick_create_modal.html b/app/shared/frontend/quick_create_modal.html
index 0e0d5be..ba93418 100644
--- a/app/shared/frontend/quick_create_modal.html
+++ b/app/shared/frontend/quick_create_modal.html
@@ -558,7 +558,7 @@
// TODO: Add tags via separate endpoint if any exist
// Redirect to case detail
- window.location.href = `/sag/${newCase.id}`;
+ window.location.href = `/sag/${newCase.id}/v3`;
} catch (error) {
console.error('Error creating case:', error);
diff --git a/app/subscriptions/frontend/list.html b/app/subscriptions/frontend/list.html
index a702a83..1454f7c 100644
--- a/app/subscriptions/frontend/list.html
+++ b/app/subscriptions/frontend/list.html
@@ -327,7 +327,7 @@ async function loadStagingOverview() {
const hubCustomer = row.hub_customer_name
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
: (row.hub_customer_id ? `#${row.hub_customer_id}` : '-');
- const sag = row.hub_sag_id ? `#${row.hub_sag_id}` : '-';
+ const sag = row.hub_sag_id ? `#${row.hub_sag_id}` : '-';
return `
| ${row.id} |
@@ -561,7 +561,7 @@ function renderSubscriptions(subscriptions) {
tbody.innerHTML = subscriptions.map(sub => {
const intervalLabel = formatInterval(sub.billing_interval);
const statusBadge = getStatusBadge(sub.status);
- const sagLink = sub.sag_id ? `${sub.sag_title || 'Sag #' + sub.sag_id}` : '-';
+ const sagLink = sub.sag_id ? `${sub.sag_title || 'Sag #' + sub.sag_id}` : '-';
const subNumber = sub.subscription_number || `#${sub.id}`;
// Show product name with item count if available
diff --git a/app/subscriptions/frontend/list_backup.html b/app/subscriptions/frontend/list_backup.html
index 6732fa2..d01d7d2 100644
--- a/app/subscriptions/frontend/list_backup.html
+++ b/app/subscriptions/frontend/list_backup.html
@@ -129,7 +129,7 @@ function renderSubscriptions(subscriptions) {
tbody.innerHTML = subscriptions.map(sub => {
const intervalLabel = formatInterval(sub.billing_interval);
const statusBadge = getStatusBadge(sub.status);
- const sagLink = sub.sag_id ? `${sub.sag_title || 'Sag #' + sub.sag_id}` : '-';
+ const sagLink = sub.sag_id ? `${sub.sag_title || 'Sag #' + sub.sag_id}` : '-';
const subNumber = sub.subscription_number || `#${sub.id}`;
return `
diff --git a/app/subscriptions/frontend/simply_imports.html b/app/subscriptions/frontend/simply_imports.html
index ab8c2c3..d2f8be8 100644
--- a/app/subscriptions/frontend/simply_imports.html
+++ b/app/subscriptions/frontend/simply_imports.html
@@ -236,7 +236,7 @@ async function loadStagingOverview() {
const hubCustomer = row.hub_customer_name
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
: (row.hub_customer_id ? `#${row.hub_customer_id}` : '-');
- const sag = row.hub_sag_id ? `#${row.hub_sag_id}` : '-';
+ const sag = row.hub_sag_id ? `#${row.hub_sag_id}` : '-';
return `
| ${row.id} |
diff --git a/app/tags/backend/router.py b/app/tags/backend/router.py
index 5da1602..d0e5567 100644
--- a/app/tags/backend/router.py
+++ b/app/tags/backend/router.py
@@ -52,7 +52,7 @@ def _entity_reference_payload(entity_type: Optional[str], entity_id: Optional[in
)
if row:
title = str(row.get("titel") or "Sag").strip()
- return {"entity_title": title, "entity_url": f"/sag/{eid}"}
+ return {"entity_title": title, "entity_url": f"/sag/{eid}/v3"}
elif etype == "email":
row = execute_query_single(
diff --git a/app/ticket/frontend/mockups/tech_v1_overview.html b/app/ticket/frontend/mockups/tech_v1_overview.html
index 9485124..7d71ecd 100644
--- a/app/ticket/frontend/mockups/tech_v1_overview.html
+++ b/app/ticket/frontend/mockups/tech_v1_overview.html
@@ -408,7 +408,7 @@ async function showCaseDetails(id, type) {
document.getElementById('modulePills').style.display = 'none';
document.getElementById('contactRow').style.display = 'none';
document.getElementById('kommentarFeed').innerHTML = '';
- document.getElementById('detailOpenBtn').href = type === 'case' ? `/sag/${id}` : `/ticket/tickets/${id}`;
+ document.getElementById('detailOpenBtn').href = type === 'case' ? `/sag/${id}/v3` : `/ticket/tickets/${id}`;
window._currentDetailId = id;
window._currentDetailType = type;
diff --git a/app/ticket/frontend/mockups/tech_v2_workboard.html b/app/ticket/frontend/mockups/tech_v2_workboard.html
index 843e83d..2bada70 100644
--- a/app/ticket/frontend/mockups/tech_v2_workboard.html
+++ b/app/ticket/frontend/mockups/tech_v2_workboard.html
@@ -48,7 +48,7 @@
#{{ item.id }} · {{ item.titel }}
{{ item.customer_name }} · Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}
-
Ă…bn
+
Ă…bn
{% else %}
Ingen aktive sager.
@@ -103,7 +103,7 @@
{% for item in new_cases %}
-
+
| #{{ item.id }} |
{{ item.customer_name or '-' }} |
{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }} |
@@ -138,7 +138,7 @@
{{ item.titel }}
{{ item.customer_name }} · {{ item.pipeline_stage or 'Uden stage' }}
Sandsynlighed: {{ "%.0f"|format(item.pipeline_probability or 0) }}%
- Ă…bn
+ Ă…bn
{% else %}
Ingen opportunities.
diff --git a/main.py b/main.py
index 7505e26..a8f9f0b 100644
--- a/main.py
+++ b/main.py
@@ -131,6 +131,7 @@ from app.modules.calendar.backend import router as calendar_api
from app.modules.calendar.frontend import views as calendar_views
from app.modules.orders.backend import router as orders_api
from app.modules.orders.frontend import views as orders_views
+from app.modules.fedex.backend import router as fedex_api
from app.modules.manual.backend import router as manual_api
from app.modules.manual.frontend import views as manual_views
from app.modules.bottom_bar.backend import router as bottom_bar_api
@@ -448,6 +449,7 @@ app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devp
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
+app.include_router(fedex_api.router, prefix="/api/v1", tags=["FedEx"])
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
diff --git a/migrations/181_fedex_shipments.sql b/migrations/181_fedex_shipments.sql
new file mode 100644
index 0000000..eea534b
--- /dev/null
+++ b/migrations/181_fedex_shipments.sql
@@ -0,0 +1,96 @@
+-- Migration 181: FedEx shipments foundation (case-linked)
+
+CREATE TABLE IF NOT EXISTS fedex_shipments (
+ id SERIAL PRIMARY KEY,
+ booking_ref VARCHAR(64) NOT NULL UNIQUE,
+ case_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
+ customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
+
+ service_type VARCHAR(32) NOT NULL,
+ shipment_status VARCHAR(32) NOT NULL DEFAULT 'draft',
+
+ pickup_window_start TIMESTAMP NOT NULL,
+ pickup_window_end TIMESTAMP NOT NULL,
+
+ recipient_name VARCHAR(150) NOT NULL,
+ company_name VARCHAR(150),
+ address_line1 VARCHAR(200) NOT NULL,
+ address_line2 VARCHAR(200),
+ postal_code VARCHAR(20) NOT NULL,
+ city VARCHAR(120) NOT NULL,
+ country_code VARCHAR(2) NOT NULL,
+ phone VARCHAR(50),
+ email VARCHAR(150),
+
+ tracking_number VARCHAR(64),
+ label_url TEXT,
+ cancel_reason TEXT,
+
+ dry_run BOOLEAN NOT NULL DEFAULT FALSE,
+ submitted_at TIMESTAMP,
+
+ api_payload JSONB,
+ api_response JSONB,
+
+ created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
+ updated_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP,
+
+ CONSTRAINT fedex_shipments_service_type_check CHECK (service_type IN ('PRIORITY', 'ECONOMY')),
+ CONSTRAINT fedex_shipments_status_check CHECK (
+ shipment_status IN ('draft', 'submitted', 'booked', 'in_transit', 'delivered', 'cancelled', 'failed')
+ ),
+ CONSTRAINT fedex_shipments_pickup_window_check CHECK (pickup_window_end > pickup_window_start)
+);
+
+CREATE TABLE IF NOT EXISTS fedex_shipment_packages (
+ id SERIAL PRIMARY KEY,
+ shipment_id INTEGER NOT NULL REFERENCES fedex_shipments(id) ON DELETE CASCADE,
+ weight_kg NUMERIC(10,3) NOT NULL,
+ length_cm NUMERIC(10,2) NOT NULL,
+ width_cm NUMERIC(10,2) NOT NULL,
+ height_cm NUMERIC(10,2) NOT NULL,
+ description VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT fedex_package_weight_positive CHECK (weight_kg > 0),
+ CONSTRAINT fedex_package_length_positive CHECK (length_cm > 0),
+ CONSTRAINT fedex_package_width_positive CHECK (width_cm > 0),
+ CONSTRAINT fedex_package_height_positive CHECK (height_cm > 0)
+);
+
+CREATE TABLE IF NOT EXISTS fedex_tracking_events (
+ id SERIAL PRIMARY KEY,
+ shipment_id INTEGER NOT NULL REFERENCES fedex_shipments(id) ON DELETE CASCADE,
+ status VARCHAR(64) NOT NULL,
+ description TEXT,
+ event_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ location_city VARCHAR(120),
+ location_country VARCHAR(2),
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_fedex_shipments_case_id ON fedex_shipments(case_id);
+CREATE INDEX IF NOT EXISTS idx_fedex_shipments_status ON fedex_shipments(shipment_status);
+CREATE INDEX IF NOT EXISTS idx_fedex_shipments_tracking_number ON fedex_shipments(tracking_number);
+CREATE INDEX IF NOT EXISTS idx_fedex_shipments_created_at ON fedex_shipments(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_fedex_packages_shipment_id ON fedex_shipment_packages(shipment_id);
+CREATE INDEX IF NOT EXISTS idx_fedex_tracking_shipment_id ON fedex_tracking_events(shipment_id);
+CREATE INDEX IF NOT EXISTS idx_fedex_tracking_event_timestamp ON fedex_tracking_events(event_timestamp DESC);
+
+CREATE OR REPLACE FUNCTION update_fedex_shipments_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS fedex_shipments_updated_at_trigger ON fedex_shipments;
+CREATE TRIGGER fedex_shipments_updated_at_trigger
+ BEFORE UPDATE ON fedex_shipments
+ FOR EACH ROW
+ EXECUTE FUNCTION update_fedex_shipments_updated_at();