Compare commits

..

No commits in common. "main" and "v2.2.65" have entirely different histories.

69 changed files with 973 additions and 13508 deletions

View File

@ -74,8 +74,6 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
requires_2fa_setup = ( requires_2fa_setup = (
not user.get("is_shadow_admin", False) not user.get("is_shadow_admin", False)
and not settings.AUTH_DISABLE_2FA
and AuthService.is_2fa_supported()
and not user.get("is_2fa_enabled", False) and not user.get("is_2fa_enabled", False)
) )
@ -141,18 +139,10 @@ async def setup_2fa(current_user: dict = Depends(get_current_user)):
detail="Shadow admin cannot configure 2FA", detail="Shadow admin cannot configure 2FA",
) )
try: result = AuthService.setup_user_2fa(
result = AuthService.setup_user_2fa( user_id=current_user["id"],
user_id=current_user["id"], username=current_user["username"]
username=current_user["username"] )
)
except RuntimeError as exc:
if "2FA columns missing" in str(exc):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA er ikke tilgaengelig i denne database (mangler kolonner).",
)
raise
return result return result

View File

@ -25,26 +25,8 @@ class BackupService:
"""Service for managing backup operations""" """Service for managing backup operations"""
def __init__(self): def __init__(self):
configured_backup_dir = Path(settings.BACKUP_STORAGE_PATH) self.backup_dir = Path(settings.BACKUP_STORAGE_PATH)
self.backup_dir = configured_backup_dir self.backup_dir.mkdir(parents=True, exist_ok=True)
try:
self.backup_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
# Local development can run outside Docker where /app is not writable.
# Fall back to the workspace data path so app startup does not fail.
if str(configured_backup_dir).startswith('/app/'):
project_root = Path(__file__).resolve().parents[3]
fallback_dir = project_root / 'data' / 'backups'
logger.warning(
"⚠️ Backup path %s not writable (%s). Using fallback %s",
configured_backup_dir,
exc,
fallback_dir,
)
fallback_dir.mkdir(parents=True, exist_ok=True)
self.backup_dir = fallback_dir
else:
raise
# Subdirectories for different backup types # Subdirectories for different backup types
self.db_dir = self.backup_dir / "database" self.db_dir = self.backup_dir / "database"

View File

@ -3,14 +3,7 @@ Billing Router
API endpoints for billing operations API endpoints for billing operations
""" """
from fastapi import APIRouter, HTTPException from fastapi import APIRouter
from typing import Any, Dict, List
from datetime import datetime, date
import json
from dateutil.relativedelta import relativedelta
from app.core.database import execute_query, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
from app.jobs.reconcile_ordre_drafts import reconcile_ordre_drafts_sync_status
from . import supplier_invoices from . import supplier_invoices
router = APIRouter() router = APIRouter()
@ -19,83 +12,6 @@ router = APIRouter()
router.include_router(supplier_invoices.router, prefix="", tags=["Supplier Invoices"]) router.include_router(supplier_invoices.router, prefix="", tags=["Supplier Invoices"])
@router.get("/billing/drafts/sync-dashboard")
async def get_draft_sync_dashboard(limit: int = 20):
"""Operational dashboard data for ordre draft sync lifecycle."""
try:
summary = execute_query(
"""
SELECT
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
COUNT(*) FILTER (WHERE sync_status = 'paid') AS paid_count,
COUNT(*) AS total_count
FROM ordre_drafts
""",
(),
) or []
attention = execute_query(
"""
SELECT
d.id,
d.title,
d.customer_id,
d.sync_status,
d.economic_order_number,
d.economic_invoice_number,
d.last_sync_at,
d.updated_at,
ev.event_type AS latest_event_type,
ev.created_at AS latest_event_at
FROM ordre_drafts d
LEFT JOIN LATERAL (
SELECT event_type, created_at
FROM ordre_draft_sync_events
WHERE draft_id = d.id
ORDER BY created_at DESC, id DESC
LIMIT 1
) ev ON TRUE
WHERE d.sync_status IN ('pending', 'failed')
ORDER BY d.updated_at DESC
LIMIT %s
""",
(max(1, min(limit, 200)),),
) or []
recent_events = execute_query(
"""
SELECT
ev.id,
ev.draft_id,
ev.event_type,
ev.from_status,
ev.to_status,
ev.event_payload,
ev.created_by_user_id,
ev.created_at,
d.title AS draft_title,
d.customer_id,
d.sync_status
FROM ordre_draft_sync_events ev
JOIN ordre_drafts d ON d.id = ev.draft_id
ORDER BY ev.created_at DESC, ev.id DESC
LIMIT %s
""",
(max(1, min(limit, 200)),),
) or []
return {
"summary": summary[0] if summary else {},
"attention_items": attention,
"recent_events": recent_events,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to load sync dashboard: {e}")
@router.get("/billing/invoices") @router.get("/billing/invoices")
async def list_invoices(): async def list_invoices():
"""List all invoices""" """List all invoices"""
@ -106,390 +22,3 @@ async def list_invoices():
async def sync_to_economic(): async def sync_to_economic():
"""Sync data to e-conomic""" """Sync data to e-conomic"""
return {"message": "e-conomic sync coming soon"} return {"message": "e-conomic sync coming soon"}
def _to_date(value: Any) -> date | None:
if value is None:
return None
if isinstance(value, date):
return value
if isinstance(value, datetime):
return value.date()
text = str(value).strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).date()
except ValueError:
return None
def _next_period(start: date, interval: str) -> date:
normalized = (interval or "monthly").strip().lower()
if normalized == "daily":
return start + relativedelta(days=1)
if normalized == "biweekly":
return start + relativedelta(weeks=2)
if normalized == "quarterly":
return start + relativedelta(months=3)
if normalized == "yearly":
return start + relativedelta(years=1)
return start + relativedelta(months=1)
@router.post("/billing/subscriptions/preview")
async def preview_subscription_billing(payload: Dict[str, Any]):
"""
Preview aggregated customer billing from due subscriptions.
Generates prorata suggestions for approved-but-not-applied price changes.
"""
try:
as_of = _to_date(payload.get("as_of")) or date.today()
customer_id = payload.get("customer_id")
where = ["s.status = 'active'", "s.next_invoice_date <= %s", "COALESCE(s.billing_blocked, false) = false"]
params: List[Any] = [as_of]
if customer_id:
where.append("s.customer_id = %s")
params.append(customer_id)
subscriptions = execute_query(
f"""
SELECT
s.id,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_direction,
s.invoice_merge_key,
s.next_invoice_date,
s.period_start,
s.price,
COALESCE(
(
SELECT json_agg(
json_build_object(
'id', i.id,
'description', i.description,
'quantity', i.quantity,
'unit_price', i.unit_price,
'line_total', i.line_total,
'asset_id', i.asset_id,
'period_from', i.period_from,
'period_to', i.period_to,
'billing_blocked', i.billing_blocked
) ORDER BY i.line_no ASC, i.id ASC
)
FROM sag_subscription_items i
WHERE i.subscription_id = s.id
),
'[]'::json
) AS line_items
FROM sag_subscriptions s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE {' AND '.join(where)}
ORDER BY s.customer_id, s.next_invoice_date, s.id
""",
tuple(params),
) or []
groups: Dict[str, Dict[str, Any]] = {}
for sub in subscriptions:
merge_key = sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}"
key = f"{sub['customer_id']}|{merge_key}|{sub.get('billing_direction') or 'forward'}|{sub.get('next_invoice_date')}"
grp = groups.setdefault(
key,
{
"customer_id": sub["customer_id"],
"customer_name": sub.get("customer_name"),
"merge_key": merge_key,
"billing_direction": sub.get("billing_direction") or "forward",
"invoice_date": str(sub.get("next_invoice_date")),
"coverage_start": None,
"coverage_end": None,
"subscription_ids": [],
"line_count": 0,
"amount_total": 0.0,
},
)
sub_id = int(sub["id"])
grp["subscription_ids"].append(sub_id)
start = _to_date(sub.get("period_start") or sub.get("next_invoice_date")) or as_of
end = _next_period(start, sub.get("billing_interval") or "monthly")
grp["coverage_start"] = str(start) if grp["coverage_start"] is None or str(start) < grp["coverage_start"] else grp["coverage_start"]
grp["coverage_end"] = str(end) if grp["coverage_end"] is None or str(end) > grp["coverage_end"] else grp["coverage_end"]
for item in sub.get("line_items") or []:
if item.get("billing_blocked"):
continue
grp["line_count"] += 1
grp["amount_total"] += float(item.get("line_total") or 0)
price_changes = execute_query(
"""
SELECT
spc.id,
spc.subscription_id,
spc.subscription_item_id,
spc.old_unit_price,
spc.new_unit_price,
spc.effective_date,
spc.approval_status,
spc.reason,
s.period_start,
s.billing_interval
FROM subscription_price_changes spc
JOIN sag_subscriptions s ON s.id = spc.subscription_id
WHERE spc.deleted_at IS NULL
AND spc.approval_status IN ('approved', 'pending')
AND spc.effective_date <= %s
ORDER BY spc.effective_date ASC, spc.id ASC
""",
(as_of,),
) or []
prorata_suggestions: List[Dict[str, Any]] = []
for change in price_changes:
period_start = _to_date(change.get("period_start"))
if not period_start:
continue
period_end = _next_period(period_start, change.get("billing_interval") or "monthly")
eff = _to_date(change.get("effective_date"))
if not eff:
continue
if eff <= period_start or eff >= period_end:
continue
total_days = max((period_end - period_start).days, 1)
remaining_days = max((period_end - eff).days, 0)
old_price = float(change.get("old_unit_price") or 0)
new_price = float(change.get("new_unit_price") or 0)
delta = new_price - old_price
prorata_amount = round(delta * (remaining_days / total_days), 2)
if prorata_amount == 0:
continue
prorata_suggestions.append(
{
"price_change_id": change.get("id"),
"subscription_id": change.get("subscription_id"),
"subscription_item_id": change.get("subscription_item_id"),
"effective_date": str(eff),
"period_start": str(period_start),
"period_end": str(period_end),
"old_unit_price": old_price,
"new_unit_price": new_price,
"remaining_days": remaining_days,
"total_days": total_days,
"suggested_adjustment": prorata_amount,
"adjustment_type": "debit" if prorata_amount > 0 else "credit",
"reason": change.get("reason"),
"requires_manual_approval": True,
}
)
return {
"status": "preview",
"as_of": str(as_of),
"group_count": len(groups),
"groups": list(groups.values()),
"prorata_suggestions": prorata_suggestions,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to preview subscription billing: {e}")
@router.post("/billing/prorata-adjustments/draft")
async def create_prorata_adjustment_draft(payload: Dict[str, Any]):
"""
Create a manual adjustment draft from an approved prorata suggestion.
Payload expects customer_id, subscription_id, amount, reason and optional effective dates.
"""
conn = get_db_connection()
try:
customer_id = payload.get("customer_id")
subscription_id = payload.get("subscription_id")
amount = float(payload.get("amount") or 0)
reason = (payload.get("reason") or "Prorata justering").strip()
effective_date = _to_date(payload.get("effective_date")) or date.today()
period_start = _to_date(payload.get("period_start"))
period_end = _to_date(payload.get("period_end"))
if not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required")
if not subscription_id:
raise HTTPException(status_code=400, detail="subscription_id is required")
if amount == 0:
raise HTTPException(status_code=400, detail="amount must be non-zero")
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
SELECT id, customer_id, product_name
FROM sag_subscriptions
WHERE id = %s
""",
(subscription_id,),
)
sub = cursor.fetchone()
if not sub:
raise HTTPException(status_code=404, detail="Subscription not found")
if int(sub.get("customer_id") or 0) != int(customer_id):
raise HTTPException(status_code=400, detail="customer_id mismatch for subscription")
adjustment_label = "Prorata tillæg" if amount > 0 else "Prorata kredit"
line = {
"product": {
"productNumber": "PRORATA",
"description": f"{adjustment_label}: {sub.get('product_name') or 'Abonnement'}"
},
"quantity": 1,
"unitNetPrice": amount,
"totalNetAmount": amount,
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"effective_date": str(effective_date),
"period_start": str(period_start) if period_start else None,
"period_end": str(period_end) if period_end else None,
"reason": reason,
"manual_approval": True,
}
}
cursor.execute(
"""
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
coverage_start,
coverage_end,
billing_direction,
source_subscription_ids,
invoice_aggregate_key,
layout_number,
created_by_user_id,
sync_status,
export_status_json,
updated_at
) VALUES (
%s, %s, %s::jsonb, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP
)
RETURNING id, created_at
""",
(
f"Manuel {adjustment_label}",
customer_id,
json.dumps([line], ensure_ascii=False),
reason,
period_start,
period_end,
"backward",
[subscription_id],
f"manual-prorata-{customer_id}",
1,
payload.get("created_by_user_id"),
"pending",
json.dumps(
{
"source": "prorata_manual",
"subscription_id": subscription_id,
"effective_date": str(effective_date),
},
ensure_ascii=False,
),
),
)
created = cursor.fetchone()
conn.commit()
return {
"status": "draft_created",
"draft_id": created.get("id") if created else None,
"created_at": created.get("created_at") if created else None,
"subscription_id": subscription_id,
"amount": amount,
}
except HTTPException:
conn.rollback()
raise
except Exception as e:
conn.rollback()
raise HTTPException(status_code=500, detail=f"Failed to create prorata adjustment draft: {e}")
finally:
release_db_connection(conn)
@router.post("/billing/drafts/reconcile-sync-status")
async def reconcile_draft_sync_status(payload: Dict[str, Any]):
"""
Reconcile ordre_drafts sync_status from known economic references.
Rules:
- pending/failed + economic_order_number -> exported
- exported + economic_invoice_number -> posted
- posted + mark_paid_ids contains draft id -> paid
"""
try:
apply_changes = bool(payload.get("apply", False))
result = await reconcile_ordre_drafts_sync_status(apply_changes=apply_changes)
mark_paid_ids = set(int(x) for x in (payload.get("mark_paid_ids") or []) if str(x).isdigit())
if apply_changes and mark_paid_ids:
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
for draft_id in mark_paid_ids:
cursor.execute("SELECT sync_status FROM ordre_drafts WHERE id = %s", (draft_id,))
before = cursor.fetchone()
from_status = (before or {}).get("sync_status")
cursor.execute(
"""
UPDATE ordre_drafts
SET sync_status = 'paid',
last_sync_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
last_exported_at = CURRENT_TIMESTAMP
WHERE id = %s
AND sync_status = 'posted'
RETURNING id
""",
(draft_id,),
)
updated = cursor.fetchone()
if updated:
cursor.execute(
"""
INSERT INTO ordre_draft_sync_events (
draft_id,
event_type,
from_status,
to_status,
event_payload,
created_by_user_id
) VALUES (%s, %s, %s, %s, %s::jsonb, NULL)
""",
(
draft_id,
'sync_status_manual_paid',
from_status,
'paid',
'{"source":"billing_reconcile_endpoint"}',
),
)
conn.commit()
finally:
release_db_connection(conn)
if mark_paid_ids:
result["mark_paid_ids"] = sorted(mark_paid_ids)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to reconcile draft sync status: {e}")

View File

@ -110,9 +110,6 @@
<p class="text-muted mb-0">Kassekladde - Integration med e-conomic</p> <p class="text-muted mb-0">Kassekladde - Integration med e-conomic</p>
</div> </div>
<div> <div>
<a href="/billing/sync-dashboard" class="btn btn-outline-dark me-2">
<i class="bi bi-activity me-2"></i>Sync Dashboard
</a>
<a href="/billing/templates" class="btn btn-outline-secondary me-2"> <a href="/billing/templates" class="btn btn-outline-secondary me-2">
<i class="bi bi-grid-3x3 me-2"></i>Se Templates <i class="bi bi-grid-3x3 me-2"></i>Se Templates
</a> </a>

View File

@ -1,408 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Sync Dashboard - BMC Hub{% endblock %}
{% block extra_css %}
<style>
:root {
--sync-accent: #0f4c75;
--sync-accent-soft: rgba(15, 76, 117, 0.1);
--sync-ok: #2f855a;
--sync-warn: #c05621;
--sync-danger: #c53030;
}
.sync-header {
background: linear-gradient(130deg, rgba(15, 76, 117, 0.14), rgba(22, 160, 133, 0.08));
border: 1px solid rgba(15, 76, 117, 0.15);
border-radius: 16px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.sync-kpi {
border-radius: 14px;
border: 1px solid var(--border-color);
background: var(--bg-card);
padding: 1rem;
height: 100%;
}
.sync-kpi .label {
color: var(--text-secondary);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sync-kpi .value {
font-size: 1.8rem;
font-weight: 700;
line-height: 1.1;
}
.sync-kpi.pending .value { color: var(--sync-warn); }
.sync-kpi.failed .value { color: var(--sync-danger); }
.sync-kpi.posted .value { color: var(--sync-accent); }
.sync-kpi.paid .value { color: var(--sync-ok); }
.status-badge {
padding: 0.3rem 0.55rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-pending { background: rgba(192, 86, 33, 0.14); color: var(--sync-warn); }
.status-exported { background: rgba(15, 76, 117, 0.14); color: var(--sync-accent); }
.status-failed { background: rgba(197, 48, 48, 0.14); color: var(--sync-danger); }
.status-posted { background: rgba(22, 101, 52, 0.14); color: #166534; }
.status-paid { background: rgba(47, 133, 90, 0.14); color: var(--sync-ok); }
.table thead th {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.85rem;
}
.event-card {
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 0.75rem;
background: var(--bg-card);
}
[data-bs-theme="dark"] .sync-header {
background: linear-gradient(130deg, rgba(61, 139, 253, 0.14), rgba(44, 62, 80, 0.3));
border-color: rgba(61, 139, 253, 0.25);
}
</style>
{% endblock %}
{% block content %}
<div class="sync-header d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<h2 class="mb-1">Draft Sync Dashboard</h2>
<p class="text-muted mb-0">Overblik over ordre-draft sync, attention queue og seneste events.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" id="btnPreviewReconcile">
<i class="bi bi-search me-1"></i>Preview Reconcile
</button>
<button class="btn btn-primary" id="btnApplyReconcile">
<i class="bi bi-arrow-repeat me-1"></i>Kør Reconcile
</button>
</div>
</div>
<div class="row g-3 mb-4" id="kpiRow">
<div class="col-6 col-lg-2">
<div class="sync-kpi">
<div class="label">Total</div>
<div class="value" id="kpiTotal">0</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="sync-kpi pending">
<div class="label">Pending</div>
<div class="value" id="kpiPending">0</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="sync-kpi">
<div class="label">Exported</div>
<div class="value" id="kpiExported">0</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="sync-kpi failed">
<div class="label">Failed</div>
<div class="value" id="kpiFailed">0</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="sync-kpi posted">
<div class="label">Posted</div>
<div class="value" id="kpiPosted">0</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="sync-kpi paid">
<div class="label">Paid</div>
<div class="value" id="kpiPaid">0</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-7">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Attention Items</h5>
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshAttention">Opdater</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Draft</th>
<th>Status</th>
<th>Order</th>
<th>Invoice</th>
<th>Seneste Event</th>
<th></th>
</tr>
</thead>
<tbody id="attentionBody">
<tr><td colspan="6" class="text-center py-4 text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Recent Events</h5>
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshEvents">Opdater</button>
</div>
<div class="card-body" id="recentEventsList">
<div class="text-muted">Indlæser...</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="eventsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Draft Events</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-3">
<input class="form-control form-control-sm" id="filterEventType" placeholder="event_type">
</div>
<div class="col-md-3">
<input class="form-control form-control-sm" id="filterFromStatus" placeholder="from_status">
</div>
<div class="col-md-3">
<input class="form-control form-control-sm" id="filterToStatus" placeholder="to_status">
</div>
<div class="col-md-3 d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" id="btnApplyEventFilters">Filtrer</button>
<button class="btn btn-sm btn-outline-secondary" id="btnClearEventFilters">Nulstil</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Tid</th>
<th>Event</th>
<th>Fra</th>
<th>Til</th>
<th>Payload</th>
</tr>
</thead>
<tbody id="eventsModalBody"></tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<small class="text-muted" id="eventsPagerInfo"></small>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" id="btnPrevEvents">Forrige</button>
<button class="btn btn-outline-secondary" id="btnNextEvents">Næste</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(() => {
const state = {
selectedDraftId: null,
eventsLimit: 20,
eventsOffset: 0,
eventsTotal: 0,
};
const el = (id) => document.getElementById(id);
const statusBadge = (status) => {
const s = (status || '').toLowerCase();
return `<span class="status-badge status-${s || 'pending'}">${s || 'pending'}</span>`;
};
const fetchJson = async (url, options = {}) => {
const res = await fetch(url, options);
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return res.json();
};
const loadDashboard = async () => {
const data = await fetchJson('/api/v1/billing/drafts/sync-dashboard?limit=20');
const summary = data.summary || {};
el('kpiTotal').textContent = summary.total_count || 0;
el('kpiPending').textContent = summary.pending_count || 0;
el('kpiExported').textContent = summary.exported_count || 0;
el('kpiFailed').textContent = summary.failed_count || 0;
el('kpiPosted').textContent = summary.posted_count || 0;
el('kpiPaid').textContent = summary.paid_count || 0;
const attention = data.attention_items || [];
const tbody = el('attentionBody');
if (!attention.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Ingen attention items</td></tr>';
} else {
tbody.innerHTML = attention.map(row => `
<tr>
<td>
<div class="fw-semibold">#${row.id} ${row.title || ''}</div>
<div class="text-muted small">Kunde ${row.customer_id || '-'}</div>
</td>
<td>${statusBadge(row.sync_status)}</td>
<td class="mono">${row.economic_order_number || '-'}</td>
<td class="mono">${row.economic_invoice_number || '-'}</td>
<td>
<div class="small">${row.latest_event_type || '-'}</div>
<div class="text-muted small">${row.latest_event_at || ''}</div>
</td>
<td>
<button class="btn btn-sm btn-outline-primary" data-open-events="${row.id}">Events</button>
</td>
</tr>
`).join('');
}
const recent = data.recent_events || [];
const list = el('recentEventsList');
if (!recent.length) {
list.innerHTML = '<div class="text-muted">Ingen events endnu.</div>';
} else {
list.innerHTML = recent.map(ev => `
<div class="event-card mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<strong>#${ev.draft_id} ${ev.event_type}</strong>
${statusBadge(ev.to_status || ev.sync_status || 'pending')}
</div>
<div class="small text-muted">${ev.created_at || ''}</div>
<div class="small">${ev.draft_title || ''}</div>
</div>
`).join('');
}
};
const runReconcile = async (applyChanges) => {
await fetchJson('/api/v1/billing/drafts/reconcile-sync-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apply: applyChanges }),
});
await loadDashboard();
};
const loadEventsForDraft = async () => {
if (!state.selectedDraftId) return;
const qs = new URLSearchParams({
limit: String(state.eventsLimit),
offset: String(state.eventsOffset),
});
const eventType = el('filterEventType').value.trim();
const fromStatus = el('filterFromStatus').value.trim();
const toStatus = el('filterToStatus').value.trim();
if (eventType) qs.set('event_type', eventType);
if (fromStatus) qs.set('from_status', fromStatus);
if (toStatus) qs.set('to_status', toStatus);
const data = await fetchJson(`/api/v1/ordre/drafts/${state.selectedDraftId}/sync-events?${qs.toString()}`);
const items = data.items || [];
state.eventsTotal = data.total || 0;
const body = el('eventsModalBody');
body.innerHTML = items.map(ev => `
<tr>
<td class="small">${ev.created_at || ''}</td>
<td class="mono">${ev.event_type || ''}</td>
<td>${ev.from_status || '-'}</td>
<td>${ev.to_status || '-'}</td>
<td><pre class="small mb-0 mono">${JSON.stringify(ev.event_payload || {}, null, 2)}</pre></td>
</tr>
`).join('') || '<tr><td colspan="5" class="text-center text-muted py-3">Ingen events</td></tr>';
const start = state.eventsOffset + 1;
const end = Math.min(state.eventsOffset + state.eventsLimit, state.eventsTotal);
el('eventsPagerInfo').textContent = state.eventsTotal ? `${start}-${end} af ${state.eventsTotal}` : '0 resultater';
el('btnPrevEvents').disabled = state.eventsOffset <= 0;
el('btnNextEvents').disabled = (state.eventsOffset + state.eventsLimit) >= state.eventsTotal;
};
document.addEventListener('click', async (e) => {
const target = e.target;
if (target.matches('[data-open-events]')) {
state.selectedDraftId = Number(target.getAttribute('data-open-events'));
state.eventsOffset = 0;
await loadEventsForDraft();
const modal = new bootstrap.Modal(el('eventsModal'));
modal.show();
}
});
el('btnRefreshAttention').addEventListener('click', loadDashboard);
el('btnRefreshEvents').addEventListener('click', loadDashboard);
el('btnPreviewReconcile').addEventListener('click', async () => runReconcile(false));
el('btnApplyReconcile').addEventListener('click', async () => runReconcile(true));
el('btnApplyEventFilters').addEventListener('click', async () => {
state.eventsOffset = 0;
await loadEventsForDraft();
});
el('btnClearEventFilters').addEventListener('click', async () => {
el('filterEventType').value = '';
el('filterFromStatus').value = '';
el('filterToStatus').value = '';
state.eventsOffset = 0;
await loadEventsForDraft();
});
el('btnPrevEvents').addEventListener('click', async () => {
state.eventsOffset = Math.max(0, state.eventsOffset - state.eventsLimit);
await loadEventsForDraft();
});
el('btnNextEvents').addEventListener('click', async () => {
state.eventsOffset += state.eventsLimit;
await loadEventsForDraft();
});
loadDashboard().catch((err) => {
console.error(err);
alert('Kunne ikke indlæse sync dashboard.');
});
})();
</script>
{% endblock %}

View File

@ -45,12 +45,3 @@ async def templates_list_page(request: Request):
"request": request, "request": request,
"title": "Templates" "title": "Templates"
}) })
@router.get("/billing/sync-dashboard", response_class=HTMLResponse)
async def billing_sync_dashboard_page(request: Request):
"""Operational sync dashboard for ordre_drafts lifecycle."""
return templates.TemplateResponse("billing/frontend/sync_dashboard.html", {
"request": request,
"title": "Billing Sync Dashboard"
})

View File

@ -15,21 +15,6 @@ logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
def _users_column_exists(column_name: str) -> bool:
result = execute_query_single(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = %s
LIMIT 1
""",
(column_name,),
)
return bool(result)
async def get_current_user( async def get_current_user(
request: Request, request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
@ -85,11 +70,9 @@ async def get_current_user(
} }
# Get additional user details from database # Get additional user details from database
is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
user_details = execute_query_single( user_details = execute_query_single(
f"SELECT email, full_name, {is_2fa_expr} FROM users WHERE user_id = %s", "SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,), (user_id,))
)
return { return {
"id": user_id, "id": user_id,

View File

@ -15,28 +15,6 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_users_column_cache: Dict[str, bool] = {}
def _users_column_exists(column_name: str) -> bool:
if column_name in _users_column_cache:
return _users_column_cache[column_name]
result = execute_query_single(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = %s
LIMIT 1
""",
(column_name,),
)
exists = bool(result)
_users_column_cache[column_name] = exists
return exists
# JWT Settings # JWT Settings
SECRET_KEY = settings.JWT_SECRET_KEY SECRET_KEY = settings.JWT_SECRET_KEY
ALGORITHM = "HS256" ALGORITHM = "HS256"
@ -47,11 +25,6 @@ pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"],
class AuthService: class AuthService:
"""Service for authentication and authorization""" """Service for authentication and authorization"""
@staticmethod
def is_2fa_supported() -> bool:
"""Return True only when required 2FA columns exist in users table."""
return _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret")
@staticmethod @staticmethod
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
@ -116,9 +89,6 @@ class AuthService:
@staticmethod @staticmethod
def setup_user_2fa(user_id: int, username: str) -> Dict: def setup_user_2fa(user_id: int, username: str) -> Dict:
"""Create and store a new TOTP secret (not enabled until verified)""" """Create and store a new TOTP secret (not enabled until verified)"""
if not AuthService.is_2fa_supported():
raise RuntimeError("2FA columns missing in users table")
secret = AuthService.generate_2fa_secret() secret = AuthService.generate_2fa_secret()
execute_update( execute_update(
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s", "UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
@ -133,9 +103,6 @@ class AuthService:
@staticmethod @staticmethod
def enable_user_2fa(user_id: int, otp_code: str) -> bool: def enable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Enable 2FA after verifying TOTP code""" """Enable 2FA after verifying TOTP code"""
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
return False
user = execute_query_single( user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s", "SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,) (user_id,)
@ -156,9 +123,6 @@ class AuthService:
@staticmethod @staticmethod
def disable_user_2fa(user_id: int, otp_code: str) -> bool: def disable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Disable 2FA after verifying TOTP code""" """Disable 2FA after verifying TOTP code"""
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
return False
user = execute_query_single( user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s", "SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,) (user_id,)
@ -187,11 +151,10 @@ class AuthService:
if not user: if not user:
return False return False
if _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret"): execute_update(
execute_update( "UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s", (user_id,)
(user_id,) )
)
return True return True
@staticmethod @staticmethod
@ -293,18 +256,13 @@ class AuthService:
request_username = (username or "").strip().lower() request_username = (username or "").strip().lower()
# Get user # Get user
is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
totp_expr = "totp_secret" if _users_column_exists("totp_secret") else "NULL::text AS totp_secret"
last_2fa_expr = "last_2fa_at" if _users_column_exists("last_2fa_at") else "NULL::timestamp AS last_2fa_at"
user = execute_query_single( user = execute_query_single(
f"""SELECT user_id, username, email, password_hash, full_name, """SELECT user_id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until, is_active, is_superadmin, failed_login_attempts, locked_until,
{is_2fa_expr}, {totp_expr}, {last_2fa_expr} is_2fa_enabled, totp_secret, last_2fa_at
FROM users FROM users
WHERE username = %s OR email = %s""", WHERE username = %s OR email = %s""",
(username, username), (username, username))
)
if not user: if not user:
# Shadow Admin fallback (only when no regular user matches) # Shadow Admin fallback (only when no regular user matches)
@ -409,11 +367,10 @@ class AuthService:
logger.warning(f"❌ Login failed: Invalid 2FA - {username}") logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
return None, "Invalid 2FA code" return None, "Invalid 2FA code"
if _users_column_exists("last_2fa_at"): execute_update(
execute_update( "UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s", (user['user_id'],)
(user['user_id'],) )
)
# Success! Reset failed attempts and update last login # Success! Reset failed attempts and update last login
execute_update( execute_update(
@ -459,9 +416,6 @@ class AuthService:
@staticmethod @staticmethod
def is_user_2fa_enabled(user_id: int) -> bool: def is_user_2fa_enabled(user_id: int) -> bool:
"""Check if user has 2FA enabled""" """Check if user has 2FA enabled"""
if not _users_column_exists("is_2fa_enabled"):
return False
user = execute_query_single( user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s", "SELECT is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,) (user_id,)

View File

@ -6,7 +6,6 @@ PostgreSQL connection and helpers using psycopg2
import psycopg2 import psycopg2
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from psycopg2.pool import SimpleConnectionPool from psycopg2.pool import SimpleConnectionPool
from functools import lru_cache
from typing import Optional from typing import Optional
import logging import logging
@ -129,34 +128,3 @@ def execute_query_single(query: str, params: tuple = None):
"""Execute query and return single row (backwards compatibility for fetchone=True)""" """Execute query and return single row (backwards compatibility for fetchone=True)"""
result = execute_query(query, params) result = execute_query(query, params)
return result[0] if result and len(result) > 0 else None return result[0] if result and len(result) > 0 else None
@lru_cache(maxsize=256)
def table_has_column(table_name: str, column_name: str, schema: str = "public") -> bool:
"""Return whether a column exists in the current database schema."""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = %s
AND table_name = %s
AND column_name = %s
LIMIT 1
""",
(schema, table_name, column_name),
)
return cursor.fetchone() is not None
except Exception as e:
logger.warning(
"Schema lookup failed for %s.%s.%s: %s",
schema,
table_name,
column_name,
e,
)
return False
finally:
release_db_connection(conn)

View File

@ -125,24 +125,10 @@ async def dashboard(request: Request):
from app.core.database import execute_query from app.core.database import execute_query
try: result = execute_query_single(unknown_query)
result = execute_query_single(unknown_query) unknown_count = result['count'] if result else 0
unknown_count = result['count'] if result else 0
except Exception as exc: raw_alerts = execute_query(bankruptcy_query) or []
if "tticket_worklog" in str(exc):
logger.warning("⚠️ tticket_worklog table not found; defaulting unknown worklog count to 0")
unknown_count = 0
else:
raise
try:
raw_alerts = execute_query(bankruptcy_query) or []
except Exception as exc:
if "email_messages" in str(exc):
logger.warning("⚠️ email_messages table not found; skipping bankruptcy alerts")
raw_alerts = []
else:
raise
bankruptcy_alerts = [] bankruptcy_alerts = []
for alert in raw_alerts: for alert in raw_alerts:

View File

@ -18,8 +18,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"}
# Pydantic Models # Pydantic Models
class EmailListItem(BaseModel): class EmailListItem(BaseModel):
@ -38,8 +36,6 @@ class EmailListItem(BaseModel):
rule_name: Optional[str] = None rule_name: Optional[str] = None
supplier_name: Optional[str] = None supplier_name: Optional[str] = None
customer_name: Optional[str] = None customer_name: Optional[str] = None
linked_case_id: Optional[int] = None
linked_case_title: Optional[str] = None
class EmailAttachment(BaseModel): class EmailAttachment(BaseModel):
@ -83,7 +79,6 @@ class EmailDetail(BaseModel):
attachments: List[EmailAttachment] = [] attachments: List[EmailAttachment] = []
customer_name: Optional[str] = None customer_name: Optional[str] = None
supplier_name: Optional[str] = None supplier_name: Optional[str] = None
linked_case_title: Optional[str] = None
class EmailRule(BaseModel): class EmailRule(BaseModel):
@ -165,12 +160,14 @@ class CreateSagFromEmailRequest(BaseModel):
ansvarlig_bruger_id: Optional[int] = None ansvarlig_bruger_id: Optional[int] = None
assigned_group_id: Optional[int] = None assigned_group_id: Optional[int] = None
created_by_user_id: int = 1 created_by_user_id: int = 1
relation_type: str = "mail" relation_type: str = "kommentar"
class LinkEmailToSagRequest(BaseModel): class LinkEmailToSagRequest(BaseModel):
sag_id: int sag_id: int
relation_type: str = "mail" relation_type: str = "kommentar"
note: Optional[str] = None
forfatter: str = "E-mail Motor"
mark_processed: bool = True mark_processed: bool = True
@ -305,16 +302,13 @@ async def list_emails(
em.received_date, em.classification, em.confidence_score, em.status, em.received_date, em.classification, em.confidence_score, em.status,
em.is_read, em.has_attachments, em.attachment_count, em.is_read, em.has_attachments, em.attachment_count,
em.body_text, em.body_html, em.body_text, em.body_html,
em.linked_case_id,
er.name as rule_name, er.name as rule_name,
v.name as supplier_name, v.name as supplier_name,
c.name as customer_name, c.name as customer_name
s.titel AS linked_case_title
FROM email_messages em FROM email_messages em
LEFT JOIN email_rules er ON em.rule_id = er.id LEFT JOIN email_rules er ON em.rule_id = er.id
LEFT JOIN vendors v ON em.supplier_id = v.id LEFT JOIN vendors v ON em.supplier_id = v.id
LEFT JOIN customers c ON em.customer_id = c.id LEFT JOIN customers c ON em.customer_id = c.id
LEFT JOIN sag_sager s ON em.linked_case_id = s.id
WHERE {where_sql} WHERE {where_sql}
ORDER BY em.received_date DESC ORDER BY em.received_date DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
@ -337,12 +331,10 @@ async def get_email(email_id: int):
query = """ query = """
SELECT em.*, SELECT em.*,
c.name AS customer_name, c.name AS customer_name,
v.name AS supplier_name, v.name AS supplier_name
s.titel AS linked_case_title
FROM email_messages em FROM email_messages em
LEFT JOIN customers c ON em.customer_id = c.id LEFT JOIN customers c ON em.customer_id = c.id
LEFT JOIN vendors v ON em.supplier_id = v.id LEFT JOIN vendors v ON em.supplier_id = v.id
LEFT JOIN sag_sager s ON em.linked_case_id = s.id
WHERE em.id = %s AND em.deleted_at IS NULL WHERE em.id = %s AND em.deleted_at IS NULL
""" """
result = execute_query(query, (email_id,)) result = execute_query(query, (email_id,))
@ -588,9 +580,23 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
(sag_id, payload.contact_id, 'primary') (sag_id, payload.contact_id, 'primary')
) )
relation_type = (payload.relation_type or 'mail').strip().lower() relation_type = (payload.relation_type or 'kommentar').strip().lower()
if relation_type not in ALLOWED_SAG_EMAIL_RELATION_TYPES: if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
raise HTTPException(status_code=400, detail="relation_type must be 'mail'") system_note = (
f"E-mail knyttet som {relation_type}.\n"
f"Emne: {email_data.get('subject') or '(ingen emne)'}\n"
f"Fra: {email_data.get('sender_email') or '(ukendt)'}"
)
if payload.secondary_label:
system_note += f"\nLabel: {payload.secondary_label.strip()[:60]}"
execute_update(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(sag_id, 'E-mail Motor', system_note, True)
)
return { return {
"success": True, "success": True,
@ -657,9 +663,20 @@ async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
(payload.sag_id, email_id) (payload.sag_id, email_id)
) )
relation_type = (payload.relation_type or 'mail').strip().lower() relation_type = (payload.relation_type or 'kommentar').strip().lower()
if relation_type not in ALLOWED_SAG_EMAIL_RELATION_TYPES: if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
raise HTTPException(status_code=400, detail="relation_type must be 'mail'") email_data = email_row[0]
note = payload.note or (
f"E-mail knyttet som {relation_type}. "
f"Emne: {email_data.get('subject') or '(ingen emne)'}"
)
execute_update(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(payload.sag_id, payload.forfatter, note, True)
)
return { return {
"success": True, "success": True,

View File

@ -555,27 +555,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.quick-action-row-case .btn {
min-height: 40px;
}
@media (max-width: 576px) {
.quick-action-row-case {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.quick-action-row-case .btn {
width: 100%;
}
.quick-action-row-case .btn:nth-child(1),
.quick-action-row-case .btn:nth-child(2) {
grid-column: 1 / -1;
}
}
.customer-search-wrap { .customer-search-wrap {
position: relative; position: relative;
} }
@ -1723,7 +1702,6 @@ function renderEmailList(emailList) {
<span class="classification-badge classification-${classification}"> <span class="classification-badge classification-${classification}">
${formatClassification(classification)} ${formatClassification(classification)}
</span> </span>
${getCaseBadge(email)}
${getStatusBadge(email)} ${getStatusBadge(email)}
${email.confidence_score ? `<small class="text-muted">${Math.round(email.confidence_score * 100)}%</small>` : ''} ${email.confidence_score ? `<small class="text-muted">${Math.round(email.confidence_score * 100)}%</small>` : ''}
${email.has_attachments ? `<span class="text-muted ms-2"><i class="bi bi-paperclip"></i> ${email.attachment_count || ''}</span>` : ''} ${email.has_attachments ? `<span class="text-muted ms-2"><i class="bi bi-paperclip"></i> ${email.attachment_count || ''}</span>` : ''}
@ -1886,13 +1864,6 @@ function renderEmailDetail(email) {
<i class="bi bi-clock me-1"></i>${timestamp} <i class="bi bi-clock me-1"></i>${timestamp}
</div> </div>
</div> </div>
${email.linked_case_id ? `
<div class="mt-3">
<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none">
<i class="bi bi-link-45deg me-1"></i>SAG-${email.linked_case_id}${email.linked_case_title ? `: ${escapeHtml(email.linked_case_title)}` : ''}
</a>
</div>
` : ''}
</div> </div>
<div class="email-actions d-flex justify-content-between align-items-center"> <div class="email-actions d-flex justify-content-between align-items-center">
@ -1909,11 +1880,6 @@ function renderEmailDetail(email) {
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail()" title="Kør Workflows"> <button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail()" title="Kør Workflows">
<i class="bi bi-diagram-3 me-1"></i>Workflows <i class="bi bi-diagram-3 me-1"></i>Workflows
</button> </button>
${email.linked_case_id ? `
<a class="btn btn-sm btn-outline-primary border" href="/sag/${email.linked_case_id}" title="Åbn SAG-${email.linked_case_id}">
<i class="bi bi-box-arrow-up-right me-1"></i>Sag
</a>
` : ''}
<button class="btn btn-sm btn-light border text-danger" onclick="deleteEmail()" title="Slet"> <button class="btn btn-sm btn-light border text-danger" onclick="deleteEmail()" title="Slet">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
@ -2089,7 +2055,7 @@ function renderEmailAnalysis(email) {
</div> </div>
</div> </div>
<div class="quick-action-row quick-action-row-case mt-3"> <div class="quick-action-row mt-3">
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()"> <button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag <i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
</button> </button>
@ -2107,7 +2073,15 @@ function renderEmailAnalysis(email) {
<datalist id="existingSagResults"></datalist> <datalist id="existingSagResults"></datalist>
<input id="existingSagId" type="hidden" value=""> <input id="existingSagId" type="hidden" value="">
</div> </div>
<button class="btn btn-sm btn-primary mt-2 w-100" onclick="linkCurrentEmailToExistingSag()"> <div class="suggestion-field full mt-2">
<label for="existingSagRelationType">Tilføj mail som</label>
<select id="existingSagRelationType">
<option value="kommentar">Kommentar</option>
<option value="intern_note">Intern note</option>
<option value="kundeopdatering">Kundeopdatering</option>
</select>
</div>
<button class="btn btn-sm btn-primary mt-2" onclick="linkCurrentEmailToExistingSag()">
<i class="bi bi-link me-1"></i>Tilknyt Sag <i class="bi bi-link me-1"></i>Tilknyt Sag
</button> </button>
</div> </div>
@ -2309,7 +2283,7 @@ function getCaseFormPayload() {
priority: document.getElementById('casePriority')?.value || 'normal', priority: document.getElementById('casePriority')?.value || 'normal',
ansvarlig_bruger_id: document.getElementById('caseAssignee')?.value ? Number(document.getElementById('caseAssignee').value) : null, ansvarlig_bruger_id: document.getElementById('caseAssignee')?.value ? Number(document.getElementById('caseAssignee').value) : null,
assigned_group_id: document.getElementById('caseGroup')?.value ? Number(document.getElementById('caseGroup').value) : null, assigned_group_id: document.getElementById('caseGroup')?.value ? Number(document.getElementById('caseGroup').value) : null,
relation_type: 'mail' relation_type: 'kommentar'
}; };
} }
@ -2357,7 +2331,7 @@ async function linkCurrentEmailToExistingSag() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
sag_id: Number(selectedSagId), sag_id: Number(selectedSagId),
relation_type: 'mail' relation_type: document.getElementById('existingSagRelationType')?.value || 'kommentar'
}) })
}); });
@ -3222,15 +3196,6 @@ function getStatusBadge(email) {
return ''; return '';
} }
function getCaseBadge(email) {
if (!email.linked_case_id) {
return '';
}
const title = email.linked_case_title ? ` title="${escapeHtml(email.linked_case_title)}"` : '';
return `<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none ms-1"${title}>SAG-${email.linked_case_id}</a>`;
}
function getFileIcon(contentType) { function getFileIcon(contentType) {
if (contentType?.includes('pdf')) return 'pdf'; if (contentType?.includes('pdf')) return 'pdf';
if (contentType?.includes('image')) return 'image'; if (contentType?.includes('image')) return 'image';

View File

@ -7,9 +7,11 @@ Runs daily at 04:00
import logging import logging
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal
import json import json
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from app.core.config import settings
from app.core.database import execute_query, get_db_connection from app.core.database import execute_query, get_db_connection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -17,11 +19,11 @@ logger = logging.getLogger(__name__)
async def process_subscriptions(): async def process_subscriptions():
""" """
Main job: Process subscriptions due for invoicing. Main job: Process subscriptions due for invoicing
- Find active subscriptions where next_invoice_date <= today - Find active subscriptions where next_invoice_date <= TODAY
- Skip subscriptions blocked for invoicing (missing asset/serial) - Create ordre draft with line items from subscription
- Aggregate eligible subscriptions into one ordre_draft per customer + merge key + due date + billing direction - Advance period_start and next_invoice_date based on billing_interval
- Advance period_start and next_invoice_date for processed subscriptions - Log all actions for audit trail
""" """
try: try:
@ -37,14 +39,9 @@ async def process_subscriptions():
c.name AS customer_name, c.name AS customer_name,
s.product_name, s.product_name,
s.billing_interval, s.billing_interval,
s.billing_direction,
s.advance_months,
s.price, s.price,
s.next_invoice_date, s.next_invoice_date,
s.period_start, s.period_start,
s.invoice_merge_key,
s.billing_blocked,
s.billing_block_reason,
COALESCE( COALESCE(
( (
SELECT json_agg( SELECT json_agg(
@ -54,12 +51,7 @@ async def process_subscriptions():
'quantity', si.quantity, 'quantity', si.quantity,
'unit_price', si.unit_price, 'unit_price', si.unit_price,
'line_total', si.line_total, 'line_total', si.line_total,
'product_id', si.product_id, 'product_id', si.product_id
'asset_id', si.asset_id,
'billing_blocked', si.billing_blocked,
'billing_block_reason', si.billing_block_reason,
'period_from', si.period_from,
'period_to', si.period_to
) ORDER BY si.id ) ORDER BY si.id
) )
FROM sag_subscription_items si FROM sag_subscription_items si
@ -83,186 +75,110 @@ async def process_subscriptions():
logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process") logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process")
blocked_count = 0
processed_count = 0 processed_count = 0
error_count = 0 error_count = 0
grouped_subscriptions = {}
for sub in subscriptions: for sub in subscriptions:
if sub.get('billing_blocked'):
blocked_count += 1
logger.warning(
"⚠️ Subscription %s skipped due to billing block: %s",
sub.get('id'),
sub.get('billing_block_reason') or 'unknown reason'
)
continue
group_key = (
int(sub['customer_id']),
str(sub.get('invoice_merge_key') or f"cust-{sub['customer_id']}"),
str(sub.get('next_invoice_date')),
str(sub.get('billing_direction') or 'forward'),
)
grouped_subscriptions.setdefault(group_key, []).append(sub)
for group in grouped_subscriptions.values():
try: try:
count = await _process_subscription_group(group) await _process_single_subscription(sub)
processed_count += count processed_count += 1
except Exception as e: except Exception as e:
logger.error("❌ Failed processing subscription group: %s", e, exc_info=True) logger.error(f"❌ Failed to process subscription {sub['id']}: {e}", exc_info=True)
error_count += 1 error_count += 1
logger.info( logger.info(f"✅ Subscription processing complete: {processed_count} processed, {error_count} errors")
"✅ Subscription processing complete: %s processed, %s blocked, %s errors",
processed_count,
blocked_count,
error_count,
)
except Exception as e: except Exception as e:
logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True) logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True)
async def _process_subscription_group(subscriptions: list[dict]) -> int: async def _process_single_subscription(sub: dict):
"""Create one aggregated ordre draft for a group of subscriptions and advance all periods.""" """Process a single subscription: create ordre draft and advance period"""
if not subscriptions: subscription_id = sub['id']
return 0 logger.info(f"Processing subscription #{subscription_id}: {sub['product_name']} for {sub['customer_name']}")
first = subscriptions[0]
customer_id = first['customer_id']
customer_name = first.get('customer_name') or f"Customer #{customer_id}"
billing_direction = first.get('billing_direction') or 'forward'
invoice_aggregate_key = first.get('invoice_merge_key') or f"cust-{customer_id}"
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
# Convert line_items from JSON to list
line_items = sub.get('line_items', [])
if isinstance(line_items, str):
line_items = json.loads(line_items)
# Build ordre draft lines_json
ordre_lines = [] ordre_lines = []
source_subscription_ids = [] for item in line_items:
coverage_start = None product_number = str(item.get('product_id', 'SUB'))
coverage_end = None ordre_lines.append({
"product": {
for sub in subscriptions: "productNumber": product_number,
subscription_id = int(sub['id']) "description": item.get('description', '')
source_subscription_ids.append(subscription_id) },
"quantity": float(item.get('quantity', 1)),
line_items = sub.get('line_items', []) "unitNetPrice": float(item.get('unit_price', 0)),
if isinstance(line_items, str): "totalNetAmount": float(item.get('line_total', 0)),
line_items = json.loads(line_items) "discountPercentage": 0
})
period_start = sub.get('period_start') or sub.get('next_invoice_date')
period_end = _calculate_next_period_start(period_start, sub['billing_interval']) # Create ordre draft title with period information
if coverage_start is None or period_start < coverage_start: period_start = sub.get('period_start') or sub.get('next_invoice_date')
coverage_start = period_start next_period_start = _calculate_next_period_start(period_start, sub['billing_interval'])
if coverage_end is None or period_end > coverage_end:
coverage_end = period_end title = f"Abonnement: {sub['product_name']}"
notes = f"Periode: {period_start} til {next_period_start}\nAbonnement ID: {subscription_id}"
for item in line_items:
if item.get('billing_blocked'): if sub.get('sag_id'):
logger.warning( notes += f"\nSag: {sub['sag_name']}"
"⚠️ Skipping blocked subscription item %s on subscription %s",
item.get('id'), # Insert ordre draft
subscription_id,
)
continue
product_number = str(item.get('product_id', 'SUB'))
ordre_lines.append({
"product": {
"productNumber": product_number,
"description": item.get('description', '')
},
"quantity": float(item.get('quantity', 1)),
"unitNetPrice": float(item.get('unit_price', 0)),
"totalNetAmount": float(item.get('line_total', 0)),
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"asset_id": item.get('asset_id'),
"period_from": str(item.get('period_from') or period_start),
"period_to": str(item.get('period_to') or period_end),
}
})
if not ordre_lines:
logger.warning("⚠️ No invoiceable lines in subscription group for customer %s", customer_id)
return 0
title = f"Abonnementer: {customer_name}"
notes = (
f"Aggregated abonnement faktura\n"
f"Kunde: {customer_name}\n"
f"Coverage: {coverage_start} til {coverage_end}\n"
f"Subscription IDs: {', '.join(str(sid) for sid in source_subscription_ids)}"
)
insert_query = """ insert_query = """
INSERT INTO ordre_drafts ( INSERT INTO ordre_drafts (
title, title,
customer_id, customer_id,
lines_json, lines_json,
notes, notes,
coverage_start,
coverage_end,
billing_direction,
source_subscription_ids,
invoice_aggregate_key,
layout_number, layout_number,
created_by_user_id, created_by_user_id,
sync_status,
export_status_json, export_status_json,
updated_at updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP) ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
RETURNING id RETURNING id
""" """
cursor.execute(insert_query, ( cursor.execute(insert_query, (
title, title,
customer_id, sub['customer_id'],
json.dumps(ordre_lines, ensure_ascii=False), json.dumps(ordre_lines, ensure_ascii=False),
notes, notes,
coverage_start,
coverage_end,
billing_direction,
source_subscription_ids,
invoice_aggregate_key,
1, # Default layout 1, # Default layout
None, # System-created None, # System-created
'pending', json.dumps({"source": "subscription", "subscription_id": subscription_id}, ensure_ascii=False)
json.dumps({"source": "subscription", "subscription_ids": source_subscription_ids}, ensure_ascii=False)
)) ))
ordre_id = cursor.fetchone()[0] ordre_id = cursor.fetchone()[0]
logger.info( logger.info(f"✅ Created ordre draft #{ordre_id} for subscription #{subscription_id}")
"✅ Created aggregated ordre draft #%s for %s subscription(s)",
ordre_id, # Calculate new period dates
len(source_subscription_ids), current_period_start = sub.get('period_start') or sub.get('next_invoice_date')
) new_period_start = next_period_start
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
for sub in subscriptions:
subscription_id = int(sub['id']) # Update subscription with new period dates
current_period_start = sub.get('period_start') or sub.get('next_invoice_date') update_query = """
new_period_start = _calculate_next_period_start(current_period_start, sub['billing_interval']) UPDATE sag_subscriptions
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval']) SET period_start = %s,
next_invoice_date = %s,
cursor.execute( updated_at = CURRENT_TIMESTAMP
""" WHERE id = %s
UPDATE sag_subscriptions """
SET period_start = %s,
next_invoice_date = %s, cursor.execute(update_query, (new_period_start, new_next_invoice_date, subscription_id))
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(new_period_start, new_next_invoice_date, subscription_id)
)
conn.commit() conn.commit()
return len(source_subscription_ids) logger.info(f"✅ Advanced subscription #{subscription_id}: next invoice {new_next_invoice_date}")
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
raise e raise e

View File

@ -1,119 +0,0 @@
"""
Reconcile ordre draft sync lifecycle.
Promotes sync_status based on known economic references on ordre_drafts:
- pending/failed + economic_order_number -> exported
- exported + economic_invoice_number -> posted
"""
import logging
from typing import Any, Dict, List
from app.core.database import execute_query, get_db_connection, release_db_connection
from app.services.economic_service import get_economic_service
from psycopg2.extras import RealDictCursor
logger = logging.getLogger(__name__)
async def reconcile_ordre_drafts_sync_status(apply_changes: bool = True) -> Dict[str, Any]:
"""Reconcile ordre_drafts sync statuses and optionally persist changes."""
drafts = execute_query(
"""
SELECT id, sync_status, economic_order_number, economic_invoice_number
FROM ordre_drafts
ORDER BY id ASC
""",
(),
) or []
changes: List[Dict[str, Any]] = []
invoice_status_cache: Dict[str, str] = {}
economic_service = get_economic_service()
for draft in drafts:
current = (draft.get("sync_status") or "pending").strip().lower()
target = current
if current in {"pending", "failed"} and draft.get("economic_order_number"):
target = "exported"
if target == "exported" and draft.get("economic_invoice_number"):
target = "posted"
invoice_number = str(draft.get("economic_invoice_number") or "").strip()
if invoice_number:
if invoice_number not in invoice_status_cache:
invoice_status_cache[invoice_number] = await economic_service.get_invoice_lifecycle_status(invoice_number)
lifecycle = invoice_status_cache[invoice_number]
if lifecycle == "paid":
target = "paid"
elif lifecycle in {"booked", "unpaid"} and target in {"pending", "failed", "exported"}:
target = "posted"
if target != current:
changes.append(
{
"draft_id": draft.get("id"),
"from": current,
"to": target,
"economic_invoice_number": invoice_number or None,
}
)
if apply_changes and changes:
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
for change in changes:
cursor.execute(
"""
UPDATE ordre_drafts
SET sync_status = %s,
last_sync_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
last_exported_at = CASE
WHEN %s IN ('exported', 'posted', 'paid') THEN CURRENT_TIMESTAMP
ELSE last_exported_at
END
WHERE id = %s
""",
(change["to"], change["to"], change["draft_id"]),
)
cursor.execute(
"""
INSERT INTO ordre_draft_sync_events (
draft_id,
event_type,
from_status,
to_status,
event_payload,
created_by_user_id
) VALUES (%s, %s, %s, %s, %s::jsonb, NULL)
""",
(
change["draft_id"],
"sync_status_reconcile",
change["from"],
change["to"],
'{"source":"reconcile_job"}',
),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
logger.info(
"✅ Reconciled ordre draft sync status: %s changes (%s)",
len(changes),
"applied" if apply_changes else "preview",
)
return {
"status": "applied" if apply_changes else "preview",
"change_count": len(changes),
"changes": changes,
}

View File

@ -280,7 +280,6 @@ class TodoStepCreate(TodoStepBase):
class TodoStepUpdate(BaseModel): class TodoStepUpdate(BaseModel):
"""Schema for updating a todo step""" """Schema for updating a todo step"""
is_done: Optional[bool] = None is_done: Optional[bool] = None
is_next: Optional[bool] = None
class TodoStep(TodoStepBase): class TodoStep(TodoStepBase):
@ -288,7 +287,6 @@ class TodoStep(TodoStepBase):
id: int id: int
sag_id: int sag_id: int
is_done: bool is_done: bool
is_next: bool = False
created_by_user_id: Optional[int] = None created_by_user_id: Optional[int] = None
created_by_name: Optional[str] = None created_by_name: Optional[str] = None
created_at: datetime created_at: datetime

View File

@ -1,7 +1,6 @@
import logging import logging
import json import json
from datetime import datetime from datetime import datetime
from uuid import uuid4
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
@ -12,7 +11,6 @@ from app.modules.orders.backend.service import aggregate_order_lines
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
class OrdreLineInput(BaseModel): class OrdreLineInput(BaseModel):
@ -34,7 +32,6 @@ class OrdreExportRequest(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
layout_number: Optional[int] = None layout_number: Optional[int] = None
draft_id: Optional[int] = None draft_id: Optional[int] = None
force_export: bool = False
class OrdreDraftUpsertRequest(BaseModel): class OrdreDraftUpsertRequest(BaseModel):
@ -68,42 +65,6 @@ def _get_user_id_from_request(http_request: Request) -> Optional[int]:
return None return None
def _log_sync_event(
draft_id: int,
event_type: str,
from_status: Optional[str],
to_status: Optional[str],
event_payload: Dict[str, Any],
user_id: Optional[int],
) -> None:
"""Best-effort logging of sync events for ordre_drafts."""
try:
from app.core.database import execute_query
execute_query(
"""
INSERT INTO ordre_draft_sync_events (
draft_id,
event_type,
from_status,
to_status,
event_payload,
created_by_user_id
) VALUES (%s, %s, %s, %s, %s::jsonb, %s)
""",
(
draft_id,
event_type,
from_status,
to_status,
json.dumps(event_payload, ensure_ascii=False),
user_id,
)
)
except Exception as e:
logger.warning("⚠️ Could not log ordre sync event for draft %s: %s", draft_id, e)
@router.get("/ordre/aggregate") @router.get("/ordre/aggregate")
async def get_ordre_aggregate( async def get_ordre_aggregate(
customer_id: Optional[int] = Query(None), customer_id: Optional[int] = Query(None),
@ -134,39 +95,6 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"""Export selected ordre lines to e-conomic draft order.""" """Export selected ordre lines to e-conomic draft order."""
try: try:
user_id = _get_user_id_from_request(http_request) user_id = _get_user_id_from_request(http_request)
previous_status = None
export_idempotency_key = None
if request.draft_id:
from app.core.database import execute_query_single
draft_row = execute_query_single(
"""
SELECT id, sync_status, export_idempotency_key, export_status_json
FROM ordre_drafts
WHERE id = %s
""",
(request.draft_id,)
)
if not draft_row:
raise HTTPException(status_code=404, detail="Draft not found")
previous_status = (draft_row.get("sync_status") or "pending").strip().lower()
if previous_status in {"exported", "posted", "paid"} and not request.force_export:
raise HTTPException(
status_code=409,
detail=f"Draft already exported with status '{previous_status}'. Use force_export=true to retry.",
)
export_idempotency_key = draft_row.get("export_idempotency_key") or str(uuid4())
_log_sync_event(
request.draft_id,
"export_attempt",
previous_status,
previous_status,
{"force_export": request.force_export, "idempotency_key": export_idempotency_key},
user_id,
)
line_payload = [line.model_dump() for line in request.lines] line_payload = [line.model_dump() for line in request.lines]
export_result = await ordre_economic_export_service.export_order( export_result = await ordre_economic_export_service.export_order(
@ -195,53 +123,15 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"timestamp": datetime.utcnow().isoformat(), "timestamp": datetime.utcnow().isoformat(),
} }
economic_order_number = (
export_result.get("economic_order_number")
or export_result.get("order_number")
or export_result.get("orderNumber")
)
economic_invoice_number = (
export_result.get("economic_invoice_number")
or export_result.get("invoice_number")
or export_result.get("invoiceNumber")
)
target_sync_status = "pending" if export_result.get("dry_run") else "exported"
execute_query( execute_query(
""" """
UPDATE ordre_drafts UPDATE ordre_drafts
SET export_status_json = %s::jsonb, SET export_status_json = %s::jsonb,
sync_status = %s,
export_idempotency_key = %s,
economic_order_number = COALESCE(%s, economic_order_number),
economic_invoice_number = COALESCE(%s, economic_invoice_number),
last_sync_at = CURRENT_TIMESTAMP,
last_exported_at = CURRENT_TIMESTAMP, last_exported_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = %s WHERE id = %s
""", """,
( (json.dumps(existing_status, ensure_ascii=False), request.draft_id),
json.dumps(existing_status, ensure_ascii=False),
target_sync_status,
export_idempotency_key,
str(economic_order_number) if economic_order_number is not None else None,
str(economic_invoice_number) if economic_invoice_number is not None else None,
request.draft_id,
),
)
_log_sync_event(
request.draft_id,
"export_success",
previous_status,
target_sync_status,
{
"dry_run": bool(export_result.get("dry_run")),
"idempotency_key": export_idempotency_key,
"economic_order_number": economic_order_number,
"economic_invoice_number": economic_invoice_number,
},
user_id,
) )
return export_result return export_result
@ -260,26 +150,9 @@ async def list_ordre_drafts(
"""List all ordre drafts (no user filtering).""" """List all ordre drafts (no user filtering)."""
try: try:
query = """ query = """
SELECT id, title, customer_id, notes, layout_number, created_by_user_id, SELECT id, title, customer_id, notes, layout_number, created_by_user_id,
coverage_start, coverage_end, billing_direction, source_subscription_ids, created_at, updated_at, last_exported_at
invoice_aggregate_key, sync_status, export_idempotency_key,
economic_order_number, economic_invoice_number,
ev_latest.event_type AS latest_event_type,
ev_latest.created_at AS latest_event_at,
(
SELECT COUNT(*)
FROM ordre_draft_sync_events ev
WHERE ev.draft_id = ordre_drafts.id
) AS sync_event_count,
last_sync_at, created_at, updated_at, last_exported_at
FROM ordre_drafts FROM ordre_drafts
LEFT JOIN LATERAL (
SELECT event_type, created_at
FROM ordre_draft_sync_events
WHERE draft_id = ordre_drafts.id
ORDER BY created_at DESC, id DESC
LIMIT 1
) ev_latest ON TRUE
ORDER BY updated_at DESC, id DESC ORDER BY updated_at DESC, id DESC
LIMIT %s LIMIT %s
""" """
@ -329,10 +202,9 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
notes, notes,
layout_number, layout_number,
created_by_user_id, created_by_user_id,
sync_status,
export_status_json, export_status_json,
updated_at updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, CURRENT_TIMESTAMP) ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
RETURNING * RETURNING *
""" """
params = ( params = (
@ -351,172 +223,6 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
raise HTTPException(status_code=500, detail="Failed to create ordre draft") raise HTTPException(status_code=500, detail="Failed to create ordre draft")
@router.get("/ordre/drafts/sync-status/summary")
async def get_ordre_draft_sync_summary(http_request: Request):
"""Return sync status counters for ordre drafts."""
try:
from app.core.database import execute_query_single
query = """
SELECT
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
COUNT(*) FILTER (WHERE sync_status = 'paid') AS paid_count,
COUNT(*) AS total_count
FROM ordre_drafts
"""
return execute_query_single(query, ()) or {
"pending_count": 0,
"exported_count": 0,
"failed_count": 0,
"posted_count": 0,
"paid_count": 0,
"total_count": 0,
}
except Exception as e:
logger.error("❌ Error loading ordre sync summary: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to load sync summary")
@router.patch("/ordre/drafts/{draft_id}/sync-status")
async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any], http_request: Request):
"""Update sync lifecycle fields for one ordre draft."""
try:
user_id = _get_user_id_from_request(http_request)
sync_status = (payload.get("sync_status") or "").strip().lower()
if sync_status not in ALLOWED_SYNC_STATUSES:
raise HTTPException(status_code=400, detail="Invalid sync_status")
economic_order_number = payload.get("economic_order_number")
economic_invoice_number = payload.get("economic_invoice_number")
export_status_json = payload.get("export_status_json")
updates = ["sync_status = %s", "last_sync_at = CURRENT_TIMESTAMP", "updated_at = CURRENT_TIMESTAMP"]
values: List[Any] = [sync_status]
if economic_order_number is not None:
updates.append("economic_order_number = %s")
values.append(str(economic_order_number) if economic_order_number else None)
if economic_invoice_number is not None:
updates.append("economic_invoice_number = %s")
values.append(str(economic_invoice_number) if economic_invoice_number else None)
if export_status_json is not None:
updates.append("export_status_json = %s::jsonb")
values.append(json.dumps(export_status_json, ensure_ascii=False))
if sync_status in {"exported", "posted", "paid"}:
updates.append("last_exported_at = CURRENT_TIMESTAMP")
from app.core.database import execute_query_single
previous = execute_query_single(
"SELECT sync_status FROM ordre_drafts WHERE id = %s",
(draft_id,)
)
if not previous:
raise HTTPException(status_code=404, detail="Draft not found")
from_status = (previous.get("sync_status") or "pending").strip().lower()
values.append(draft_id)
from app.core.database import execute_query
result = execute_query(
f"""
UPDATE ordre_drafts
SET {', '.join(updates)}
WHERE id = %s
RETURNING *
""",
tuple(values)
)
if not result:
raise HTTPException(status_code=404, detail="Draft not found")
_log_sync_event(
draft_id,
"sync_status_manual_update",
from_status,
sync_status,
{
"economic_order_number": economic_order_number,
"economic_invoice_number": economic_invoice_number,
},
user_id,
)
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating ordre draft sync status: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to update draft sync status")
@router.get("/ordre/drafts/{draft_id}/sync-events")
async def list_ordre_draft_sync_events(
draft_id: int,
http_request: Request,
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
event_type: Optional[str] = Query(None),
from_status: Optional[str] = Query(None),
to_status: Optional[str] = Query(None),
from_date: Optional[str] = Query(None),
to_date: Optional[str] = Query(None),
):
"""List audit events for one ordre draft sync lifecycle."""
try:
from app.core.database import execute_query
where_clauses = ["draft_id = %s"]
params: List[Any] = [draft_id]
if event_type:
where_clauses.append("event_type = %s")
params.append(event_type)
if from_status:
where_clauses.append("from_status = %s")
params.append(from_status)
if to_status:
where_clauses.append("to_status = %s")
params.append(to_status)
if from_date:
where_clauses.append("created_at >= %s::timestamp")
params.append(from_date)
if to_date:
where_clauses.append("created_at <= %s::timestamp")
params.append(to_date)
count_query = f"""
SELECT COUNT(*) AS total
FROM ordre_draft_sync_events
WHERE {' AND '.join(where_clauses)}
"""
total_row = execute_query(count_query, tuple(params)) or [{"total": 0}]
total = int(total_row[0].get("total") or 0)
data_query = f"""
SELECT id, draft_id, event_type, from_status, to_status, event_payload, created_by_user_id, created_at
FROM ordre_draft_sync_events
WHERE {' AND '.join(where_clauses)}
ORDER BY created_at DESC, id DESC
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
rows = execute_query(data_query, tuple(params)) or []
return {
"items": rows,
"total": total,
"limit": limit,
"offset": offset,
}
except Exception as e:
logger.error("❌ Error listing ordre draft sync events: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to list sync events")
@router.patch("/ordre/drafts/{draft_id}") @router.patch("/ordre/drafts/{draft_id}")
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request): async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
"""Update existing ordre draft.""" """Update existing ordre draft."""

View File

@ -49,30 +49,6 @@
color: white; color: white;
background: var(--accent); background: var(--accent);
} }
.sync-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0,0,0,0.06);
margin-bottom: 1rem;
}
.sync-label {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.sync-value {
font-weight: 600;
color: var(--text-primary);
}
.event-payload {
max-width: 360px;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.8rem;
}
</style> </style>
{% endblock %} {% endblock %}
@ -96,13 +72,6 @@
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run. <strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div> </div>
<div class="d-flex justify-content-end mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="forceExportToggle">
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
</div>
</div>
<div class="ordre-header"> <div class="ordre-header">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
@ -145,120 +114,6 @@
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div> <div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
</div> </div>
<div class="sync-card">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
<div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events
</button>
<button class="btn btn-outline-success btn-sm" onclick="markDraftPaid()">
<i class="bi bi-cash-coin me-1"></i>Markér som betalt
</button>
</div>
</div>
<div class="row g-3 align-items-end mb-3">
<div class="col-md-3">
<div class="sync-label">Sync status</div>
<select id="syncStatusSelect" class="form-select form-select-sm">
<option value="pending">pending</option>
<option value="exported">exported</option>
<option value="failed">failed</option>
<option value="posted">posted</option>
<option value="paid">paid</option>
</select>
</div>
<div class="col-md-3">
<div class="sync-label">e-conomic ordre nr.</div>
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
</div>
<div class="col-md-3">
<div class="sync-label">e-conomic faktura nr.</div>
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
</div>
<div class="col-md-3 d-grid">
<button class="btn btn-primary btn-sm" onclick="updateSyncStatus()">
<i class="bi bi-check2-circle me-1"></i>Gem sync status
</button>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-3">
<div class="sync-label">Aktuel status</div>
<div id="syncStatusBadge" class="sync-value">-</div>
</div>
<div class="col-md-3">
<div class="sync-label">Sidste sync</div>
<div id="lastSyncAt" class="sync-value">-</div>
</div>
<div class="col-md-3">
<div class="sync-label">Ordrenummer</div>
<div id="economicOrderNumberView" class="sync-value">-</div>
</div>
<div class="col-md-3">
<div class="sync-label">Fakturanummer</div>
<div id="economicInvoiceNumberView" class="sync-value">-</div>
</div>
</div>
<div class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<label class="form-label mb-1">Event type</label>
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
</div>
<div class="col-md-2">
<label class="form-label mb-1">Fra status</label>
<input id="fromStatusFilter" type="text" class="form-control form-control-sm" placeholder="fx pending">
</div>
<div class="col-md-2">
<label class="form-label mb-1">Til status</label>
<input id="toStatusFilter" type="text" class="form-control form-control-sm" placeholder="fx exported">
</div>
<div class="col-md-2">
<label class="form-label mb-1">Fra dato</label>
<input id="fromDateFilter" type="date" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label mb-1">Til dato</label>
<input id="toDateFilter" type="date" class="form-control form-control-sm">
</div>
<div class="col-md-1 d-grid">
<button class="btn btn-outline-primary btn-sm" onclick="loadSyncEvents(0)">
<i class="bi bi-funnel"></i>
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-2">
<thead>
<tr>
<th>Tidspunkt</th>
<th>Type</th>
<th>Fra</th>
<th>Til</th>
<th>Payload</th>
</tr>
</thead>
<tbody id="syncEventsBody">
<tr><td colspan="5" class="text-muted text-center py-3">Indlæser events...</td></tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center">
<div id="syncEventsMeta" class="small text-muted">-</div>
<div class="d-flex gap-2">
<button id="eventsPrevBtn" class="btn btn-outline-secondary btn-sm" onclick="changeEventsPage(-1)">Forrige</button>
<button id="eventsNextBtn" class="btn btn-outline-secondary btn-sm" onclick="changeEventsPage(1)">Næste</button>
</div>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
@ -283,15 +138,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
<div id="detailToast" class="toast align-items-center text-bg-dark border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="detailToastBody">-</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
@ -300,27 +146,6 @@
const draftId = {{ draft_id }}; const draftId = {{ draft_id }};
let orderData = null; let orderData = null;
let orderLines = []; let orderLines = [];
const syncEventsLimit = 10;
let syncEventsOffset = 0;
let syncEventsTotal = 0;
let detailToast = null;
function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('detailToast');
const bodyEl = document.getElementById('detailToastBody');
if (!toastEl || !bodyEl || typeof bootstrap === 'undefined') {
console.log(message);
return;
}
toastEl.className = 'toast align-items-center border-0';
toastEl.classList.add(`text-bg-${variant}`);
bodyEl.textContent = message;
if (!detailToast) {
detailToast = new bootstrap.Toast(toastEl, { delay: 3200 });
}
detailToast.show();
}
function formatCurrency(value) { function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0)); return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
@ -339,35 +164,6 @@
return '<span class="badge bg-success">Salg</span>'; return '<span class="badge bg-success">Salg</span>';
} }
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function syncStatusBadge(status) {
const normalized = String(status || 'pending').toLowerCase();
if (normalized === 'paid') return '<span class="badge bg-success">paid</span>';
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>';
if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>';
return '<span class="badge bg-warning text-dark">pending</span>';
}
function refreshSyncPanelFromOrder() {
document.getElementById('syncStatusSelect').value = (orderData.sync_status || 'pending').toLowerCase();
document.getElementById('economicOrderNumber').value = orderData.economic_order_number || '';
document.getElementById('economicInvoiceNumber').value = orderData.economic_invoice_number || '';
document.getElementById('syncStatusBadge').innerHTML = syncStatusBadge(orderData.sync_status);
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
}
function renderLines() { function renderLines() {
const tbody = document.getElementById('linesTableBody'); const tbody = document.getElementById('linesTableBody');
if (!orderLines.length) { if (!orderLines.length) {
@ -515,142 +311,10 @@
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at); document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
renderLines(); renderLines();
refreshSyncPanelFromOrder();
await loadConfig(); await loadConfig();
await loadSyncEvents(syncEventsOffset);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
showToast(`Fejl: ${error.message}`, 'danger'); alert(`Fejl: ${error.message}`);
}
}
async function updateSyncStatus() {
const payload = {
sync_status: (document.getElementById('syncStatusSelect').value || 'pending').trim().toLowerCase(),
economic_order_number: document.getElementById('economicOrderNumber').value.trim() || null,
economic_invoice_number: document.getElementById('economicInvoiceNumber').value.trim() || null,
};
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/sync-status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke opdatere sync status');
orderData = data;
refreshSyncPanelFromOrder();
await loadSyncEvents(0);
showToast('Sync status opdateret', 'success');
} catch (error) {
showToast(`Fejl: ${error.message}`, 'danger');
}
}
async function markDraftPaid() {
if (!confirm('Markér denne ordre som betalt (kun hvis status er posted)?')) return;
try {
const res = await fetch('/api/v1/billing/drafts/reconcile-sync-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apply: true, mark_paid_ids: [draftId] }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke markere som betalt');
await loadOrder();
if ((orderData.sync_status || '').toLowerCase() !== 'paid') {
showToast('Ingen statusændring. Ordren skal være i status posted før den kan markeres som paid.', 'warning');
return;
}
showToast('Ordre markeret som betalt', 'success');
} catch (error) {
showToast(`Fejl: ${error.message}`, 'danger');
}
}
function buildEventsQuery(offset) {
const params = new URLSearchParams();
params.set('limit', String(syncEventsLimit));
params.set('offset', String(Math.max(0, offset || 0)));
const eventType = document.getElementById('eventTypeFilter').value.trim();
const fromStatus = document.getElementById('fromStatusFilter').value.trim();
const toStatus = document.getElementById('toStatusFilter').value.trim();
const fromDate = document.getElementById('fromDateFilter').value;
const toDate = document.getElementById('toDateFilter').value;
if (eventType) params.set('event_type', eventType);
if (fromStatus) params.set('from_status', fromStatus);
if (toStatus) params.set('to_status', toStatus);
if (fromDate) params.set('from_date', fromDate);
if (toDate) params.set('to_date', toDate);
return params.toString();
}
function renderSyncEvents(items) {
const body = document.getElementById('syncEventsBody');
if (!Array.isArray(items) || items.length === 0) {
body.innerHTML = '<tr><td colspan="5" class="text-muted text-center py-3">Ingen events fundet</td></tr>';
return;
}
body.innerHTML = items.map((event) => {
const payload = typeof event.event_payload === 'object'
? JSON.stringify(event.event_payload, null, 2)
: String(event.event_payload || '');
return `
<tr>
<td>${formatDate(event.created_at)}</td>
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
<td>${escapeHtml(event.from_status || '-')}</td>
<td>${escapeHtml(event.to_status || '-')}</td>
<td><pre class="event-payload mb-0">${escapeHtml(payload)}</pre></td>
</tr>
`;
}).join('');
}
function updateEventsPager() {
const start = syncEventsTotal === 0 ? 0 : syncEventsOffset + 1;
const end = Math.min(syncEventsOffset + syncEventsLimit, syncEventsTotal);
document.getElementById('syncEventsMeta').textContent = `Viser ${start}-${end} af ${syncEventsTotal}`;
document.getElementById('eventsPrevBtn').disabled = syncEventsOffset <= 0;
document.getElementById('eventsNextBtn').disabled = syncEventsOffset + syncEventsLimit >= syncEventsTotal;
}
function changeEventsPage(delta) {
const nextOffset = syncEventsOffset + (delta * syncEventsLimit);
if (nextOffset < 0 || nextOffset >= syncEventsTotal) {
return;
}
loadSyncEvents(nextOffset);
}
async function loadSyncEvents(offset = 0) {
syncEventsOffset = Math.max(0, offset);
const body = document.getElementById('syncEventsBody');
body.innerHTML = '<tr><td colspan="5" class="text-muted text-center py-3">Indlæser events...</td></tr>';
try {
const query = buildEventsQuery(syncEventsOffset);
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/sync-events?${query}`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke hente sync events');
syncEventsTotal = Number(data.total || 0);
renderSyncEvents(data.items || []);
updateEventsPager();
} catch (error) {
body.innerHTML = `<tr><td colspan="5" class="text-danger text-center py-3">${escapeHtml(error.message)}</td></tr>`;
syncEventsTotal = 0;
updateEventsPager();
} }
} }
@ -688,22 +352,22 @@
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke gemme ordre'); if (!res.ok) throw new Error(data.detail || 'Kunne ikke gemme ordre');
showToast('Ordre gemt', 'success'); alert('Ordre gemt');
await loadOrder(); await loadOrder();
} catch (err) { } catch (err) {
showToast(`Kunne ikke gemme ordre: ${err.message}`, 'danger'); alert(`Kunne ikke gemme ordre: ${err.message}`);
} }
} }
async function exportOrder() { async function exportOrder() {
const customerId = Number(document.getElementById('customerId').value || 0); const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) { if (!customerId) {
showToast('Angiv kunde ID før eksport', 'warning'); alert('Angiv kunde ID før eksport');
return; return;
} }
if (!orderLines.length) { if (!orderLines.length) {
showToast('Ingen linjer at eksportere', 'warning'); alert('Ingen linjer at eksportere');
return; return;
} }
@ -724,7 +388,6 @@
notes: document.getElementById('orderNotes').value || null, notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null, layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId, draft_id: draftId,
force_export: document.getElementById('forceExportToggle').checked,
}; };
try { try {
@ -738,11 +401,11 @@
throw new Error(data.detail || 'Eksport fejlede'); throw new Error(data.detail || 'Eksport fejlede');
} }
showToast(data.message || 'Eksport udført', data.dry_run ? 'warning' : 'success'); alert(data.message || 'Eksport udført');
await loadOrder(); await loadOrder();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
showToast(`Eksport fejlede: ${err.message}`, 'danger'); alert(`Eksport fejlede: ${err.message}`);
} }
} }

View File

@ -36,31 +36,6 @@
.order-row:hover { .order-row:hover {
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.05); background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.05);
} }
.sync-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.sync-actions .form-select {
min-width: 128px;
}
.latest-event {
max-width: 210px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected-counter {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--accent);
color: var(--accent);
background: rgba(var(--accent-rgb, 15, 76, 117), 0.08);
font-size: 0.85rem;
font-weight: 600;
}
</style> </style>
{% endblock %} {% endblock %}
@ -73,16 +48,6 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="/ordre/create/new" class="btn btn-success"><i class="bi bi-plus-circle me-1"></i>Opret ny ordre</a> <a href="/ordre/create/new" class="btn btn-success"><i class="bi bi-plus-circle me-1"></i>Opret ny ordre</a>
<select id="syncStatusFilter" class="form-select" style="min-width: 170px;" onchange="renderOrders()">
<option value="all">Alle sync-status</option>
<option value="pending">pending</option>
<option value="exported">exported</option>
<option value="failed">failed</option>
<option value="posted">posted</option>
<option value="paid">paid</option>
</select>
<span id="selectedCountBadge" class="selected-counter">Valgte: 0</span>
<button class="btn btn-outline-success" onclick="markSelectedOrdersPaid()"><i class="bi bi-cash-stack me-1"></i>Markér valgte som betalt</button>
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button> <button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div> </div>
</div> </div>
@ -100,7 +65,6 @@
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th style="width: 42px;"><input id="selectAllOrders" type="checkbox" onchange="toggleSelectAll(this.checked)"></th>
<th>Ordre #</th> <th>Ordre #</th>
<th>Titel</th> <th>Titel</th>
<th>Kunde</th> <th>Kunde</th>
@ -108,53 +72,23 @@
<th>Oprettet</th> <th>Oprettet</th>
<th>Sidst opdateret</th> <th>Sidst opdateret</th>
<th>Sidst eksporteret</th> <th>Sidst eksporteret</th>
<th>Seneste event</th>
<th>Status</th> <th>Status</th>
<th>Sync</th>
<th>Handlinger</th> <th>Handlinger</th>
</tr> </tr>
</thead> </thead>
<tbody id="ordersTableBody"> <tbody id="ordersTableBody">
<tr><td colspan="12" class="text-muted text-center py-4">Indlæser...</td></tr> <tr><td colspan="9" class="text-muted text-center py-4">Indlæser...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
<div id="ordersToast" class="toast align-items-center text-bg-dark border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="ordersToastBody">-</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
let orders = []; let orders = [];
let ordersToast = null;
let selectedOrderIds = new Set();
function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('ordersToast');
const bodyEl = document.getElementById('ordersToastBody');
if (!toastEl || !bodyEl || typeof bootstrap === 'undefined') {
console.log(message);
return;
}
toastEl.className = 'toast align-items-center border-0';
toastEl.classList.add(`text-bg-${variant}`);
bodyEl.textContent = message;
if (!ordersToast) {
ordersToast = new bootstrap.Toast(toastEl, { delay: 2800 });
}
ordersToast.show();
}
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
@ -162,48 +96,23 @@
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
} }
function getFilteredOrders() {
const filter = (document.getElementById('syncStatusFilter')?.value || 'all').toLowerCase();
if (filter === 'all') return orders;
return orders.filter(order => String(order.sync_status || 'pending').toLowerCase() === filter);
}
function renderOrders() { function renderOrders() {
const visibleOrders = getFilteredOrders();
const tbody = document.getElementById('ordersTableBody'); const tbody = document.getElementById('ordersTableBody');
if (!orders.length || !visibleOrders.length) { if (!orders.length) {
const message = orders.length tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Ingen ordre fundet</td></tr>';
? 'Ingen ordre matcher det valgte filter' updateSummary();
: 'Ingen ordre fundet';
tbody.innerHTML = `<tr><td colspan="12" class="text-muted text-center py-4">${message}</td></tr>`;
updateSummary(visibleOrders);
syncSelectAllCheckbox(visibleOrders);
return; return;
} }
tbody.innerHTML = visibleOrders.map(order => { tbody.innerHTML = orders.map(order => {
const lines = Array.isArray(order.lines_json) ? order.lines_json : []; const lines = Array.isArray(order.lines_json) ? order.lines_json : [];
const hasExported = order.last_exported_at ? true : false; const hasExported = order.last_exported_at ? true : false;
const statusBadge = hasExported const statusBadge = hasExported
? '<span class="badge bg-success">Eksporteret</span>' ? '<span class="badge bg-success">Eksporteret</span>'
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>'; : '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
const syncStatus = String(order.sync_status || 'pending').toLowerCase();
let syncBadge = '<span class="badge bg-warning text-dark">pending</span>';
if (syncStatus === 'failed') syncBadge = '<span class="badge bg-danger">failed</span>';
if (syncStatus === 'exported') syncBadge = '<span class="badge bg-primary">exported</span>';
if (syncStatus === 'posted') syncBadge = '<span class="badge bg-info text-dark">posted</span>';
if (syncStatus === 'paid') syncBadge = '<span class="badge bg-success">paid</span>';
const isChecked = selectedOrderIds.has(order.id);
const latestEventType = order.latest_event_type || '-';
const latestEventAt = order.latest_event_at ? formatDate(order.latest_event_at) : '-';
return ` return `
<tr class="order-row" onclick="window.location.href='/ordre/${order.id}'"> <tr class="order-row" onclick="window.location.href='/ordre/${order.id}'">
<td onclick="event.stopPropagation();">
<input type="checkbox" ${isChecked ? 'checked' : ''} onchange="toggleOrderSelection(${order.id}, this.checked)">
</td>
<td><strong>#${order.id}</strong></td> <td><strong>#${order.id}</strong></td>
<td>${order.title || '-'}</td> <td>${order.title || '-'}</td>
<td>${order.customer_id ? `Kunde ${order.customer_id}` : '-'}</td> <td>${order.customer_id ? `Kunde ${order.customer_id}` : '-'}</td>
@ -211,31 +120,7 @@
<td>${formatDate(order.created_at)}</td> <td>${formatDate(order.created_at)}</td>
<td>${formatDate(order.updated_at)}</td> <td>${formatDate(order.updated_at)}</td>
<td>${formatDate(order.last_exported_at)}</td> <td>${formatDate(order.last_exported_at)}</td>
<td class="latest-event" title="${latestEventType} · ${latestEventAt}">
<div class="small fw-semibold">${latestEventType}</div>
<div class="small text-muted">${latestEventAt}</div>
</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td>
<div class="sync-actions" onclick="event.stopPropagation();">
<span>${syncBadge}</span>
<select class="form-select form-select-sm" id="syncStatus-${order.id}">
<option value="pending" ${syncStatus === 'pending' ? 'selected' : ''}>pending</option>
<option value="exported" ${syncStatus === 'exported' ? 'selected' : ''}>exported</option>
<option value="failed" ${syncStatus === 'failed' ? 'selected' : ''}>failed</option>
<option value="posted" ${syncStatus === 'posted' ? 'selected' : ''}>posted</option>
<option value="paid" ${syncStatus === 'paid' ? 'selected' : ''}>paid</option>
</select>
<button class="btn btn-sm btn-outline-primary" title="Gem sync" onclick="saveQuickSyncStatus(${order.id})">
<i class="bi bi-check2"></i>
</button>
${syncStatus === 'posted' ? `
<button class="btn btn-sm btn-outline-success" title="Markér som betalt" onclick="markOrderPaid(${order.id})">
<i class="bi bi-cash-coin"></i>
</button>
` : ''}
</div>
</td>
<td> <td>
<button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); window.location.href='/ordre/${order.id}'"> <button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); window.location.href='/ordre/${order.id}'">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
@ -248,61 +133,18 @@
`; `;
}).join(''); }).join('');
syncSelectAllCheckbox(visibleOrders); updateSummary();
updateSelectedCounter();
updateSummary(visibleOrders);
} }
function toggleOrderSelection(orderId, checked) { function updateSummary() {
if (checked) {
selectedOrderIds.add(orderId);
} else {
selectedOrderIds.delete(orderId);
}
syncSelectAllCheckbox();
updateSelectedCounter();
}
function toggleSelectAll(checked) {
const visibleOrders = getFilteredOrders();
if (checked) {
visibleOrders.forEach(order => selectedOrderIds.add(order.id));
} else {
visibleOrders.forEach(order => selectedOrderIds.delete(order.id));
}
renderOrders();
}
function updateSelectedCounter() {
const badge = document.getElementById('selectedCountBadge');
if (!badge) return;
const visibleIds = new Set(getFilteredOrders().map(order => order.id));
const selectedVisibleCount = Array.from(selectedOrderIds).filter(id => visibleIds.has(id)).length;
badge.textContent = `Valgte: ${selectedVisibleCount}`;
}
function syncSelectAllCheckbox(visibleOrders = null) {
const selectAll = document.getElementById('selectAllOrders');
if (!selectAll) return;
const rows = Array.isArray(visibleOrders) ? visibleOrders : getFilteredOrders();
if (!rows.length) {
selectAll.checked = false;
return;
}
selectAll.checked = rows.every(order => selectedOrderIds.has(order.id));
}
function updateSummary(visibleOrders = null) {
const rows = Array.isArray(visibleOrders) ? visibleOrders : getFilteredOrders();
const now = new Date(); const now = new Date();
const oneMonthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); const oneMonthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
const recentOrders = rows.filter(order => new Date(order.created_at) >= oneMonthAgo); const recentOrders = orders.filter(order => new Date(order.created_at) >= oneMonthAgo);
const exportedOrders = rows.filter(order => order.last_exported_at); const exportedOrders = orders.filter(order => order.last_exported_at);
const notExportedOrders = rows.filter(order => !order.last_exported_at); const notExportedOrders = orders.filter(order => !order.last_exported_at);
document.getElementById('sumOrders').textContent = rows.length; document.getElementById('sumOrders').textContent = orders.length;
document.getElementById('sumRecent').textContent = recentOrders.length; document.getElementById('sumRecent').textContent = recentOrders.length;
document.getElementById('sumExported').textContent = exportedOrders.length; document.getElementById('sumExported').textContent = exportedOrders.length;
document.getElementById('sumNotExported').textContent = notExportedOrders.length; document.getElementById('sumNotExported').textContent = notExportedOrders.length;
@ -310,7 +152,7 @@
async function loadOrders() { async function loadOrders() {
const tbody = document.getElementById('ordersTableBody'); const tbody = document.getElementById('ordersTableBody');
tbody.innerHTML = '<tr><td colspan="12" class="text-muted text-center py-4">Indlæser...</td></tr>'; tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Indlæser...</td></tr>';
try { try {
const res = await fetch('/api/v1/ordre/drafts?limit=100'); const res = await fetch('/api/v1/ordre/drafts?limit=100');
@ -324,13 +166,10 @@
console.log('Fetched orders:', data); console.log('Fetched orders:', data);
orders = Array.isArray(data) ? data : []; orders = Array.isArray(data) ? data : [];
const availableIds = new Set(orders.map(order => order.id));
selectedOrderIds = new Set(Array.from(selectedOrderIds).filter(id => availableIds.has(id)));
updateSelectedCounter();
if (orders.length === 0) { if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="12" class="text-muted text-center py-4">Ingen ordre fundet. <a href="/ordre/create/new" class="btn btn-sm btn-success ms-2">Opret første ordre</a></td></tr>'; tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Ingen ordre fundet. <a href="/ordre/create/new" class="btn btn-sm btn-success ms-2">Opret første ordre</a></td></tr>';
updateSummary([]); updateSummary();
return; return;
} }
@ -355,86 +194,9 @@
renderOrders(); renderOrders();
} catch (error) { } catch (error) {
console.error('Load orders error:', error); console.error('Load orders error:', error);
tbody.innerHTML = `<tr><td colspan="12" class="text-danger text-center py-4">${error.message || 'Kunne ikke hente ordre'}</td></tr>`; tbody.innerHTML = `<tr><td colspan="9" class="text-danger text-center py-4">${error.message || 'Kunne ikke hente ordre'}</td></tr>`;
orders = []; orders = [];
updateSummary([]); updateSummary();
}
}
async function markSelectedOrdersPaid() {
const ids = Array.from(selectedOrderIds).map(Number).filter(Boolean);
if (!ids.length) {
showToast('Vælg mindst én ordre først', 'warning');
return;
}
const postedEligibleIds = ids.filter(id => {
const order = orders.find(item => item.id === id);
return String(order?.sync_status || '').toLowerCase() === 'posted';
});
if (!postedEligibleIds.length) {
showToast('Ingen af de valgte ordre er i status posted', 'warning');
return;
}
if (!confirm(`Markér ${postedEligibleIds.length} posted ordre som betalt?`)) return;
try {
const res = await fetch('/api/v1/billing/drafts/reconcile-sync-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apply: true, mark_paid_ids: postedEligibleIds }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke opdatere valgte ordre');
await loadOrders();
const paidCount = postedEligibleIds.filter(id => {
const order = orders.find(item => item.id === id);
return String(order?.sync_status || '').toLowerCase() === 'paid';
}).length;
showToast(`${paidCount}/${postedEligibleIds.length} posted ordre sat til paid`, paidCount > 0 ? 'success' : 'warning');
} catch (error) {
showToast(`Fejl: ${error.message}`, 'danger');
}
}
async function saveQuickSyncStatus(orderId) {
const select = document.getElementById(`syncStatus-${orderId}`);
const syncStatus = (select?.value || '').trim().toLowerCase();
if (!syncStatus) return;
try {
const res = await fetch(`/api/v1/ordre/drafts/${orderId}/sync-status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sync_status: syncStatus }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke opdatere sync status');
await loadOrders();
showToast(`Sync status gemt for ordre #${orderId}`, 'success');
} catch (error) {
showToast(`Fejl: ${error.message}`, 'danger');
}
}
async function markOrderPaid(orderId) {
if (!confirm('Markér ordren som betalt?')) return;
try {
const res = await fetch('/api/v1/billing/drafts/reconcile-sync-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apply: true, mark_paid_ids: [orderId] }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke markere som betalt');
await loadOrders();
showToast(`Ordre #${orderId} markeret som betalt`, 'success');
} catch (error) {
showToast(`Fejl: ${error.message}`, 'danger');
} }
} }
@ -449,9 +211,9 @@
} }
await loadOrders(); await loadOrders();
showToast('Ordre slettet', 'success'); alert('Ordre slettet');
} catch (error) { } catch (error) {
showToast(`Fejl: ${error.message}`, 'danger'); alert(`Fejl: ${error.message}`);
} }
} }

View File

@ -1,9 +1,6 @@
import logging import logging
import os import os
import shutil import shutil
import json
import re
import hashlib
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
@ -53,64 +50,15 @@ def _get_user_id_from_request(request: Request) -> int:
def _normalize_case_status(status_value: Optional[str]) -> str: def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = []
seen = set()
def _add_status(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
allowed_statuses.append(candidate)
try:
setting_row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("case_statuses",))
if setting_row and setting_row.get("value"):
parsed = json.loads(setting_row.get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
else:
value = ""
_add_status(value)
except Exception:
pass
# Include historical/current DB statuses so legacy values remain valid
try:
rows = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in rows:
_add_status(row.get("status"))
except Exception:
pass
if not allowed_statuses:
allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
allowed_map = {s.lower(): s for s in allowed_statuses}
if not status_value: if not status_value:
return allowed_map.get("åben", allowed_statuses[0]) return "åben"
normalized = str(status_value).strip().lower() normalized = str(status_value).strip().lower()
if normalized in allowed_map: if normalized == "afventer":
return allowed_map[normalized] return "åben"
if normalized in {"åben", "lukket"}:
# Backward compatibility for legacy mapping return normalized
if normalized == "afventer" and "åben" in allowed_map: return "åben"
return allowed_map["åben"]
# Do not force unknown values back to default; preserve user-entered/custom DB values
raw_value = str(status_value).strip()
if raw_value:
return raw_value
return allowed_map.get("åben", allowed_statuses[0])
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]: def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
@ -190,131 +138,6 @@ def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
return list(dict.fromkeys(cleaned)) return list(dict.fromkeys(cleaned))
def _normalize_message_id_token(value: Optional[str]) -> Optional[str]:
if not value:
return None
normalized = str(value).strip().strip("<>").lower()
normalized = "".join(normalized.split())
return normalized or None
def _derive_thread_key_for_outbound(
payload_thread_key: Optional[str],
in_reply_to_header: Optional[str],
references_header: Optional[str],
generated_message_id: Optional[str],
) -> Optional[str]:
provided = _normalize_message_id_token(payload_thread_key)
if provided:
return provided
if references_header:
for token in re.split(r"[\s,]+", references_header.strip()):
normalized = _normalize_message_id_token(token)
if normalized:
return normalized
reply_to = _normalize_message_id_token(in_reply_to_header)
if reply_to:
return reply_to
return _normalize_message_id_token(generated_message_id)
def _get_signature_template() -> str:
default_template = (
"{full_name}\n"
"{title}\n"
"{company_name}\n"
"Telefon: {company_phone}\n"
"Email: {email}\n"
"Web: {company_website}\n"
"Adresse: {company_address}\n"
"BMCid: {bmc_id_tag}"
)
row = execute_query_single(
"SELECT value FROM settings WHERE key = %s",
("email_default_signature_template",),
)
value = (row or {}).get("value") if row else None
return (value or default_template).strip()
def _build_case_bmc_id_tag(sag_id: int, thread_key: Optional[str] = None) -> str:
"""Build a case+thread marker used in signatures for helpdesk routing hints.
Example: s53t472193
- s53 => case id 53
- t472193 => deterministic per-thread suffix (or 001 when unknown)
"""
if not thread_key:
return f"s{sag_id}t001"
normalized = re.sub(r"[^a-z0-9]+", "", str(thread_key).lower())
if not normalized:
return f"s{sag_id}t001"
digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()
thread_suffix = (int(digest[:8], 16) % 900000) + 100000
return f"s{sag_id}t{thread_suffix}"
def _render_signature_for_case_email(user_id: int, sag_id: int, thread_key: Optional[str] = None) -> str:
user = execute_query_single(
"SELECT full_name, email FROM users WHERE user_id = %s",
(user_id,),
) or {}
company_rows = execute_query(
"SELECT key, value FROM settings WHERE key IN ('company_name', 'company_phone', 'company_address', 'company_email', 'company_website')",
(),
) or []
company = {row.get("key"): row.get("value") for row in company_rows}
context = {
"full_name": (user.get("full_name") or "BMC Hub").strip(),
"title": "IT-Supporter",
"email": (user.get("email") or company.get("company_email") or "").strip(),
"company_name": (company.get("company_name") or "BMC Networks ApS").strip(),
"company_phone": (company.get("company_phone") or "").strip(),
"company_website": (company.get("company_website") or "https://bmcnetworks.dk").strip(),
"company_address": (company.get("company_address") or "").strip(),
"bmc_id_tag": _build_case_bmc_id_tag(sag_id, thread_key),
}
template = _get_signature_template()
signature = template
for key, value in context.items():
signature = signature.replace(f"{{{key}}}", value)
if "BMCid:" not in signature:
signature = f"{signature}\nBMCid: {context['bmc_id_tag']}"
return signature.strip()
def _append_signature_to_body(body_text: str, signature: str) -> str:
clean_body = (body_text or "").rstrip()
clean_signature = (signature or "").strip()
if not clean_signature:
return clean_body
return f"{clean_body}\n\n--\n{clean_signature}" if clean_body else clean_signature
def _append_signature_to_html(body_html: Optional[str], signature: str) -> Optional[str]:
clean_signature = (signature or "").strip()
if not clean_signature:
return body_html
# If original message is plain text, do not synthesize an HTML-only body.
# That can cause clients/providers to display only the signature.
if not body_html or not body_html.strip():
return None
signature_html = clean_signature.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br>")
return f"{body_html}<br><br>--<br>{signature_html}"
@router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis) @router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis)
async def analyze_quick_create(request: QuickCreateRequest): async def analyze_quick_create(request: QuickCreateRequest):
""" """
@ -403,8 +226,8 @@ async def list_sager(
cases = [c for c in cases if c["id"] in tagged_ids] cases = [c for c in cases if c["id"] in tagged_ids]
return cases return cases
except Exception: except Exception as e:
logger.exception("❌ Error listing cases") logger.error("❌ Error listing cases: %s", e)
raise HTTPException(status_code=500, detail="Failed to list cases") raise HTTPException(status_code=500, detail="Failed to list cases")
@router.get("/sag/sale-items/all") @router.get("/sag/sale-items/all")
@ -588,7 +411,7 @@ async def list_todo_steps(sag_id: int):
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
WHERE t.sag_id = %s AND t.deleted_at IS NULL WHERE t.sag_id = %s AND t.deleted_at IS NULL
ORDER BY t.is_done ASC, t.is_next DESC, t.due_date NULLS LAST, t.created_at DESC ORDER BY t.is_done ASC, t.due_date NULLS LAST, t.created_at DESC
""" """
return execute_query(query, (sag_id,)) or [] return execute_query(query, (sag_id,)) or []
except Exception as e: except Exception as e:
@ -643,62 +466,33 @@ async def create_todo_step(sag_id: int, request: Request, data: TodoStepCreate):
@router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep) @router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep)
async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate): async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate):
try: try:
if data.is_done is None and data.is_next is None: if data.is_done is None:
raise HTTPException(status_code=400, detail="Provide is_done or is_next") raise HTTPException(status_code=400, detail="is_done is required")
step_row = execute_query_single( user_id = _get_user_id_from_request(request)
"SELECT id, sag_id, is_done FROM sag_todo_steps WHERE id = %s AND deleted_at IS NULL", if data.is_done:
(step_id,) update_query = """
)
if not step_row:
raise HTTPException(status_code=404, detail="Todo step not found")
if data.is_done is not None:
user_id = _get_user_id_from_request(request)
if data.is_done:
update_query = """
UPDATE sag_todo_steps
SET is_done = TRUE,
is_next = FALSE,
completed_by_user_id = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
execute_query(update_query, (user_id, step_id))
else:
update_query = """
UPDATE sag_todo_steps
SET is_done = FALSE,
completed_by_user_id = NULL,
completed_at = NULL
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
execute_query(update_query, (step_id,))
if data.is_next is not None:
if step_row.get("is_done") and data.is_next:
raise HTTPException(status_code=400, detail="Completed todo cannot be marked as next")
if data.is_next:
execute_query(
"""
UPDATE sag_todo_steps
SET is_next = FALSE
WHERE sag_id = %s AND deleted_at IS NULL
""",
(step_row["sag_id"],)
)
execute_query(
"""
UPDATE sag_todo_steps UPDATE sag_todo_steps
SET is_next = %s SET is_done = TRUE,
completed_by_user_id = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL WHERE id = %s AND deleted_at IS NULL
""", RETURNING id
(bool(data.is_next), step_id) """
) result = execute_query(update_query, (user_id, step_id))
else:
update_query = """
UPDATE sag_todo_steps
SET is_done = FALSE,
completed_by_user_id = NULL,
completed_at = NULL
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(update_query, (step_id,))
if not result:
raise HTTPException(status_code=404, detail="Todo step not found")
return execute_query( return execute_query(
""" """
@ -758,12 +552,8 @@ async def update_sag(sag_id: int, updates: dict):
updates["status"] = _normalize_case_status(updates.get("status")) updates["status"] = _normalize_case_status(updates.get("status"))
if "deadline" in updates: if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline") updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates:
updates["start_date"] = _normalize_optional_timestamp(updates.get("start_date"), "start_date")
if "deferred_until" in updates: if "deferred_until" in updates:
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until") updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
if "priority" in updates:
updates["priority"] = (str(updates.get("priority") or "").strip().lower() or "normal")
if "ansvarlig_bruger_id" in updates: if "ansvarlig_bruger_id" in updates:
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id") updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
_validate_user_id(updates["ansvarlig_bruger_id"]) _validate_user_id(updates["ansvarlig_bruger_id"])
@ -779,8 +569,6 @@ async def update_sag(sag_id: int, updates: dict):
"status", "status",
"ansvarlig_bruger_id", "ansvarlig_bruger_id",
"assigned_group_id", "assigned_group_id",
"priority",
"start_date",
"deadline", "deadline",
"deferred_until", "deferred_until",
"deferred_until_case_id", "deferred_until_case_id",
@ -2028,7 +1816,7 @@ async def get_kommentarer(sag_id: int):
if not check: if not check:
raise HTTPException(status_code=404, detail="Case not found") raise HTTPException(status_code=404, detail="Case not found")
query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at ASC"
result = execute_query(query, (sag_id,)) result = execute_query(query, (sag_id,))
return result return result
except HTTPException: except HTTPException:
@ -2038,7 +1826,7 @@ async def get_kommentarer(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to get comments") raise HTTPException(status_code=500, detail="Failed to get comments")
@router.post("/sag/{sag_id}/kommentarer") @router.post("/sag/{sag_id}/kommentarer")
async def add_kommentar(sag_id: int, data: dict, request: Request): async def add_kommentar(sag_id: int, data: dict):
"""Add a comment to a case.""" """Add a comment to a case."""
try: try:
if not data.get("indhold"): if not data.get("indhold"):
@ -2048,34 +1836,10 @@ async def add_kommentar(sag_id: int, data: dict, request: Request):
if not check: if not check:
raise HTTPException(status_code=404, detail="Case not found") raise HTTPException(status_code=404, detail="Case not found")
er_system_besked = bool(data.get("er_system_besked", False)) # Default author to current user or provided in body (if system)
# simplistic auth for now
if er_system_besked: forfatter = data.get("forfatter", "Bruger")
forfatter = str(data.get("forfatter") or "System").strip() or "System" er_system_besked = data.get("er_system_besked", False)
else:
forfatter = None
try:
user_id = _get_user_id_from_request(request)
user_row = execute_query_single(
"""
SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), '')) AS display_name
FROM users
WHERE user_id = %s
""",
(user_id,),
)
if user_row and user_row.get("display_name"):
forfatter = str(user_row.get("display_name")).strip()
except HTTPException:
# Fallback to provided author for legacy callers without auth context.
pass
if not forfatter:
provided_author = str(data.get("forfatter") or "").strip()
if provided_author and provided_author.lower() != "bruger":
forfatter = provided_author
else:
forfatter = "Bruger"
query = """ query = """
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
@ -2269,33 +2033,19 @@ async def get_sag_emails(sag_id: int):
SELECT SELECT
e.*, e.*,
COALESCE( COALESCE(
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.thread_key, '')), '[<>\\s]', '', 'g'), ''),
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.in_reply_to, '')), '[<>\\s]', '', 'g'), ''), NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.in_reply_to, '')), '[<>\\s]', '', 'g'), ''),
NULLIF(REGEXP_REPLACE((REGEXP_SPLIT_TO_ARRAY(COALESCE(e.email_references, ''), E'[\\s,]+'))[1], '[<>\\s]', '', 'g'), ''), NULLIF(REGEXP_REPLACE((REGEXP_SPLIT_TO_ARRAY(COALESCE(e.email_references, ''), E'[\\s,]+'))[1], '[<>\\s]', '', 'g'), ''),
NULLIF(
REGEXP_REPLACE(
LOWER(TRIM(COALESCE(e.subject, ''))),
'^(?:re|fw|fwd)\\s*:\\s*',
'',
'g'
),
''
),
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.message_id, '')), '[<>\\s]', '', 'g'), ''), NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.message_id, '')), '[<>\\s]', '', 'g'), ''),
CONCAT('email-', e.id::text) CONCAT('email-', e.id::text)
) AS resolved_thread_key ) AS thread_key
FROM email_messages e FROM email_messages e
JOIN sag_emails se ON e.id = se.email_id JOIN sag_emails se ON e.id = se.email_id
WHERE se.sag_id = %s WHERE se.sag_id = %s
) )
SELECT SELECT
linked_emails.*, linked_emails.*,
( COUNT(*) OVER (PARTITION BY linked_emails.thread_key) AS thread_message_count,
LOWER(COALESCE(linked_emails.folder, '')) LIKE 'sent%%' MAX(linked_emails.received_date) OVER (PARTITION BY linked_emails.thread_key) AS thread_last_received_date
OR LOWER(COALESCE(linked_emails.status, '')) = 'sent'
) AS is_outgoing,
COUNT(*) OVER (PARTITION BY linked_emails.resolved_thread_key) AS thread_message_count,
MAX(linked_emails.received_date) OVER (PARTITION BY linked_emails.resolved_thread_key) AS thread_last_received_date
FROM linked_emails FROM linked_emails
ORDER BY thread_last_received_date DESC NULLS LAST, received_date DESC ORDER BY thread_last_received_date DESC NULLS LAST, received_date DESC
""" """
@ -2403,7 +2153,7 @@ async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
@router.post("/sag/{sag_id}/emails/send") @router.post("/sag/{sag_id}/emails/send")
async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Request): async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
"""Send outbound email directly from case email tab and link it to case.""" """Send outbound email directly from case email tab and link it to case."""
case_exists = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) case_exists = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not case_exists: if not case_exists:
@ -2423,11 +2173,6 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
if not body_text: if not body_text:
raise HTTPException(status_code=400, detail="body_text is required") raise HTTPException(status_code=400, detail="body_text is required")
try:
user_id = _get_user_id_from_request(request)
except HTTPException:
user_id = 1
attachment_rows = [] attachment_rows = []
attachment_ids = list(dict.fromkeys(payload.attachment_file_ids or [])) attachment_ids = list(dict.fromkeys(payload.attachment_file_ids or []))
if attachment_ids: if attachment_ids:
@ -2492,22 +2237,12 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
else: else:
references_header = base_message_id references_header = base_message_id
provisional_thread_key = _derive_thread_key_for_outbound(
payload.thread_key,
in_reply_to_header,
references_header,
None,
)
signature = _render_signature_for_case_email(user_id, sag_id, provisional_thread_key)
body_text = _append_signature_to_body(body_text, signature)
body_html = _append_signature_to_html(payload.body_html, signature)
email_service = EmailService() email_service = EmailService()
success, send_message, generated_message_id, provider_thread_key = await email_service.send_email_with_attachments( success, send_message, generated_message_id = await email_service.send_email_with_attachments(
to_addresses=to_addresses, to_addresses=to_addresses,
subject=subject, subject=subject,
body_text=body_text, body_text=body_text,
body_html=body_html, body_html=payload.body_html,
cc=cc_addresses, cc=cc_addresses,
bcc=bcc_addresses, bcc=bcc_addresses,
in_reply_to=in_reply_to_header, in_reply_to=in_reply_to_header,
@ -2518,23 +2253,10 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
if not success: if not success:
logger.error("❌ Failed to send case email for case %s: %s", sag_id, send_message) logger.error("❌ Failed to send case email for case %s: %s", sag_id, send_message)
failure_detail = str(send_message or "Email send failed without provider detail").strip() raise HTTPException(status_code=500, detail="Failed to send email")
# Keep error user-facing but concise; frontend will display this detail directly.
failure_detail = failure_detail.replace("", "").strip()
if failure_detail.lower() == "failed to send email":
failure_detail = "Email send failed (provider returned generic error; check Graph app permissions and API logs)"
raise HTTPException(status_code=500, detail=f"{failure_detail} [diag:sag-send-v3]")
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub" sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or "" sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
thread_key = _normalize_message_id_token(provider_thread_key)
if not thread_key:
thread_key = _derive_thread_key_for_outbound(
payload.thread_key,
in_reply_to_header,
references_header,
generated_message_id,
)
insert_result = None insert_result = None
try: try:
@ -2542,30 +2264,11 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
INSERT INTO email_messages ( INSERT INTO email_messages (
message_id, subject, sender_email, sender_name, message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text, body_html, recipient_email, cc, body_text, body_html,
in_reply_to, email_references, thread_key, in_reply_to, email_references,
received_date, folder, has_attachments, attachment_count, received_date, folder, has_attachments, attachment_count,
status, import_method, linked_case_id status, import_method, linked_case_id
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (message_id) DO UPDATE
SET
subject = EXCLUDED.subject,
sender_email = EXCLUDED.sender_email,
sender_name = EXCLUDED.sender_name,
recipient_email = EXCLUDED.recipient_email,
cc = EXCLUDED.cc,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
in_reply_to = COALESCE(EXCLUDED.in_reply_to, email_messages.in_reply_to),
email_references = COALESCE(EXCLUDED.email_references, email_messages.email_references),
thread_key = COALESCE(EXCLUDED.thread_key, email_messages.thread_key),
folder = 'Sent',
has_attachments = EXCLUDED.has_attachments,
attachment_count = EXCLUDED.attachment_count,
status = 'sent',
import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method),
linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id),
updated_at = CURRENT_TIMESTAMP
RETURNING id RETURNING id
""" """
insert_result = execute_query( insert_result = execute_query(
@ -2578,16 +2281,15 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
", ".join(to_addresses), ", ".join(to_addresses),
", ".join(cc_addresses), ", ".join(cc_addresses),
body_text, body_text,
body_html, payload.body_html,
in_reply_to_header, in_reply_to_header,
references_header, references_header,
thread_key,
datetime.now(), datetime.now(),
"Sent", "Sent",
bool(smtp_attachments), bool(smtp_attachments),
len(smtp_attachments), len(smtp_attachments),
"sent", "sent",
"manual_upload", "sag_outbound",
sag_id, sag_id,
), ),
) )
@ -2600,22 +2302,6 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
status, import_method, linked_case_id status, import_method, linked_case_id
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (message_id) DO UPDATE
SET
subject = EXCLUDED.subject,
sender_email = EXCLUDED.sender_email,
sender_name = EXCLUDED.sender_name,
recipient_email = EXCLUDED.recipient_email,
cc = EXCLUDED.cc,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
folder = 'Sent',
has_attachments = EXCLUDED.has_attachments,
attachment_count = EXCLUDED.attachment_count,
status = 'sent',
import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method),
linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id),
updated_at = CURRENT_TIMESTAMP
RETURNING id RETURNING id
""" """
insert_result = execute_query( insert_result = execute_query(
@ -2628,13 +2314,13 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
", ".join(to_addresses), ", ".join(to_addresses),
", ".join(cc_addresses), ", ".join(cc_addresses),
body_text, body_text,
body_html, payload.body_html,
datetime.now(), datetime.now(),
"Sent", "Sent",
bool(smtp_attachments), bool(smtp_attachments),
len(smtp_attachments), len(smtp_attachments),
"sent", "sent",
"manual_upload", "sag_outbound",
sag_id, sag_id,
), ),
) )
@ -2675,32 +2361,6 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
(sag_id, email_id), (sag_id, email_id),
) )
sent_ts = datetime.now().isoformat()
outgoing_comment = (
"📧 Udgående email\n"
f"Email-ID: {email_id}\n"
f"Fra: {sender_email or sender_name or 'BMC Hub'}\n"
f"Til: {', '.join(to_addresses)}\n"
f"Emne: {subject}\n"
f"Modtaget: {sent_ts}\n\n"
f"{body_text}"
)
comment_row = execute_query_single(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
RETURNING kommentar_id, created_at
""",
(sag_id, 'Email Bot', outgoing_comment, True),
) or {}
comment_created_at = comment_row.get("created_at")
if isinstance(comment_created_at, datetime):
comment_created_at = comment_created_at.isoformat()
elif comment_created_at is None:
comment_created_at = sent_ts
logger.info( logger.info(
"✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)", "✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)",
sag_id, sag_id,
@ -2713,13 +2373,6 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
"status": "sent", "status": "sent",
"email_id": email_id, "email_id": email_id,
"message": send_message, "message": send_message,
"comment": {
"kommentar_id": comment_row.get("kommentar_id"),
"forfatter": "Email Bot",
"indhold": outgoing_comment,
"created_at": comment_created_at,
"er_system_besked": True,
},
} }
# ============================================================================ # ============================================================================

View File

@ -1,5 +1,4 @@
import logging import logging
import json
from datetime import date, datetime from datetime import date, datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
@ -57,50 +56,6 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
return None return None
def _fetch_case_status_options() -> list[str]:
default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
values = []
seen = set()
def _add(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
values.append(candidate)
setting_row = execute_query(
"SELECT value FROM settings WHERE key = %s",
("case_statuses",)
)
if setting_row and setting_row[0].get("value"):
try:
parsed = json.loads(setting_row[0].get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
value = ""
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
_add(value)
except Exception:
pass
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in statuses:
_add(row.get("status"))
for default in default_statuses:
_add(default)
return values
@router.get("/sag", response_class=HTMLResponse) @router.get("/sag", response_class=HTMLResponse)
async def sager_liste( async def sager_liste(
request: Request, request: Request,
@ -122,9 +77,7 @@ async def sager_liste(
c.name as customer_name, c.name as customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn, CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name, g.name AS assigned_group_name
nt.title AS next_todo_title,
nt.due_date AS next_todo_due_date
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
@ -137,22 +90,6 @@ async def sager_liste(
LIMIT 1 LIMIT 1
) cc_first ON true ) cc_first ON true
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id LEFT JOIN contacts cont ON cc_first.contact_id = cont.id
LEFT JOIN LATERAL (
SELECT t.title, t.due_date
FROM sag_todo_steps t
WHERE t.sag_id = s.id
AND t.deleted_at IS NULL
AND t.is_done = FALSE
ORDER BY
CASE
WHEN t.is_next THEN 0
WHEN t.due_date IS NOT NULL THEN 1
ELSE 2
END,
t.due_date ASC NULLS LAST,
t.created_at ASC
LIMIT 1
) nt ON true
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
""" """
@ -225,11 +162,7 @@ async def sager_liste(
sager = [s for s in sager if s['id'] in tagged_ids] sager = [s for s in sager if s['id'] in tagged_ids]
# Fetch all distinct statuses and tags for filters # Fetch all distinct statuses and tags for filters
status_options = _fetch_case_status_options() statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
current_status = str(status or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ()) all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ())
toggle_include_deferred_url = str( toggle_include_deferred_url = str(
@ -241,7 +174,7 @@ async def sager_liste(
"sager": sager, "sager": sager,
"relations_map": relations_map, "relations_map": relations_map,
"child_ids": list(child_ids), "child_ids": list(child_ids),
"statuses": status_options, "statuses": [s['status'] for s in statuses],
"all_tags": [t['tag_navn'] for t in all_tags], "all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status, "current_status": status,
"current_tag": tag, "current_tag": tag,
@ -252,24 +185,9 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
}) })
except Exception: except Exception as e:
logger.exception("❌ Error displaying case list") logger.error("❌ Error displaying case list: %s", e)
return templates.TemplateResponse("modules/sag/templates/index.html", { raise HTTPException(status_code=500, detail="Failed to load case list")
"request": request,
"sager": [],
"relations_map": {},
"child_ids": [],
"statuses": _fetch_case_status_options(),
"all_tags": [],
"current_status": status,
"current_tag": tag,
"include_deferred": include_deferred,
"toggle_include_deferred_url": str(request.url),
"assignment_users": [],
"assignment_groups": [],
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
})
@router.get("/sag/new", response_class=HTMLResponse) @router.get("/sag/new", response_class=HTMLResponse)
async def opret_sag_side(request: Request): async def opret_sag_side(request: Request):
@ -469,7 +387,7 @@ async def sag_detaljer(request: Request, sag_id: int):
customers = execute_query(customers_query, (sag_id,)) customers = execute_query(customers_query, (sag_id,))
# Fetch comments # Fetch comments
comments_query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" comments_query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at ASC"
comments = execute_query(comments_query, (sag_id,)) comments = execute_query(comments_query, (sag_id,))
# Fetch Solution # Fetch Solution
@ -533,10 +451,7 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.warning("⚠️ Could not load pipeline stages: %s", e) logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = [] pipeline_stages = []
status_options = _fetch_case_status_options() statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
current_status = str(sag.get("status") or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail.html", { return templates.TemplateResponse("modules/sag/templates/detail.html", {
@ -560,7 +475,7 @@ async def sag_detaljer(request: Request, sag_id: int):
"nextcloud_instance": nextcloud_instance, "nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options, "related_case_options": related_case_options,
"pipeline_stages": pipeline_stages, "pipeline_stages": pipeline_stages,
"status_options": status_options, "status_options": [s["status"] for s in statuses],
"is_deadline_overdue": is_deadline_overdue, "is_deadline_overdue": is_deadline_overdue,
"assignment_users": _fetch_assignment_users(), "assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(), "assignment_groups": _fetch_assignment_groups(),

View File

@ -33,7 +33,7 @@ class RelationService:
# 2. Fetch details for these cases # 2. Fetch details for these cases
placeholders = ','.join(['%s'] * len(tree_ids)) placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"SELECT id, titel, status, type, template_key FROM sag_sager WHERE id IN ({placeholders})" tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})"
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))} tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
# 3. Fetch all edges between these cases # 3. Fetch all edges between these cases

File diff suppressed because it is too large Load Diff

View File

@ -17,14 +17,12 @@
.table-wrapper { .table-wrapper {
background: var(--bg-card); background: var(--bg-card);
border-radius: 12px; border-radius: 12px;
overflow-x: auto; overflow: hidden;
overflow-y: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.05);
} }
.sag-table { .sag-table {
width: 100%; width: 100%;
min-width: 1760px;
margin: 0; margin: 0;
} }
@ -34,13 +32,12 @@
} }
.sag-table thead th { .sag-table thead th {
padding: 0.6rem 0.75rem; padding: 0.8rem 1rem;
font-weight: 600; font-weight: 600;
font-size: 0.78rem; font-size: 0.85rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px; letter-spacing: 0.5px;
border: none; border: none;
white-space: nowrap;
} }
.sag-table tbody tr { .sag-table tbody tr {
@ -54,30 +51,9 @@
} }
.sag-table tbody td { .sag-table tbody td {
padding: 0.5rem 0.75rem; padding: 0.6rem 1rem;
vertical-align: top; vertical-align: middle;
font-size: 0.86rem; font-size: 0.9rem;
white-space: nowrap;
}
.sag-table td.col-company,
.sag-table td.col-contact,
.sag-table td.col-owner,
.sag-table td.col-group,
.sag-table td.col-desc {
white-space: normal;
}
.sag-table td.col-company,
.sag-table td.col-contact,
.sag-table td.col-owner,
.sag-table td.col-group {
max-width: 180px;
}
.sag-table td.col-desc {
min-width: 260px;
max-width: 360px;
} }
.sag-id { .sag-id {
@ -270,7 +246,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid" style="max-width: none; padding-top: 2rem;"> <div class="container-fluid" style="max-width: 1400px; padding-top: 2rem;">
<!-- Header --> <!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="margin: 0; color: var(--accent);"> <h1 style="margin: 0; color: var(--accent);">
@ -354,19 +330,17 @@
<table class="sag-table"> <table class="sag-table">
<thead> <thead>
<tr> <tr>
<th style="width: 90px;">SagsID</th> <th style="width: 90px;">ID</th>
<th style="width: 180px;">Virksom.</th> <th>Titel & Beskrivelse</th>
<th style="width: 150px;">Kontakt</th>
<th style="width: 300px;">Beskr.</th>
<th style="width: 120px;">Type</th> <th style="width: 120px;">Type</th>
<th style="width: 110px;">Prioritet</th> <th style="width: 180px;">Kunde</th>
<th style="width: 160px;">Ansvarl.</th> <th style="width: 150px;">Hovedkontakt</th>
<th style="width: 170px;">Gruppe/Level</th> <th style="width: 160px;">Ansvarlig</th>
<th style="width: 240px;">Næste todo</th> <th style="width: 160px;">Gruppe</th>
<th style="width: 120px;">Opret.</th> <th style="width: 100px;">Status</th>
<th style="width: 120px;">Start arbejde</th> <th style="width: 120px;">Udsat start</th>
<th style="width: 140px;">Start inden</th> <th style="width: 120px;">Oprettet</th>
<th style="width: 120px;">Deadline</th> <th style="width: 120px;">Opdateret</th>
</tr> </tr>
</thead> </thead>
<tbody id="sagTableBody"> <tbody id="sagTableBody">
@ -383,13 +357,7 @@
{% endif %} {% endif %}
<span class="sag-id">#{{ sag.id }}</span> <span class="sag-id">#{{ sag.id }}</span>
</td> </td>
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ sag.id }}'">
{{ sag.customer_name if sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'">
<div class="sag-titel">{{ sag.titel }}</div> <div class="sag-titel">{{ sag.titel }}</div>
{% if sag.beskrivelse %} {% if sag.beskrivelse %}
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div> <div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
@ -398,36 +366,29 @@
<td onclick="window.location.href='/sag/{{ sag.id }}'"> <td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span> <span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.priority if sag.priority else 'normal' }} {{ sag.customer_name if sag.customer_name else '-' }}
</td> </td>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }} {{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
</td> </td>
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }} {{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;"> <td onclick="window.location.href='/sag/{{ sag.id }}'">
{% if sag.next_todo_title %} <span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
<div>{{ sag.next_todo_title }}</div>
{% if sag.next_todo_due_date %}
<div class="small text-muted">Forfald: {{ sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
{% endif %}
{% else %}
-
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }} {{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }} {{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.updated_at.strftime('%d/%m-%Y') if sag.updated_at else '-' }}
</td> </td>
</tr> </tr>
{% if has_relations %} {% if has_relations %}
@ -441,13 +402,7 @@
<td> <td>
<span class="sag-id">#{{ related_sag.id }}</span> <span class="sag-id">#{{ related_sag.id }}</span>
</td> </td>
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'">
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}'">
{% for rt in all_rel_types %} {% for rt in all_rel_types %}
<span class="relation-badge">{{ rt }}</span> <span class="relation-badge">{{ rt }}</span>
{% endfor %} {% endfor %}
@ -459,36 +414,29 @@
<td onclick="window.location.href='/sag/{{ related_sag.id }}'"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span> <span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.priority if related_sag.priority else 'normal' }} {{ related_sag.customer_name if related_sag.customer_name else '-' }}
</td> </td>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }} {{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
</td> </td>
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }} {{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'">
{% if related_sag.next_todo_title %} <span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
<div>{{ related_sag.next_todo_title }}</div>
{% if related_sag.next_todo_due_date %}
<div class="small text-muted">Forfald: {{ related_sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
{% endif %}
{% else %}
-
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.start_date.strftime('%d/%m-%Y') if related_sag.start_date else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }} {{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.deadline.strftime('%d/%m-%Y') if related_sag.deadline else '-' }} {{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.updated_at.strftime('%d/%m-%Y') if related_sag.updated_at else '-' }}
</td> </td>
</tr> </tr>
{% endif %} {% endif %}

View File

@ -12,31 +12,15 @@ import os
import shutil import shutil
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# APIRouter instance (module_loader kigger efter denne) # APIRouter instance (module_loader kigger efter denne)
router = APIRouter() router = APIRouter()
# Upload directory for logos (works in both Docker and local development) # Upload directory for logos
_logo_base_dir = os.path.abspath(settings.UPLOAD_DIR) LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
LOGO_UPLOAD_DIR = os.path.join(_logo_base_dir, "webshop_logos") os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
try:
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
except OSError as exc:
if _logo_base_dir.startswith('/app/'):
_fallback_base = os.path.abspath('uploads')
LOGO_UPLOAD_DIR = os.path.join(_fallback_base, "webshop_logos")
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
logger.warning(
"⚠️ Webshop logo dir %s not writable (%s). Using fallback %s",
_logo_base_dir,
exc,
LOGO_UPLOAD_DIR,
)
else:
raise
# ============================================================================ # ============================================================================

View File

@ -135,7 +135,7 @@ async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/{card_id:int}", response_model=Dict[str, Any]) @router.get("/prepaid-cards/{card_id}", response_model=Dict[str, Any])
async def get_prepaid_card(card_id: int): async def get_prepaid_card(card_id: int):
""" """
Get a specific prepaid card with transactions Get a specific prepaid card with transactions
@ -321,7 +321,7 @@ async def create_prepaid_card(card: PrepaidCardCreate):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put("/prepaid-cards/{card_id:int}/status") @router.put("/prepaid-cards/{card_id}/status")
async def update_card_status(card_id: int, status: str): async def update_card_status(card_id: int, status: str):
""" """
Update prepaid card status (cancel, reactivate) Update prepaid card status (cancel, reactivate)
@ -362,7 +362,7 @@ async def update_card_status(card_id: int, status: str):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put("/prepaid-cards/{card_id:int}/rounding", response_model=Dict[str, Any]) @router.put("/prepaid-cards/{card_id}/rounding", response_model=Dict[str, Any])
async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate): async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate):
""" """
Update rounding interval for a prepaid card (minutes) Update rounding interval for a prepaid card (minutes)
@ -394,7 +394,7 @@ async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete("/prepaid-cards/{card_id:int}") @router.delete("/prepaid-cards/{card_id}")
async def delete_prepaid_card(card_id: int): async def delete_prepaid_card(card_id: int):
""" """
Delete a prepaid card (only if no transactions) Delete a prepaid card (only if no transactions)

View File

@ -6,7 +6,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory=["app/prepaid/frontend", "app/shared/frontend", "app"]) templates = Jinja2Templates(directory=["app/prepaid/frontend", "app/shared/frontend"])
@router.get("/prepaid-cards", response_class=HTMLResponse) @router.get("/prepaid-cards", response_class=HTMLResponse)

View File

@ -113,9 +113,7 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
""" """
match_params = (product_id, supplier_name, supplier_sku) match_params = (product_id, supplier_name, supplier_sku)
existing = None existing = execute_query_single(match_query, match_params) if match_query else None
if match_query and match_params is not None:
existing = execute_query_single(match_query, match_params)
if existing: if existing:
update_query = """ update_query = """
@ -475,9 +473,6 @@ async def list_products(
minimum_term_months, minimum_term_months,
is_bundle, is_bundle,
billable, billable,
serial_number_required,
asset_required,
rental_asset_enabled,
image_url image_url
FROM products FROM products
{where_clause} {where_clause}
@ -531,9 +526,6 @@ async def create_product(payload: Dict[str, Any]):
parent_product_id, parent_product_id,
bundle_pricing_model, bundle_pricing_model,
billable, billable,
serial_number_required,
asset_required,
rental_asset_enabled,
default_case_tag, default_case_tag,
default_time_rate_id, default_time_rate_id,
category_id, category_id,
@ -556,8 +548,7 @@ async def create_product(payload: Dict[str, Any]):
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
%s, %s
) )
RETURNING * RETURNING *
""" """
@ -594,9 +585,6 @@ async def create_product(payload: Dict[str, Any]):
payload.get("parent_product_id"), payload.get("parent_product_id"),
payload.get("bundle_pricing_model"), payload.get("bundle_pricing_model"),
payload.get("billable", True), payload.get("billable", True),
payload.get("serial_number_required", False),
payload.get("asset_required", False),
payload.get("rental_asset_enabled", False),
payload.get("default_case_tag"), payload.get("default_case_tag"),
payload.get("default_time_rate_id"), payload.get("default_time_rate_id"),
payload.get("category_id"), payload.get("category_id"),
@ -646,7 +634,7 @@ async def update_product(
payload: Dict[str, Any], payload: Dict[str, Any],
current_user: dict = Depends(require_permission("products.update")) current_user: dict = Depends(require_permission("products.update"))
): ):
"""Update product fields for core metadata and billing validation flags.""" """Update product fields like name."""
try: try:
name = payload.get("name") name = payload.get("name")
if name is not None: if name is not None:
@ -654,45 +642,21 @@ async def update_product(
if not name: if not name:
raise HTTPException(status_code=400, detail="name cannot be empty") raise HTTPException(status_code=400, detail="name cannot be empty")
serial_number_required = payload.get("serial_number_required")
asset_required = payload.get("asset_required")
rental_asset_enabled = payload.get("rental_asset_enabled")
existing = execute_query_single( existing = execute_query_single(
""" "SELECT name FROM products WHERE id = %s AND deleted_at IS NULL",
SELECT name, serial_number_required, asset_required, rental_asset_enabled
FROM products
WHERE id = %s AND deleted_at IS NULL
""",
(product_id,) (product_id,)
) )
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
updates = ["updated_at = CURRENT_TIMESTAMP"] query = """
values: List[Any] = []
if name is not None:
updates.append("name = %s")
values.append(name)
if serial_number_required is not None:
updates.append("serial_number_required = %s")
values.append(bool(serial_number_required))
if asset_required is not None:
updates.append("asset_required = %s")
values.append(bool(asset_required))
if rental_asset_enabled is not None:
updates.append("rental_asset_enabled = %s")
values.append(bool(rental_asset_enabled))
values.append(product_id)
query = f"""
UPDATE products UPDATE products
SET {', '.join(updates)} SET name = COALESCE(%s, name),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL WHERE id = %s AND deleted_at IS NULL
RETURNING * RETURNING *
""" """
result = execute_query(query, tuple(values)) result = execute_query(query, (name, product_id))
if not result: if not result:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
if name is not None and name != existing.get("name"): if name is not None and name != existing.get("name"):
@ -702,15 +666,6 @@ async def update_product(
current_user.get("id") if current_user else None, current_user.get("id") if current_user else None,
{"old": existing.get("name"), "new": name} {"old": existing.get("name"), "new": name}
) )
for field in ("serial_number_required", "asset_required", "rental_asset_enabled"):
if field in payload and payload.get(field) != existing.get(field):
_log_product_audit(
product_id,
f"{field}_updated",
current_user.get("id") if current_user else None,
{"old": existing.get(field), "new": bool(payload.get(field))}
)
return result[0] return result[0]
except HTTPException: except HTTPException:
raise raise

View File

@ -784,7 +784,7 @@ class EconomicService:
invoice_date: str, invoice_date: str,
total_amount: float, total_amount: float,
vat_breakdown: Dict[str, float], vat_breakdown: Dict[str, float],
line_items: Optional[List[Dict]] = None, line_items: List[Dict] = None,
due_date: Optional[str] = None, due_date: Optional[str] = None,
text: Optional[str] = None) -> Dict: text: Optional[str] = None) -> Dict:
""" """
@ -983,12 +983,10 @@ class EconomicService:
data = await response.json() if response_text else {} data = await response.json() if response_text else {}
# e-conomic returns array of created vouchers # e-conomic returns array of created vouchers
if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict): if isinstance(data, list) and len(data) > 0:
voucher_data = data[0] voucher_data = data[0]
elif isinstance(data, dict):
voucher_data = data
else: else:
voucher_data = {} voucher_data = data
voucher_number = voucher_data.get('voucherNumber') voucher_number = voucher_data.get('voucherNumber')
logger.info(f"✅ Supplier invoice posted to kassekladde: voucher #{voucher_number}") logger.info(f"✅ Supplier invoice posted to kassekladde: voucher #{voucher_number}")
@ -1047,8 +1045,8 @@ class EconomicService:
url = f"{self.api_url}/journals/{journal_number}/vouchers/{accounting_year}-{voucher_number}/attachment/file" url = f"{self.api_url}/journals/{journal_number}/vouchers/{accounting_year}-{voucher_number}/attachment/file"
headers = { headers = {
'X-AppSecretToken': str(self.app_secret_token or ''), 'X-AppSecretToken': self.app_secret_token,
'X-AgreementGrantToken': str(self.agreement_grant_token or '') 'X-AgreementGrantToken': self.agreement_grant_token
} }
# Use multipart/form-data as required by e-conomic API # Use multipart/form-data as required by e-conomic API
@ -1072,55 +1070,6 @@ class EconomicService:
logger.error(f"❌ upload_voucher_attachment error: {e}") logger.error(f"❌ upload_voucher_attachment error: {e}")
return {"error": True, "message": str(e)} return {"error": True, "message": str(e)}
async def get_invoice_lifecycle_status(self, invoice_number: str) -> str:
"""
Resolve lifecycle status for an invoice number from e-conomic.
Returns one of: draft, booked, unpaid, paid, not_found, error
"""
invoice_number = str(invoice_number or "").strip()
if not invoice_number:
return "not_found"
endpoints = [
("paid", f"{self.api_url}/invoices/paid"),
("unpaid", f"{self.api_url}/invoices/unpaid"),
("booked", f"{self.api_url}/invoices/booked"),
("draft", f"{self.api_url}/invoices/drafts"),
]
try:
async with aiohttp.ClientSession() as session:
for status_name, endpoint in endpoints:
page = 0
while True:
async with session.get(
endpoint,
params={"pagesize": 1000, "skippages": page},
headers=self._get_headers(),
) as response:
if response.status != 200:
break
data = await response.json()
collection = data.get("collection", [])
if not collection:
break
for inv in collection:
inv_no = inv.get("draftInvoiceNumber") or inv.get("bookedInvoiceNumber")
if str(inv_no or "") == invoice_number:
return status_name
if len(collection) < 1000:
break
page += 1
return "not_found"
except Exception as e:
logger.error("❌ Error resolving invoice lifecycle status %s: %s", invoice_number, e)
return "error"
# Singleton instance # Singleton instance
_economic_service_instance = None _economic_service_instance = None

View File

@ -133,20 +133,13 @@ class EmailProcessorService:
classification = (email_data.get('classification') or '').strip().lower() classification = (email_data.get('classification') or '').strip().lower()
confidence = float(email_data.get('confidence_score') or 0.0) confidence = float(email_data.get('confidence_score') or 0.0)
require_manual_approval = getattr(settings, 'EMAIL_REQUIRE_MANUAL_APPROVAL', True) require_manual_approval = getattr(settings, 'EMAIL_REQUIRE_MANUAL_APPROVAL', True)
has_helpdesk_hint = email_workflow_service.has_helpdesk_routing_hint(email_data)
if has_helpdesk_hint: if require_manual_approval:
logger.info(
"🧵 Email %s has SAG/thread hint; bypassing manual approval gate for auto-routing",
email_id,
)
if require_manual_approval and not has_helpdesk_hint:
await self._set_awaiting_user_action(email_id, reason='manual_approval_required') await self._set_awaiting_user_action(email_id, reason='manual_approval_required')
stats['awaiting_user_action'] = True stats['awaiting_user_action'] = True
return stats return stats
if (not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD) and not has_helpdesk_hint: if not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD:
await self._set_awaiting_user_action(email_id, reason='low_confidence') await self._set_awaiting_user_action(email_id, reason='low_confidence')
stats['awaiting_user_action'] = True stats['awaiting_user_action'] = True
return stats return stats

View File

@ -13,12 +13,11 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email import encoders from email import encoders
from typing import List, Dict, Optional, Tuple, Any from typing import List, Dict, Optional, Tuple
from datetime import datetime from datetime import datetime
import json import json
import asyncio import asyncio
import base64 import base64
import re
from uuid import uuid4 from uuid import uuid4
# Try to import aiosmtplib, but don't fail if not available # Try to import aiosmtplib, but don't fail if not available
@ -58,186 +57,6 @@ class EmailService:
'client_secret': settings.GRAPH_CLIENT_SECRET, 'client_secret': settings.GRAPH_CLIENT_SECRET,
'user_email': settings.GRAPH_USER_EMAIL 'user_email': settings.GRAPH_USER_EMAIL
} }
def _graph_send_available(self) -> bool:
return bool(
self.use_graph
and self.graph_config.get('tenant_id')
and self.graph_config.get('client_id')
and self.graph_config.get('client_secret')
and self.graph_config.get('user_email')
)
async def _send_via_graph(
self,
to_addresses: List[str],
subject: str,
body_text: str,
body_html: Optional[str] = None,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
reply_to: Optional[str] = None,
in_reply_to: Optional[str] = None,
references: Optional[str] = None,
attachments: Optional[List[Dict]] = None,
) -> Tuple[bool, str, Optional[Dict[str, str]]]:
"""Send email via Microsoft Graph sendMail endpoint."""
access_token = await self._get_graph_access_token()
if not access_token:
return False, "Graph token acquisition failed", None
def _recipient(addr: str) -> Dict:
return {"emailAddress": {"address": addr}}
message: Dict = {
"subject": subject,
"body": {
"contentType": "HTML" if body_html else "Text",
"content": body_html or body_text,
},
"toRecipients": [_recipient(addr) for addr in (to_addresses or [])],
}
if cc:
message["ccRecipients"] = [_recipient(addr) for addr in cc]
if bcc:
message["bccRecipients"] = [_recipient(addr) for addr in bcc]
if reply_to:
message["replyTo"] = [_recipient(reply_to)]
# Microsoft Graph only allows custom internet headers prefixed with x-.
# Standard headers like In-Reply-To/References are rejected with
# InvalidInternetMessageHeader, so only attach safe diagnostic metadata.
headers = []
if in_reply_to:
headers.append({"name": "x-bmc-in-reply-to", "value": in_reply_to[:900]})
if references:
headers.append({"name": "x-bmc-references", "value": references[:900]})
if headers:
message["internetMessageHeaders"] = headers
graph_attachments = []
for attachment in (attachments or []):
content = attachment.get("content")
if not content:
continue
graph_attachments.append({
"@odata.type": "#microsoft.graph.fileAttachment",
"name": attachment.get("filename") or "attachment.bin",
"contentType": attachment.get("content_type") or "application/octet-stream",
"contentBytes": base64.b64encode(content).decode("ascii"),
})
if graph_attachments:
message["attachments"] = graph_attachments
url = f"https://graph.microsoft.com/v1.0/users/{self.graph_config['user_email']}/sendMail"
request_body = {
"message": message,
"saveToSentItems": True,
}
request_headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
try:
async with ClientSession() as session:
async with session.post(url, headers=request_headers, json=request_body) as response:
if response.status in (200, 202):
metadata = None
try:
metadata = await self._find_recent_sent_graph_message(
access_token=access_token,
subject=subject,
to_addresses=to_addresses,
)
except Exception as metadata_error:
logger.warning(
"⚠️ Graph send succeeded but SentItems metadata lookup failed: %s",
metadata_error,
)
return True, f"Email sent to {len(to_addresses)} recipient(s) via Graph", metadata
error_text = await response.text()
logger.error("❌ Graph send failed: status=%s body=%s", response.status, error_text)
return False, f"Graph send failed ({response.status}): {error_text[:300]}", None
except Exception as e:
return False, f"Graph send exception: {str(e)}", None
def _recipient_addresses_match(self, graph_recipients: Optional[List[Dict[str, Any]]], to_addresses: List[str]) -> bool:
if not to_addresses:
return True
expected = {addr.strip().lower() for addr in (to_addresses or []) if addr}
if not expected:
return True
actual = set()
for recipient in graph_recipients or []:
address = (
recipient.get("emailAddress", {}).get("address")
if isinstance(recipient, dict)
else None
)
if address:
actual.add(str(address).strip().lower())
return bool(actual) and expected.issubset(actual)
async def _find_recent_sent_graph_message(
self,
access_token: str,
subject: str,
to_addresses: List[str],
) -> Optional[Dict[str, str]]:
"""Best-effort lookup for the most recent sent Graph message metadata."""
try:
user_email = self.graph_config['user_email']
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/SentItems/messages"
params = {
'$top': 15,
'$orderby': 'sentDateTime desc',
'$select': 'id,subject,toRecipients,internetMessageId,conversationId,sentDateTime'
}
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
}
async with ClientSession() as session:
async with session.get(url, params=params, headers=headers) as response:
if response.status != 200:
logger.warning("⚠️ Could not read SentItems metadata (status=%s)", response.status)
return None
payload = await response.json()
messages = payload.get('value') or []
normalized_subject = (subject or '').strip().lower()
for msg in messages:
candidate_subject = str(msg.get('subject') or '').strip().lower()
if normalized_subject and candidate_subject != normalized_subject:
continue
if not self._recipient_addresses_match(msg.get('toRecipients'), to_addresses):
continue
internet_message_id = self._normalize_message_id_value(msg.get('internetMessageId'))
conversation_id = self._normalize_message_id_value(msg.get('conversationId'))
if internet_message_id or conversation_id:
logger.info(
"🧵 Matched sent Graph metadata (conversationId=%s, messageId=%s)",
conversation_id,
internet_message_id,
)
return {
'internet_message_id': internet_message_id,
'conversation_id': conversation_id,
}
except Exception as e:
logger.warning("⚠️ Failed to resolve sent Graph metadata: %s", e)
return None
async def fetch_new_emails(self, limit: int = 50) -> List[Dict]: async def fetch_new_emails(self, limit: int = 50) -> List[Dict]:
""" """
@ -353,7 +172,7 @@ class EmailService:
params = { params = {
'$top': limit, '$top': limit,
'$orderby': 'receivedDateTime desc', '$orderby': 'receivedDateTime desc',
'$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId,conversationId,internetMessageHeaders' '$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId'
} }
headers = { headers = {
@ -579,26 +398,10 @@ class EmailService:
received_date_str = msg.get('receivedDateTime', '') received_date_str = msg.get('receivedDateTime', '')
received_date = datetime.fromisoformat(received_date_str.replace('Z', '+00:00')) if received_date_str else datetime.now() received_date = datetime.fromisoformat(received_date_str.replace('Z', '+00:00')) if received_date_str else datetime.now()
headers = msg.get('internetMessageHeaders') or []
in_reply_to = None
references = None
for header in headers:
name = str(header.get('name') or '').strip().lower()
value = str(header.get('value') or '').strip()
if not value:
continue
if name == 'in-reply-to':
in_reply_to = value
elif name == 'references':
references = value
conversation_id = self._normalize_message_id_value(msg.get('conversationId'))
return { return {
'message_id': msg.get('internetMessageId', msg.get('id', '')), 'message_id': msg.get('internetMessageId', msg.get('id', '')),
'in_reply_to': in_reply_to, 'in_reply_to': None,
'email_references': references, 'email_references': None,
'thread_key': conversation_id,
'subject': msg.get('subject', ''), 'subject': msg.get('subject', ''),
'sender_name': sender_name, 'sender_name': sender_name,
'sender_email': sender_email, 'sender_email': sender_email,
@ -706,46 +509,6 @@ class EmailService:
else: else:
# Just email address # Just email address
return ("", header.strip()) return ("", header.strip())
def _normalize_message_id_value(self, value: Optional[str]) -> Optional[str]:
"""Normalize message-id like tokens for stable thread matching."""
if not value:
return None
normalized = str(value).strip().strip("<>").lower()
normalized = "".join(normalized.split())
return normalized or None
def _extract_reference_ids(self, raw_references: Optional[str]) -> List[str]:
if not raw_references:
return []
refs: List[str] = []
for token in re.split(r"[\s,]+", str(raw_references).strip()):
normalized = self._normalize_message_id_value(token)
if normalized:
refs.append(normalized)
return list(dict.fromkeys(refs))
def _derive_thread_key(self, email_data: Dict) -> Optional[str]:
"""
Derive a stable conversation key.
Priority:
1) First References token (root message id)
2) In-Reply-To
3) Message-ID
"""
explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
if explicit_thread_key:
return explicit_thread_key
reference_ids = self._extract_reference_ids(email_data.get("email_references"))
if reference_ids:
return reference_ids[0]
in_reply_to = self._normalize_message_id_value(email_data.get("in_reply_to"))
if in_reply_to:
return in_reply_to
return self._normalize_message_id_value(email_data.get("message_id"))
def _parse_email_date(self, date_str: str) -> datetime: def _parse_email_date(self, date_str: str) -> datetime:
"""Parse email date header into datetime object""" """Parse email date header into datetime object"""
@ -769,62 +532,32 @@ class EmailService:
async def save_email(self, email_data: Dict) -> Optional[int]: async def save_email(self, email_data: Dict) -> Optional[int]:
"""Save email to database""" """Save email to database"""
try: try:
thread_key = self._derive_thread_key(email_data) query = """
INSERT INTO email_messages
try: (message_id, subject, sender_email, sender_name, recipient_email, cc,
query = """ body_text, body_html, received_date, folder, has_attachments, attachment_count,
INSERT INTO email_messages in_reply_to, email_references,
(message_id, subject, sender_email, sender_name, recipient_email, cc, status, is_read)
body_text, body_html, received_date, folder, has_attachments, attachment_count, VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false)
in_reply_to, email_references, thread_key, RETURNING id
status, is_read) """
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false)
RETURNING id email_id = execute_insert(query, (
""" email_data['message_id'],
email_id = execute_insert(query, ( email_data['subject'],
email_data['message_id'], email_data['sender_email'],
email_data['subject'], email_data['sender_name'],
email_data['sender_email'], email_data['recipient_email'],
email_data['sender_name'], email_data['cc'],
email_data['recipient_email'], email_data['body_text'],
email_data['cc'], email_data['body_html'],
email_data['body_text'], email_data['received_date'],
email_data['body_html'], email_data['folder'],
email_data['received_date'], email_data['has_attachments'],
email_data['folder'], email_data['attachment_count'],
email_data['has_attachments'], email_data.get('in_reply_to'),
email_data['attachment_count'], email_data.get('email_references')
email_data.get('in_reply_to'), ))
email_data.get('email_references'),
thread_key,
))
except Exception:
query = """
INSERT INTO email_messages
(message_id, subject, sender_email, sender_name, recipient_email, cc,
body_text, body_html, received_date, folder, has_attachments, attachment_count,
in_reply_to, email_references,
status, is_read)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false)
RETURNING id
"""
email_id = execute_insert(query, (
email_data['message_id'],
email_data['subject'],
email_data['sender_email'],
email_data['sender_name'],
email_data['recipient_email'],
email_data['cc'],
email_data['body_text'],
email_data['body_html'],
email_data['received_date'],
email_data['folder'],
email_data['has_attachments'],
email_data['attachment_count'],
email_data.get('in_reply_to'),
email_data.get('email_references')
))
logger.info(f"✅ Saved email {email_id}: {email_data['subject'][:50]}...") logger.info(f"✅ Saved email {email_id}: {email_data['subject'][:50]}...")
@ -1146,70 +879,36 @@ class EmailService:
return None return None
# Insert email # Insert email
thread_key = self._derive_thread_key(email_data) query = """
try: INSERT INTO email_messages (
query = """ message_id, subject, sender_email, sender_name,
INSERT INTO email_messages ( recipient_email, cc, body_text, body_html,
message_id, subject, sender_email, sender_name, received_date, folder, has_attachments, attachment_count,
recipient_email, cc, body_text, body_html, in_reply_to, email_references,
received_date, folder, has_attachments, attachment_count, status, import_method, created_at
in_reply_to, email_references, thread_key, )
status, import_method, created_at VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
) RETURNING id
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) """
RETURNING id
""" result = execute_insert(query, (
email_data["message_id"],
result = execute_insert(query, ( email_data["subject"],
email_data["message_id"], email_data["sender_email"],
email_data["subject"], email_data["sender_name"],
email_data["sender_email"], email_data.get("recipient_email", ""),
email_data["sender_name"], email_data.get("cc", ""),
email_data.get("recipient_email", ""), email_data["body_text"],
email_data.get("cc", ""), email_data["body_html"],
email_data["body_text"], email_data["received_date"],
email_data["body_html"], email_data["folder"],
email_data["received_date"], email_data["has_attachments"],
email_data["folder"], len(email_data.get("attachments", [])),
email_data["has_attachments"], email_data.get("in_reply_to"),
len(email_data.get("attachments", [])), email_data.get("email_references"),
email_data.get("in_reply_to"), "new",
email_data.get("email_references"), "manual_upload"
thread_key, ))
"new",
"manual_upload"
))
except Exception:
query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text, body_html,
received_date, folder, has_attachments, attachment_count,
in_reply_to, email_references,
status, import_method, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING id
"""
result = execute_insert(query, (
email_data["message_id"],
email_data["subject"],
email_data["sender_email"],
email_data["sender_name"],
email_data.get("recipient_email", ""),
email_data.get("cc", ""),
email_data["body_text"],
email_data["body_html"],
email_data["received_date"],
email_data["folder"],
email_data["has_attachments"],
len(email_data.get("attachments", [])),
email_data.get("in_reply_to"),
email_data.get("email_references"),
"new",
"manual_upload"
))
if not result: if not result:
logger.error("❌ Failed to insert email - no ID returned") logger.error("❌ Failed to insert email - no ID returned")
@ -1259,37 +958,14 @@ class EmailService:
logger.warning(f"🔒 DRY RUN MODE: Would send email to {to_addresses} with subject '{subject}'") logger.warning(f"🔒 DRY RUN MODE: Would send email to {to_addresses} with subject '{subject}'")
return True, "Dry run mode - email not actually sent" return True, "Dry run mode - email not actually sent"
graph_failure_message: Optional[str] = None
# Prefer Graph send when Graph integration is enabled/configured.
if self._graph_send_available():
graph_ok, graph_message = await self._send_via_graph(
to_addresses=to_addresses,
subject=subject,
body_text=body_text,
body_html=body_html,
cc=cc,
bcc=bcc,
reply_to=reply_to,
)
if graph_ok:
logger.info("✅ Email sent via Graph to %s recipient(s): %s", len(to_addresses), subject)
return True, graph_message
graph_failure_message = graph_message
logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message)
# Check if aiosmtplib is available # Check if aiosmtplib is available
if not HAS_AIOSMTPLIB: if not HAS_AIOSMTPLIB:
logger.error("❌ aiosmtplib not installed - cannot send email. Install with: pip install aiosmtplib") logger.error("❌ aiosmtplib not installed - cannot send email. Install with: pip install aiosmtplib")
if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: aiosmtplib not installed"
return False, "aiosmtplib not installed" return False, "aiosmtplib not installed"
# Validate SMTP configuration # Validate SMTP configuration
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]): if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
logger.error("❌ SMTP not configured - cannot send email") logger.error("❌ SMTP not configured - cannot send email")
if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured"
return False, "SMTP not configured" return False, "SMTP not configured"
try: try:
@ -1337,10 +1013,8 @@ class EmailService:
return True, f"Email sent to {len(to_addresses)} recipient(s)" return True, f"Email sent to {len(to_addresses)} recipient(s)"
except Exception as e: except Exception as e:
error_msg = f"SMTP send error: {str(e)}" error_msg = f"Failed to send email: {str(e)}"
logger.error(error_msg) logger.error(error_msg)
if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}"
return False, error_msg return False, error_msg
async def send_email_with_attachments( async def send_email_with_attachments(
@ -1356,11 +1030,10 @@ class EmailService:
references: Optional[str] = None, references: Optional[str] = None,
attachments: Optional[List[Dict]] = None, attachments: Optional[List[Dict]] = None,
respect_dry_run: bool = True, respect_dry_run: bool = True,
) -> Tuple[bool, str, str, Optional[str]]: ) -> Tuple[bool, str, str]:
"""Send email and return status, message, message-id, and optional provider thread key.""" """Send email via SMTP with optional attachments and return generated Message-ID."""
generated_message_id = f"<{uuid4().hex}@bmchub.local>" generated_message_id = f"<{uuid4().hex}@bmchub.local>"
provider_thread_key: Optional[str] = None
if respect_dry_run and settings.REMINDERS_DRY_RUN: if respect_dry_run and settings.REMINDERS_DRY_RUN:
logger.warning( logger.warning(
@ -1368,53 +1041,15 @@ class EmailService:
to_addresses, to_addresses,
subject, subject,
) )
return True, "Dry run mode - email not actually sent", generated_message_id, provider_thread_key return True, "Dry run mode - email not actually sent", generated_message_id
graph_failure_message: Optional[str] = None
# Prefer Graph send when Graph integration is enabled/configured.
if self._graph_send_available():
graph_ok, graph_message, graph_metadata = await self._send_via_graph(
to_addresses=to_addresses,
subject=subject,
body_text=body_text,
body_html=body_html,
cc=cc,
bcc=bcc,
reply_to=reply_to,
in_reply_to=in_reply_to,
references=references,
attachments=attachments,
)
if graph_ok:
if graph_metadata:
graph_message_id = graph_metadata.get('internet_message_id')
graph_thread_key = graph_metadata.get('conversation_id')
if graph_message_id:
generated_message_id = graph_message_id
if graph_thread_key:
provider_thread_key = graph_thread_key
logger.info(
"✅ Email with attachments sent via Graph to %s recipient(s): %s (thread_key=%s)",
len(to_addresses),
subject,
provider_thread_key,
)
return True, graph_message, generated_message_id, provider_thread_key
graph_failure_message = graph_message
logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message)
if not HAS_AIOSMTPLIB: if not HAS_AIOSMTPLIB:
logger.error("❌ aiosmtplib not installed - cannot send email. Install with: pip install aiosmtplib") logger.error("❌ aiosmtplib not installed - cannot send email. Install with: pip install aiosmtplib")
if graph_failure_message: return False, "aiosmtplib not installed", generated_message_id
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: aiosmtplib not installed", generated_message_id, provider_thread_key
return False, "aiosmtplib not installed", generated_message_id, provider_thread_key
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]): if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
logger.error("❌ SMTP not configured - cannot send email") logger.error("❌ SMTP not configured - cannot send email")
if graph_failure_message: return False, "SMTP not configured", generated_message_id
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured", generated_message_id, provider_thread_key
return False, "SMTP not configured", generated_message_id, provider_thread_key
try: try:
msg = MIMEMultipart('mixed') msg = MIMEMultipart('mixed')
@ -1479,11 +1114,9 @@ class EmailService:
len(to_addresses), len(to_addresses),
subject, subject,
) )
return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id
except Exception as e: except Exception as e:
error_msg = f"SMTP send error (attachments): {str(e)}" error_msg = f"Failed to send email with attachments: {str(e)}"
logger.error(error_msg) logger.error(error_msg)
if graph_failure_message: return False, error_msg, generated_message_id
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}", generated_message_id, provider_thread_key
return False, error_msg, generated_message_id, provider_thread_key

View File

@ -53,21 +53,12 @@ class EmailWorkflowService:
return {'status': 'disabled', 'workflows_executed': 0} return {'status': 'disabled', 'workflows_executed': 0}
email_id = email_data.get('id') email_id = email_data.get('id')
classification = (email_data.get('classification') or '').strip().lower() classification = email_data.get('classification')
confidence = email_data.get('confidence_score', 0.0) confidence = email_data.get('confidence_score', 0.0)
has_hint = self.has_helpdesk_routing_hint(email_data)
if not email_id: if not email_id or not classification:
logger.warning("⚠️ Cannot execute workflows: missing email_id") logger.warning(f"⚠️ Cannot execute workflows: missing email_id or classification")
return {'status': 'skipped', 'reason': 'missing_data'} return {'status': 'skipped', 'reason': 'missing_data'}
if not classification:
if has_hint:
classification = 'general'
email_data['classification'] = classification
else:
logger.warning("⚠️ Cannot execute workflows: missing classification")
return {'status': 'skipped', 'reason': 'missing_data'}
logger.info(f"🔄 Finding workflows for classification: {classification} (confidence: {confidence})") logger.info(f"🔄 Finding workflows for classification: {classification} (confidence: {confidence})")
@ -91,14 +82,9 @@ class EmailWorkflowService:
logger.info("✅ Bankruptcy system workflow executed successfully") logger.info("✅ Bankruptcy system workflow executed successfully")
# Special System Workflow: Helpdesk SAG routing # Special System Workflow: Helpdesk SAG routing
# - If SAG/tråd-hint findes => forsøg altid routing til eksisterende sag # - If SAG-<id> is present in subject/header => update existing case
# - Uden hints: brug klassifikationsgating som før # - If no SAG id and sender domain matches customer => create new case
should_try_helpdesk = ( if classification not in self.HELPDESK_SKIP_CLASSIFICATIONS:
classification not in self.HELPDESK_SKIP_CLASSIFICATIONS
or has_hint
)
if should_try_helpdesk:
helpdesk_result = await self._handle_helpdesk_sag_routing(email_data) helpdesk_result = await self._handle_helpdesk_sag_routing(email_data)
if helpdesk_result: if helpdesk_result:
results['details'].append(helpdesk_result) results['details'].append(helpdesk_result)
@ -222,48 +208,17 @@ class EmailWorkflowService:
domain = domain[4:] domain = domain[4:]
return domain or None return domain or None
def has_helpdesk_routing_hint(self, email_data: Dict) -> bool:
"""Return True when email has explicit routing hints (SAG or thread headers/key)."""
if self._extract_sag_id(email_data):
return True
explicit_thread_key = self._normalize_message_id(email_data.get('thread_key'))
if explicit_thread_key:
return True
if self._normalize_message_id(email_data.get('in_reply_to')):
return True
if self._extract_reference_message_ids(email_data.get('email_references')):
return True
return False
def _extract_sag_id(self, email_data: Dict) -> Optional[int]: def _extract_sag_id(self, email_data: Dict) -> Optional[int]:
candidates = [ candidates = [
email_data.get('subject') or '', email_data.get('subject') or '',
email_data.get('in_reply_to') or '', email_data.get('in_reply_to') or '',
email_data.get('email_references') or '', email_data.get('email_references') or ''
email_data.get('body_text') or '',
email_data.get('body_html') or '',
]
# Accept both strict and human variants used in real subjects, e.g.:
# - SAG-53
# - SAG #53
# - Sag 53
sag_patterns = [
r'\bSAG-(\d+)\b',
r'\bSAG\s*#\s*(\d+)\b',
r'\bSAG\s+(\d+)\b',
r'\bBMCid\s*:\s*s(\d+)t\d+\b',
] ]
for value in candidates: for value in candidates:
for pattern in sag_patterns: match = re.search(r'\bSAG-(\d+)\b', value, re.IGNORECASE)
match = re.search(pattern, value, re.IGNORECASE) if match:
if match: return int(match.group(1))
return int(match.group(1))
return None return None
def _normalize_message_id(self, value: Optional[str]) -> Optional[str]: def _normalize_message_id(self, value: Optional[str]) -> Optional[str]:
@ -289,53 +244,6 @@ class EmailWorkflowService:
# De-duplicate while preserving order # De-duplicate while preserving order
return list(dict.fromkeys(tokens)) return list(dict.fromkeys(tokens))
def _extract_reference_message_ids(self, raw_references: Optional[str]) -> List[str]:
tokens: List[str] = []
if raw_references:
for ref in re.split(r'[\s,]+', str(raw_references).strip()):
normalized_ref = self._normalize_message_id(ref)
if normalized_ref:
tokens.append(normalized_ref)
return list(dict.fromkeys(tokens))
def _derive_thread_key(self, email_data: Dict) -> Optional[str]:
"""Derive stable conversation key: root References -> In-Reply-To -> Message-ID."""
explicit = self._normalize_message_id(email_data.get('thread_key'))
if explicit:
return explicit
ref_ids = self._extract_reference_message_ids(email_data.get('email_references'))
if ref_ids:
return ref_ids[0]
in_reply_to = self._normalize_message_id(email_data.get('in_reply_to'))
if in_reply_to:
return in_reply_to
return self._normalize_message_id(email_data.get('message_id'))
def _find_sag_id_from_thread_key(self, thread_key: Optional[str]) -> Optional[int]:
if not thread_key:
return None
# Backward compatibility when DB migration is not yet applied.
try:
rows = execute_query(
"""
SELECT se.sag_id
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
WHERE em.deleted_at IS NULL
AND LOWER(TRIM(COALESCE(em.thread_key, ''))) = %s
ORDER BY se.created_at DESC
LIMIT 1
""",
(thread_key,)
)
return rows[0]['sag_id'] if rows else None
except Exception:
return None
def _find_sag_id_from_thread_headers(self, email_data: Dict) -> Optional[int]: def _find_sag_id_from_thread_headers(self, email_data: Dict) -> Optional[int]:
thread_message_ids = self._extract_thread_message_ids(email_data) thread_message_ids = self._extract_thread_message_ids(email_data)
if not thread_message_ids: if not thread_message_ids:
@ -389,58 +297,15 @@ class EmailWorkflowService:
(sag_id, email_id, sag_id, email_id) (sag_id, email_id, sag_id, email_id)
) )
def _strip_quoted_email_text(self, body_text: str) -> str:
"""Return only the newest reply content (remove quoted history/signatures)."""
if not body_text:
return ""
text = str(body_text).replace("\r\n", "\n").replace("\r", "\n")
lines = text.split("\n")
cleaned_lines: List[str] = []
header_marker_re = re.compile(r'^(fra|from|sent|date|dato|to|til|emne|subject|cc):\s*', re.IGNORECASE)
original_message_re = re.compile(r'^(original message|oprindelig besked|videresendt besked)', re.IGNORECASE)
for idx, line in enumerate(lines):
stripped = line.strip()
lowered = stripped.lower()
if stripped.startswith('>'):
break
if original_message_re.match(stripped):
break
# Typical separator before quoted headers (e.g. "---" / "_____" lines)
if re.match(r'^[-_]{3,}$', stripped):
lookahead = lines[idx + 1: idx + 4]
if any(header_marker_re.match(candidate.strip()) for candidate in lookahead):
break
if idx > 0 and header_marker_re.match(stripped):
if lines[idx - 1].strip() == "":
break
cleaned_lines.append(line)
while cleaned_lines and cleaned_lines[-1].strip() == "":
cleaned_lines.pop()
return "\n".join(cleaned_lines).strip()
def _add_helpdesk_comment(self, sag_id: int, email_data: Dict) -> None: def _add_helpdesk_comment(self, sag_id: int, email_data: Dict) -> None:
email_id = email_data.get('id')
sender = email_data.get('sender_email') or 'ukendt' sender = email_data.get('sender_email') or 'ukendt'
subject = email_data.get('subject') or '(ingen emne)' subject = email_data.get('subject') or '(ingen emne)'
received = email_data.get('received_date') received = email_data.get('received_date')
received_str = received.isoformat() if hasattr(received, 'isoformat') else str(received or '') received_str = received.isoformat() if hasattr(received, 'isoformat') else str(received or '')
body_text = self._strip_quoted_email_text((email_data.get('body_text') or '').strip()) body_text = (email_data.get('body_text') or '').strip()
email_meta_line = f"Email-ID: {email_id}\n" if email_id else ""
comment = ( comment = (
f"📧 Indgående email\n" f"📧 Indgående email\n"
f"{email_meta_line}"
f"Fra: {sender}\n" f"Fra: {sender}\n"
f"Emne: {subject}\n" f"Emne: {subject}\n"
f"Modtaget: {received_str}\n\n" f"Modtaget: {received_str}\n\n"
@ -486,45 +351,12 @@ class EmailWorkflowService:
if not email_id: if not email_id:
return None return None
derived_thread_key = self._derive_thread_key(email_data) sag_id = self._extract_sag_id(email_data)
sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key) if not sag_id:
sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data) sag_id = self._find_sag_id_from_thread_headers(email_data)
sag_id_from_tag = self._extract_sag_id(email_data) if sag_id:
routing_source = None
sag_id = None
if sag_id_from_thread_key:
sag_id = sag_id_from_thread_key
routing_source = 'thread_key'
logger.info("🧵 Matched email %s to SAG-%s via thread key", email_id, sag_id)
if sag_id_from_thread:
if sag_id and sag_id != sag_id_from_thread:
logger.warning(
"⚠️ Email %s has conflicting thread matches (thread_key: SAG-%s, headers: SAG-%s). Using thread_key.",
email_id,
sag_id,
sag_id_from_thread,
)
elif not sag_id:
sag_id = sag_id_from_thread
routing_source = 'thread_headers'
logger.info("🔗 Matched email %s to SAG-%s via thread headers", email_id, sag_id) logger.info("🔗 Matched email %s to SAG-%s via thread headers", email_id, sag_id)
if sag_id_from_tag:
if sag_id and sag_id != sag_id_from_tag:
logger.warning(
"⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using thread match.",
email_id,
sag_id,
sag_id_from_tag
)
elif not sag_id:
sag_id = sag_id_from_tag
routing_source = 'sag_tag'
logger.info("🏷️ Matched email %s to SAG-%s via SAG tag", email_id, sag_id)
# 1) Existing SAG via subject/headers # 1) Existing SAG via subject/headers
if sag_id: if sag_id:
case_rows = execute_query( case_rows = execute_query(
@ -558,8 +390,7 @@ class EmailWorkflowService:
'status': 'completed', 'status': 'completed',
'action': 'updated_existing_sag', 'action': 'updated_existing_sag',
'sag_id': sag_id, 'sag_id': sag_id,
'customer_id': case.get('customer_id'), 'customer_id': case.get('customer_id')
'routing_source': routing_source
} }
# 2) No SAG id -> create only if sender domain belongs to known customer # 2) No SAG id -> create only if sender domain belongs to known customer
@ -594,8 +425,7 @@ class EmailWorkflowService:
'action': 'created_new_sag', 'action': 'created_new_sag',
'sag_id': case['id'], 'sag_id': case['id'],
'customer_id': customer['id'], 'customer_id': customer['id'],
'domain': sender_domain, 'domain': sender_domain
'routing_source': 'customer_domain'
} }
async def _find_matching_workflows(self, email_data: Dict) -> List[Dict]: async def _find_matching_workflows(self, email_data: Dict) -> List[Dict]:

View File

@ -2,7 +2,7 @@
Settings and User Management API Router Settings and User Management API Router
""" """
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException
from typing import List, Optional, Dict from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
from app.core.database import execute_query from app.core.database import execute_query
@ -10,22 +10,10 @@ from app.core.config import settings
import httpx import httpx
import time import time
import logging import logging
import json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
DEFAULT_EMAIL_SIGNATURE_TEMPLATE = (
"{full_name}\n"
"{title}\n"
"{company_name}\n"
"Telefon: {company_phone}\n"
"Email: {email}\n"
"Web: {company_website}\n"
"Adresse: {company_address}\n"
"BMCid: {bmc_id_tag}"
)
# Pydantic Models # Pydantic Models
class Setting(BaseModel): class Setting(BaseModel):
@ -69,30 +57,6 @@ class UserUpdate(BaseModel):
@router.get("/settings", response_model=List[Setting], tags=["Settings"]) @router.get("/settings", response_model=List[Setting], tags=["Settings"])
async def get_settings(category: Optional[str] = None): async def get_settings(category: Optional[str] = None):
"""Get all settings or filter by category""" """Get all settings or filter by category"""
execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
(%s, %s, %s, %s, %s, %s),
(%s, %s, %s, %s, %s, %s)
ON CONFLICT (key) DO NOTHING
""",
(
"email_default_signature_template",
DEFAULT_EMAIL_SIGNATURE_TEMPLATE,
"email",
"Standard signatur skabelon til udgående sagsmails",
"text",
True,
"company_website",
"https://bmcnetworks.dk",
"company",
"Firma website",
"string",
True,
),
)
query = "SELECT * FROM settings" query = "SELECT * FROM settings"
params = [] params = []
@ -111,7 +75,7 @@ async def get_setting(key: str):
query = "SELECT * FROM settings WHERE key = %s" query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,)) result = execute_query(query, (key,))
if not result and key in {"case_types", "case_type_module_defaults", "case_statuses"}: if not result and key in {"case_types", "case_type_module_defaults"}:
seed_query = """ seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public) INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
@ -144,43 +108,6 @@ async def get_setting(key: str):
) )
) )
if key == "case_statuses":
execute_query(
seed_query,
(
"case_statuses",
json.dumps([
{"value": "åben", "is_closed": False},
{"value": "under behandling", "is_closed": False},
{"value": "afventer", "is_closed": False},
{"value": "løst", "is_closed": True},
{"value": "lukket", "is_closed": True},
], ensure_ascii=False),
"system",
"Sagsstatus værdier og lukkede markeringer",
"json",
True,
)
)
result = execute_query(query, (key,))
if not result and key == "email_default_signature_template":
execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key) DO NOTHING
""",
(
"email_default_signature_template",
DEFAULT_EMAIL_SIGNATURE_TEMPLATE,
"email",
"Standard signatur skabelon til udgående sagsmails",
"text",
True,
)
)
result = execute_query(query, (key,)) result = execute_query(query, (key,))
if not result: if not result:
@ -199,27 +126,6 @@ async def update_setting(key: str, setting: SettingUpdate):
RETURNING * RETURNING *
""" """
result = execute_query(query, (setting.value, key)) result = execute_query(query, (setting.value, key))
if not result and key == "email_default_signature_template":
result = execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key)
DO UPDATE SET
value = EXCLUDED.value,
updated_at = CURRENT_TIMESTAMP
RETURNING *
""",
(
"email_default_signature_template",
setting.value,
"email",
"Standard signatur skabelon til udgående sagsmails",
"text",
True,
)
)
if not result: if not result:
raise HTTPException(status_code=404, detail="Setting not found") raise HTTPException(status_code=404, detail="Setting not found")
@ -616,10 +522,6 @@ class PromptUpdate(BaseModel):
class PromptTestRequest(BaseModel): class PromptTestRequest(BaseModel):
test_input: Optional[str] = None test_input: Optional[str] = None
prompt_text: Optional[str] = None prompt_text: Optional[str] = None
timeout_seconds: Optional[int] = None
_prompt_test_last_call: Dict[str, float] = {}
@router.put("/ai-prompts/{key}", tags=["Settings"]) @router.put("/ai-prompts/{key}", tags=["Settings"])
@ -657,7 +559,7 @@ async def reset_ai_prompt(key: str):
@router.post("/ai-prompts/{key}/test", tags=["Settings"]) @router.post("/ai-prompts/{key}/test", tags=["Settings"])
async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Request): async def test_ai_prompt(key: str, payload: PromptTestRequest):
"""Run a quick AI test for a specific system prompt""" """Run a quick AI test for a specific system prompt"""
prompts = _get_prompts_with_overrides() prompts = _get_prompts_with_overrides()
if key not in prompts: if key not in prompts:
@ -675,37 +577,12 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
raise HTTPException(status_code=400, detail="Test input is empty") raise HTTPException(status_code=400, detail="Test input is empty")
start = time.perf_counter() start = time.perf_counter()
client_host = (http_request.client.host if http_request.client else "unknown")
# Cooldown to prevent hammering external endpoints and getting rate-limited/banned.
cooldown_seconds = 2.0
now_monotonic = time.monotonic()
last_call = _prompt_test_last_call.get(client_host)
if last_call and (now_monotonic - last_call) < cooldown_seconds:
wait_for = round(cooldown_seconds - (now_monotonic - last_call), 2)
raise HTTPException(
status_code=429,
detail=f"For mange tests for hurtigt. Vent {wait_for} sekunder og prøv igen.",
)
_prompt_test_last_call[client_host] = now_monotonic
read_timeout_seconds = payload.timeout_seconds or 90
read_timeout_seconds = max(5, min(int(read_timeout_seconds), 300))
try: try:
model_normalized = (model or "").strip().lower() model_normalized = (model or "").strip().lower()
# qwen models are more reliable with /api/chat than /api/generate. # qwen models are more reliable with /api/chat than /api/generate.
use_chat_api = model_normalized.startswith("qwen") use_chat_api = model_normalized.startswith("qwen")
logger.info( timeout = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0)
"🧪 AI prompt test start key=%s model=%s timeout=%ss client=%s",
key,
model,
read_timeout_seconds,
client_host,
)
timeout = httpx.Timeout(connect=10.0, read=float(read_timeout_seconds), write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
if use_chat_api: if use_chat_api:
response = await client.post( response = await client.post(
@ -762,7 +639,6 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
"endpoint": endpoint, "endpoint": endpoint,
"test_input": test_input, "test_input": test_input,
"ai_response": ai_response, "ai_response": ai_response,
"timeout_seconds": read_timeout_seconds,
"latency_ms": latency_ms, "latency_ms": latency_ms,
} }

View File

@ -348,23 +348,6 @@
<!-- Email Templates --> <!-- Email Templates -->
<div class="tab-pane fade" id="email-templates"> <div class="tab-pane fade" id="email-templates">
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="fw-bold mb-2">Standard Signatur (Sagsmails)</h5>
<p class="text-muted mb-3">Bruges automatisk ved afsendelse fra sag. Skabelonen bruger indlogget bruger + firmaoplysninger.</p>
<label class="form-label small text-muted">Signatur skabelon</label>
<textarea class="form-control font-monospace" id="emailDefaultSignatureTemplate" rows="9"></textarea>
<div class="small text-muted mt-2">
Variabler: <code>{full_name}</code>, <code>{title}</code>, <code>{email}</code>, <code>{company_name}</code>, <code>{company_phone}</code>, <code>{company_address}</code>, <code>{bmc_id_tag}</code>
</div>
<div class="d-flex justify-content-end mt-3">
<button class="btn btn-primary" onclick="saveDefaultEmailSignatureTemplate()">
<i class="bi bi-save me-2"></i>Gem Standard Signatur
</button>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h5 class="fw-bold mb-1">Email Skabeloner</h5> <h5 class="fw-bold mb-1">Email Skabeloner</h5>
@ -1160,33 +1143,6 @@ async def scan_document(file_path: str):
</div> </div>
</div> </div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Sagsstatus</h5>
<p class="text-muted mb-0">Styr hvilke status-værdier der kan vælges, og marker hvilke der er lukkede.</p>
</div>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="caseStatusInput" placeholder="F.eks. afventer kunde" style="max-width: 260px;">
<button class="btn btn-primary" onclick="addCaseStatus()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Status</th>
<th class="text-center" style="width: 150px;">Lukket værdi</th>
<th class="text-end" style="width: 100px;">Handling</th>
</tr>
</thead>
<tbody id="caseStatusesTableBody">
<tr><td colspan="3" class="text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card p-4 mt-4"> <div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
@ -1706,8 +1662,6 @@ async function loadSettings() {
displaySettingsByCategory(); displaySettingsByCategory();
renderTelefoniSettings(); renderTelefoniSettings();
await loadCaseTypesSetting(); await loadCaseTypesSetting();
await loadCaseStatusesSetting();
await loadTagsManagement();
await loadNextcloudInstances(); await loadNextcloudInstances();
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
@ -1716,7 +1670,7 @@ async function loadSettings() {
function displaySettingsByCategory() { function displaySettingsByCategory() {
const categories = { const categories = {
company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_website', 'company_address'], company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_address'],
integrations: ['vtiger_enabled', 'vtiger_url', 'vtiger_username', 'economic_enabled', 'economic_app_secret', 'economic_agreement_token'], integrations: ['vtiger_enabled', 'vtiger_url', 'vtiger_username', 'economic_enabled', 'economic_app_secret', 'economic_agreement_token'],
notifications: ['email_notifications'], notifications: ['email_notifications'],
system: ['system_timezone'] system: ['system_timezone']
@ -2077,132 +2031,6 @@ const CASE_MODULE_LABELS = {
}; };
let caseTypeModuleDefaultsCache = {}; let caseTypeModuleDefaultsCache = {};
let caseStatusesCache = [];
function normalizeCaseStatuses(raw) {
const normalized = [];
const seen = new Set();
const source = Array.isArray(raw) ? raw : [];
source.forEach((item) => {
const row = typeof item === 'string'
? { value: item, is_closed: false }
: (item && typeof item === 'object' ? item : null);
if (!row) return;
const value = String(row.value || '').trim();
if (!value) return;
const key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
normalized.push({
value,
is_closed: Boolean(row.is_closed)
});
});
const defaults = [
{ value: 'åben', is_closed: false },
{ value: 'under behandling', is_closed: false },
{ value: 'afventer', is_closed: false },
{ value: 'løst', is_closed: true },
{ value: 'lukket', is_closed: true }
];
defaults.forEach((item) => {
const key = item.value.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
normalized.push(item);
}
});
return normalized;
}
function renderCaseStatuses(rows) {
const tbody = document.getElementById('caseStatusesTableBody');
if (!tbody) return;
if (!Array.isArray(rows) || !rows.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-muted">Ingen statusværdier defineret</td></tr>';
return;
}
tbody.innerHTML = rows.map((row, index) => `
<tr>
<td><span class="fw-semibold">${escapeHtml(row.value)}</span></td>
<td class="text-center">
<div class="form-check form-switch d-inline-flex">
<input class="form-check-input" type="checkbox" id="caseStatusClosed_${index}" ${row.is_closed ? 'checked' : ''}
onchange="toggleCaseStatusClosed(${index}, this.checked)">
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeCaseStatus(${index})" title="Slet status">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function loadCaseStatusesSetting() {
try {
const response = await fetch('/api/v1/settings/case_statuses');
if (!response.ok) {
caseStatusesCache = normalizeCaseStatuses([]);
renderCaseStatuses(caseStatusesCache);
return;
}
const setting = await response.json();
const parsed = JSON.parse(setting.value || '[]');
caseStatusesCache = normalizeCaseStatuses(parsed);
renderCaseStatuses(caseStatusesCache);
} catch (error) {
console.error('Error loading case statuses:', error);
caseStatusesCache = normalizeCaseStatuses([]);
renderCaseStatuses(caseStatusesCache);
}
}
async function saveCaseStatuses() {
await updateSetting('case_statuses', JSON.stringify(caseStatusesCache));
renderCaseStatuses(caseStatusesCache);
}
async function addCaseStatus() {
const input = document.getElementById('caseStatusInput');
if (!input) return;
const value = input.value.trim();
if (!value) return;
const exists = caseStatusesCache.some((row) => String(row.value || '').toLowerCase() === value.toLowerCase());
if (!exists) {
caseStatusesCache.push({ value, is_closed: false });
await saveCaseStatuses();
}
input.value = '';
}
async function removeCaseStatus(index) {
caseStatusesCache = caseStatusesCache.filter((_, i) => i !== index);
if (!caseStatusesCache.length) {
caseStatusesCache = normalizeCaseStatuses([]);
}
await saveCaseStatuses();
}
async function toggleCaseStatusClosed(index, checked) {
if (!caseStatusesCache[index]) return;
caseStatusesCache[index].is_closed = Boolean(checked);
await saveCaseStatuses();
}
function normalizeCaseTypeModuleDefaults(raw, caseTypes) { function normalizeCaseTypeModuleDefaults(raw, caseTypes) {
const normalized = {}; const normalized = {};
@ -2959,28 +2787,6 @@ async function loadAIPrompts() {
const container = document.getElementById('aiPromptsContent'); const container = document.getElementById('aiPromptsContent');
const accordionHtml = ` const accordionHtml = `
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-3">
<div class="row g-2 align-items-center">
<div class="col-12 col-md-4">
<label class="form-label mb-1 small text-muted">Timeout pr. test (sek)</label>
<input id="aiTestTimeoutSeconds" type="number" class="form-control form-control-sm" min="5" max="300" step="1" value="90">
</div>
<div class="col-12 col-md-8 d-flex justify-content-md-end gap-2 align-self-end">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearAiPromptLog()">
<i class="bi bi-trash me-1"></i>Ryd log
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="renderAiPromptLog()">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater log
</button>
</div>
<div class="col-12">
<div id="aiPromptLogWindow" class="border rounded p-2 bg-light" style="max-height: 220px; overflow-y: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem;"></div>
</div>
</div>
</div>
</div>
<div class="accordion" id="aiPromptsAccordion"> <div class="accordion" id="aiPromptsAccordion">
${Object.entries(prompts).map(([key, prompt], index) => ` ${Object.entries(prompts).map(([key, prompt], index) => `
<div class="accordion-item"> <div class="accordion-item">
@ -3070,7 +2876,6 @@ async function loadAIPrompts() {
`; `;
container.innerHTML = accordionHtml; container.innerHTML = accordionHtml;
renderAiPromptLog();
} catch (error) { } catch (error) {
console.error('Error loading AI prompts:', error); console.error('Error loading AI prompts:', error);
@ -3156,9 +2961,6 @@ async function testPrompt(key) {
const editElement = document.getElementById(`edit_prompt_${key}`); const editElement = document.getElementById(`edit_prompt_${key}`);
const promptText = editElement ? editElement.value : ''; const promptText = editElement ? editElement.value : '';
const timeoutInput = document.getElementById('aiTestTimeoutSeconds');
const timeoutSecondsRaw = timeoutInput ? Number(timeoutInput.value) : 90;
const timeoutSeconds = Math.min(300, Math.max(5, Number.isFinite(timeoutSecondsRaw) ? timeoutSecondsRaw : 90));
const originalHtml = btn.innerHTML; const originalHtml = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
@ -3167,13 +2969,12 @@ async function testPrompt(key) {
resultElement.className = 'alert alert-secondary m-3 py-2 px-3'; resultElement.className = 'alert alert-secondary m-3 py-2 px-3';
resultElement.classList.remove('d-none'); resultElement.classList.remove('d-none');
resultElement.textContent = 'Tester AI...'; resultElement.textContent = 'Tester AI...';
addAiPromptLogEntry('info', key, `Test startet (timeout=${timeoutSeconds}s)`);
try { try {
const response = await fetch(`/api/v1/ai-prompts/${key}/test`, { const response = await fetch(`/api/v1/ai-prompts/${key}/test`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_text: promptText, timeout_seconds: timeoutSeconds }) body: JSON.stringify({ prompt_text: promptText })
}); });
if (!response.ok) { if (!response.ok) {
@ -3190,54 +2991,16 @@ async function testPrompt(key) {
`✅ AI svar modtaget (${result.latency_ms} ms)\n` + `✅ AI svar modtaget (${result.latency_ms} ms)\n` +
`Model: ${result.model}\n\n` + `Model: ${result.model}\n\n` +
`${preview || '[Tomt svar]'}`; `${preview || '[Tomt svar]'}`;
addAiPromptLogEntry('success', key, `OK (${result.latency_ms} ms, timeout=${result.timeout_seconds || timeoutSeconds}s)`);
} catch (error) { } catch (error) {
console.error('Error testing AI prompt:', error); console.error('Error testing AI prompt:', error);
resultElement.className = 'alert alert-danger m-3 py-2 px-3'; resultElement.className = 'alert alert-danger m-3 py-2 px-3';
resultElement.textContent = `❌ ${error.message || 'Kunne ikke teste AI prompt'}`; resultElement.textContent = `❌ ${error.message || 'Kunne ikke teste AI prompt'}`;
addAiPromptLogEntry('error', key, error.message || 'Kunne ikke teste AI prompt');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = originalHtml; btn.innerHTML = originalHtml;
} }
} }
let aiPromptTestLog = [];
function addAiPromptLogEntry(level, key, message) {
aiPromptTestLog.unshift({
level,
key,
message,
timestamp: new Date().toISOString(),
});
if (aiPromptTestLog.length > 200) {
aiPromptTestLog = aiPromptTestLog.slice(0, 200);
}
renderAiPromptLog();
}
function renderAiPromptLog() {
const logWindow = document.getElementById('aiPromptLogWindow');
if (!logWindow) return;
if (!aiPromptTestLog.length) {
logWindow.innerHTML = '<div class="text-muted">Ingen test-log endnu.</div>';
return;
}
logWindow.innerHTML = aiPromptTestLog.map((row) => {
const icon = row.level === 'success' ? 'bi-check-circle text-success' : row.level === 'error' ? 'bi-x-circle text-danger' : 'bi-info-circle text-primary';
const ts = new Date(row.timestamp).toLocaleString('da-DK');
return `<div class="mb-1"><i class="bi ${icon} me-1"></i><strong>[${ts}]</strong> ${escapeHtml(row.key)}: ${escapeHtml(row.message)}</div>`;
}).join('');
}
function clearAiPromptLog() {
aiPromptTestLog = [];
renderAiPromptLog();
}
function copyPrompt(key) { function copyPrompt(key) {
@ -3322,8 +3085,6 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
// Load data for tab // Load data for tab
if (tab === 'users') { if (tab === 'users') {
loadUsers(); loadUsers();
} else if (tab === 'tags') {
loadTagsManagement();
} else if (tab === 'telefoni') { } else if (tab === 'telefoni') {
renderTelefoniSettings(); renderTelefoniSettings();
} else if (tab === 'ai-prompts') { } else if (tab === 'ai-prompts') {
@ -3398,19 +3159,13 @@ let showInactive = false;
async function loadTagsManagement() { async function loadTagsManagement() {
try { try {
const response = await fetch('/api/v1/tags'); const response = await fetch('/api/v1/tags');
if (!response.ok) { if (!response.ok) throw new Error('Failed to load tags');
const msg = await getErrorMessage(response, 'Kunne ikke indlæse tags');
throw new Error(msg);
}
allTagsData = await response.json(); allTagsData = await response.json();
updateTagsStats(); updateTagsStats();
renderTagsGrid(); renderTagsGrid();
} catch (error) { } catch (error) {
console.error('Error loading tags:', error); console.error('Error loading tags:', error);
allTagsData = []; showNotification('Fejl ved indlæsning af tags', 'error');
updateTagsStats();
renderTagsGrid();
showNotification('Fejl ved indlæsning af tags: ' + (error.message || 'ukendt fejl'), 'error');
} }
} }
@ -4514,45 +4269,6 @@ async function loadEmailTemplateCustomers() {
} }
} }
async function loadDefaultEmailSignatureTemplate() {
try {
const response = await fetch('/api/v1/settings/email_default_signature_template');
if (!response.ok) {
throw new Error('Kunne ikke hente signatur-skabelon');
}
const setting = await response.json();
const textarea = document.getElementById('emailDefaultSignatureTemplate');
if (textarea) {
textarea.value = setting.value || '';
}
} catch (error) {
console.error('Error loading default email signature template:', error);
}
}
async function saveDefaultEmailSignatureTemplate() {
const textarea = document.getElementById('emailDefaultSignatureTemplate');
if (!textarea) return;
try {
const response = await fetch('/api/v1/settings/email_default_signature_template', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: textarea.value || '' })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Kunne ikke gemme signatur-skabelon');
}
showNotification('Standard signatur gemt', 'success');
} catch (error) {
console.error('Error saving default email signature template:', error);
showNotification(error.message || 'Kunne ikke gemme signatur-skabelon', 'error');
}
}
async function openEmailTemplateModal() { async function openEmailTemplateModal() {
// Reset form // Reset form
document.getElementById('emailTemplateForm').reset(); document.getElementById('emailTemplateForm').reset();
@ -4698,7 +4414,6 @@ document.addEventListener('DOMContentLoaded', () => {
// Other loaders are called at bottom of file in existing script // Other loaders are called at bottom of file in existing script
loadEmailTemplates(); loadEmailTemplates();
loadEmailTemplateCustomers(); loadEmailTemplateCustomers();
loadDefaultEmailSignatureTemplate();
}); });
</script> </script>

View File

@ -253,7 +253,6 @@
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li> <li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li> <li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li> <li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
</ul> </ul>
</li> </li>
@ -282,6 +281,21 @@
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li> <li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li> <li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li class="dropdown-submenu">
<a class="dropdown-item dropdown-toggle py-2" href="#" data-submenu-toggle="timetracking">
<span><i class="bi bi-clock-history me-2"></i>Timetracking</span>
<i class="bi bi-chevron-right small opacity-75"></i>
</a>
<ul class="dropdown-menu" data-submenu="timetracking">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li> <li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
</ul> </ul>
</li> </li>
@ -292,19 +306,6 @@
</li> </li>
</ul> </ul>
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<div class="dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-clock-history me-2"></i>Data migration
</a>
<ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
</div>
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)"> <button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
<i class="bi bi-plus-circle-fill fs-5"></i> <i class="bi bi-plus-circle-fill fs-5"></i>
</button> </button>
@ -320,7 +321,6 @@
<ul class="dropdown-menu dropdown-menu-end mt-2"> <ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="#" data-bs-toggle="modal" data-bs-target="#profileModal">Profil</a></li> <li><a class="dropdown-item py-2" href="#" data-bs-toggle="modal" data-bs-target="#profileModal">Profil</a></li>
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li> <li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li> <li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li> <li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@ -1086,7 +1086,7 @@
</script> </script>
<!-- QuickCreate Modal (AI-Powered Case Creation) --> <!-- QuickCreate Modal (AI-Powered Case Creation) -->
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %} {% include "shared/frontend/quick_create_modal.html" %}
<!-- Profile Modal --> <!-- Profile Modal -->
<div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true">

View File

@ -21,8 +21,6 @@ router = APIRouter()
ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"} ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))" STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))"
ALLOWED_BILLING_DIRECTIONS = {"forward", "backward"}
ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"}
def _staging_status_with_mapping(status: str, has_customer: bool) -> str: def _staging_status_with_mapping(status: str, has_customer: bool) -> str:
@ -167,15 +165,6 @@ async def create_subscription(payload: Dict[str, Any]):
billing_interval = payload.get("billing_interval") billing_interval = payload.get("billing_interval")
billing_day = payload.get("billing_day") billing_day = payload.get("billing_day")
start_date = payload.get("start_date") start_date = payload.get("start_date")
billing_direction = (payload.get("billing_direction") or "forward").strip().lower()
advance_months = int(payload.get("advance_months") or 1)
first_full_period_start = payload.get("first_full_period_start")
binding_months = int(payload.get("binding_months") or 0)
binding_start_date_raw = payload.get("binding_start_date") or start_date
binding_group_key = payload.get("binding_group_key")
invoice_merge_key = payload.get("invoice_merge_key")
price_change_case_id = payload.get("price_change_case_id")
renewal_case_id = payload.get("renewal_case_id")
notes = payload.get("notes") notes = payload.get("notes")
line_items = payload.get("line_items") or [] line_items = payload.get("line_items") or []
@ -189,12 +178,6 @@ async def create_subscription(payload: Dict[str, Any]):
raise HTTPException(status_code=400, detail="start_date is required") raise HTTPException(status_code=400, detail="start_date is required")
if not line_items: if not line_items:
raise HTTPException(status_code=400, detail="line_items is required") raise HTTPException(status_code=400, detail="line_items is required")
if billing_direction not in ALLOWED_BILLING_DIRECTIONS:
raise HTTPException(status_code=400, detail="billing_direction must be forward or backward")
if advance_months < 1 or advance_months > 24:
raise HTTPException(status_code=400, detail="advance_months must be between 1 and 24")
if binding_months < 0:
raise HTTPException(status_code=400, detail="binding_months must be >= 0")
sag = execute_query_single( sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s", "SELECT id, customer_id FROM sag_sager WHERE id = %s",
@ -219,27 +202,18 @@ async def create_subscription(payload: Dict[str, Any]):
product_map = {} product_map = {}
if product_ids: if product_ids:
rows = execute_query( rows = execute_query(
""" "SELECT id, name, sales_price FROM products WHERE id = ANY(%s)",
SELECT id, name, sales_price, serial_number_required, asset_required
FROM products
WHERE id = ANY(%s)
""",
(product_ids,) (product_ids,)
) )
product_map = {row["id"]: row for row in (rows or [])} product_map = {row["id"]: row for row in (rows or [])}
cleaned_items = [] cleaned_items = []
total_price = 0 total_price = 0
blocked_reasons = []
for idx, item in enumerate(line_items, start=1): for idx, item in enumerate(line_items, start=1):
product_id = item.get("product_id") product_id = item.get("product_id")
description = (item.get("description") or "").strip() description = (item.get("description") or "").strip()
quantity = item.get("quantity") quantity = item.get("quantity")
unit_price = item.get("unit_price") unit_price = item.get("unit_price")
asset_id = item.get("asset_id")
serial_number = (item.get("serial_number") or "").strip() or None
period_from = item.get("period_from")
period_to = item.get("period_to")
product = product_map.get(product_id) product = product_map.get(product_id)
if not description and product: if not description and product:
@ -254,58 +228,21 @@ async def create_subscription(payload: Dict[str, Any]):
if unit_price is None or float(unit_price) < 0: if unit_price is None or float(unit_price) < 0:
raise HTTPException(status_code=400, detail="line_items unit_price must be >= 0") raise HTTPException(status_code=400, detail="line_items unit_price must be >= 0")
if asset_id is not None:
asset = execute_query_single(
"SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
(asset_id,)
)
if not asset:
raise HTTPException(status_code=400, detail=f"asset_id {asset_id} was not found")
requires_asset = bool(product and product.get("asset_required"))
requires_serial_number = bool(product and product.get("serial_number_required"))
item_block_reasons: List[str] = []
if requires_asset and not asset_id:
item_block_reasons.append("Asset mangler")
if requires_serial_number and not serial_number:
item_block_reasons.append("Serienummer mangler")
line_total = float(quantity) * float(unit_price) line_total = float(quantity) * float(unit_price)
total_price += line_total total_price += line_total
billing_blocked = len(item_block_reasons) > 0
billing_block_reason = "; ".join(item_block_reasons) if billing_blocked else None
if billing_block_reason:
blocked_reasons.append(f"{description}: {billing_block_reason}")
cleaned_items.append({ cleaned_items.append({
"line_no": idx, "line_no": idx,
"product_id": product_id, "product_id": product_id,
"asset_id": asset_id,
"description": description, "description": description,
"quantity": quantity, "quantity": quantity,
"unit_price": unit_price, "unit_price": unit_price,
"line_total": line_total, "line_total": line_total,
"period_from": period_from,
"period_to": period_to,
"requires_serial_number": requires_serial_number,
"serial_number": serial_number,
"billing_blocked": billing_blocked,
"billing_block_reason": billing_block_reason,
}) })
product_name = cleaned_items[0]["description"] product_name = cleaned_items[0]["description"]
if len(cleaned_items) > 1: if len(cleaned_items) > 1:
product_name = f"{product_name} (+{len(cleaned_items) - 1})" product_name = f"{product_name} (+{len(cleaned_items) - 1})"
billing_blocked = len(blocked_reasons) > 0
billing_block_reason = " | ".join(blocked_reasons) if billing_blocked else None
binding_start_date = _safe_date(binding_start_date_raw)
if not binding_start_date:
raise HTTPException(status_code=400, detail="binding_start_date must be a valid date")
binding_end_date = None
if binding_months > 0:
binding_end_date = binding_start_date + relativedelta(months=binding_months)
# Calculate next_invoice_date based on billing_interval # Calculate next_invoice_date based on billing_interval
start_dt = datetime.strptime(start_date, "%Y-%m-%d").date() start_dt = datetime.strptime(start_date, "%Y-%m-%d").date()
@ -335,30 +272,14 @@ async def create_subscription(payload: Dict[str, Any]):
customer_id, customer_id,
product_name, product_name,
billing_interval, billing_interval,
billing_direction,
advance_months,
first_full_period_start,
billing_day, billing_day,
price, price,
start_date, start_date,
period_start, period_start,
next_invoice_date, next_invoice_date,
binding_months,
binding_start_date,
binding_end_date,
binding_group_key,
billing_blocked,
billing_block_reason,
invoice_merge_key,
price_change_case_id,
renewal_case_id,
status, status,
notes notes
) VALUES ( ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, 'draft', %s
)
RETURNING * RETURNING *
""", """,
( (
@ -366,23 +287,11 @@ async def create_subscription(payload: Dict[str, Any]):
sag["customer_id"], sag["customer_id"],
product_name, product_name,
billing_interval, billing_interval,
billing_direction,
advance_months,
first_full_period_start,
billing_day, billing_day,
total_price, total_price,
start_date, start_date,
period_start, period_start,
next_invoice_date, next_invoice_date,
binding_months,
binding_start_date,
binding_end_date,
binding_group_key,
billing_blocked,
billing_block_reason,
invoice_merge_key,
price_change_case_id,
renewal_case_id,
notes, notes,
) )
) )
@ -395,34 +304,20 @@ async def create_subscription(payload: Dict[str, Any]):
subscription_id, subscription_id,
line_no, line_no,
product_id, product_id,
asset_id,
description, description,
quantity, quantity,
unit_price, unit_price,
line_total, line_total
period_from, ) VALUES (%s, %s, %s, %s, %s, %s, %s)
period_to,
requires_serial_number,
serial_number,
billing_blocked,
billing_block_reason
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", """,
( (
subscription["id"], subscription["id"],
item["line_no"], item["line_no"],
item["product_id"], item["product_id"],
item["asset_id"],
item["description"], item["description"],
item["quantity"], item["quantity"],
item["unit_price"], item["unit_price"],
item["line_total"], item["line_total"],
item["period_from"],
item["period_to"],
item["requires_serial_number"],
item["serial_number"],
item["billing_blocked"],
item["billing_block_reason"],
) )
) )
@ -453,25 +348,13 @@ async def get_subscription(subscription_id: int):
c.name AS customer_name, c.name AS customer_name,
s.product_name, s.product_name,
s.billing_interval, s.billing_interval,
s.billing_direction,
s.advance_months,
s.first_full_period_start,
s.billing_day, s.billing_day,
s.price, s.price,
s.start_date, s.start_date,
s.end_date, s.end_date,
s.next_invoice_date, s.next_invoice_date,
s.period_start, s.period_start,
s.binding_months,
s.binding_start_date,
s.binding_end_date,
s.binding_group_key,
s.notice_period_days, s.notice_period_days,
s.billing_blocked,
s.billing_block_reason,
s.invoice_merge_key,
s.price_change_case_id,
s.renewal_case_id,
s.status, s.status,
s.notes, s.notes,
s.cancelled_at, s.cancelled_at,
@ -494,18 +377,11 @@ async def get_subscription(subscription_id: int):
i.id, i.id,
i.line_no, i.line_no,
i.product_id, i.product_id,
i.asset_id,
p.name AS product_name, p.name AS product_name,
i.description, i.description,
i.quantity, i.quantity,
i.unit_price, i.unit_price,
i.line_total, i.line_total
i.period_from,
i.period_to,
i.requires_serial_number,
i.serial_number,
i.billing_blocked,
i.billing_block_reason
FROM sag_subscription_items i FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id LEFT JOIN products p ON p.id = i.product_id
WHERE i.subscription_id = %s WHERE i.subscription_id = %s
@ -540,11 +416,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
allowed_fields = { allowed_fields = {
"product_name", "billing_interval", "billing_day", "price", "product_name", "billing_interval", "billing_day", "price",
"start_date", "end_date", "next_invoice_date", "period_start", "start_date", "end_date", "next_invoice_date", "period_start",
"notice_period_days", "status", "notes", "notice_period_days", "status", "notes"
"billing_direction", "advance_months", "first_full_period_start",
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
"billing_blocked", "billing_block_reason", "invoice_merge_key",
"price_change_case_id", "renewal_case_id"
} }
updates = [] updates = []
@ -599,23 +471,13 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
""" """
INSERT INTO sag_subscription_items ( INSERT INTO sag_subscription_items (
subscription_id, line_no, description, subscription_id, line_no, description,
quantity, unit_price, line_total, product_id, quantity, unit_price, line_total, product_id
asset_id, period_from, period_to, ) VALUES (%s, %s, %s, %s, %s, %s, %s)
requires_serial_number, serial_number,
billing_blocked, billing_block_reason
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", """,
( (
subscription_id, idx, description, subscription_id, idx, description,
quantity, unit_price, line_total, quantity, unit_price, line_total,
item.get("product_id"), item.get("product_id")
item.get("asset_id"),
item.get("period_from"),
item.get("period_to"),
bool(item.get("requires_serial_number")),
item.get("serial_number"),
bool(item.get("billing_blocked")),
item.get("billing_block_reason"),
) )
) )
@ -675,13 +537,10 @@ async def list_subscriptions(status: str = Query("all")):
c.name AS customer_name, c.name AS customer_name,
s.product_name, s.product_name,
s.billing_interval, s.billing_interval,
s.billing_direction,
s.billing_day, s.billing_day,
s.price, s.price,
s.start_date, s.start_date,
s.end_date, s.end_date,
s.billing_blocked,
s.invoice_merge_key,
s.status, s.status,
(SELECT COUNT(*) FROM sag_subscription_items WHERE subscription_id = s.id) as item_count (SELECT COUNT(*) FROM sag_subscription_items WHERE subscription_id = s.id) as item_count
FROM sag_subscriptions s FROM sag_subscriptions s
@ -743,475 +602,6 @@ async def trigger_subscription_processing():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/sag-subscriptions/{subscription_id}/price-changes", response_model=List[Dict[str, Any]])
async def list_subscription_price_changes(subscription_id: int):
"""List planned price changes for one subscription."""
try:
query = """
SELECT
spc.id,
spc.subscription_id,
spc.subscription_item_id,
spc.sag_id,
sg.titel AS sag_title,
spc.change_scope,
spc.old_unit_price,
spc.new_unit_price,
spc.effective_date,
spc.approval_status,
spc.reason,
spc.approved_by_user_id,
spc.approved_at,
spc.created_by_user_id,
spc.created_at,
spc.updated_at
FROM subscription_price_changes spc
LEFT JOIN sag_sager sg ON sg.id = spc.sag_id
WHERE spc.subscription_id = %s
AND spc.deleted_at IS NULL
ORDER BY spc.effective_date ASC, spc.id ASC
"""
return execute_query(query, (subscription_id,)) or []
except Exception as e:
logger.error(f"❌ Error listing subscription price changes: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sag-subscriptions/{subscription_id}/price-changes", response_model=Dict[str, Any])
async def create_subscription_price_change(subscription_id: int, payload: Dict[str, Any]):
"""Create a planned price change (case is mandatory)."""
try:
new_unit_price = payload.get("new_unit_price")
effective_date = payload.get("effective_date")
sag_id = payload.get("sag_id")
subscription_item_id = payload.get("subscription_item_id")
reason = payload.get("reason")
created_by_user_id = payload.get("created_by_user_id")
if new_unit_price is None:
raise HTTPException(status_code=400, detail="new_unit_price is required")
if float(new_unit_price) < 0:
raise HTTPException(status_code=400, detail="new_unit_price must be >= 0")
if not effective_date:
raise HTTPException(status_code=400, detail="effective_date is required")
if not sag_id:
raise HTTPException(status_code=400, detail="sag_id is required")
subscription = execute_query_single(
"SELECT id, customer_id, price FROM sag_subscriptions WHERE id = %s",
(subscription_id,)
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,)
)
if not sag:
raise HTTPException(status_code=400, detail="Sag not found")
if int(sag.get("customer_id") or 0) != int(subscription.get("customer_id") or 0):
raise HTTPException(status_code=400, detail="Sag customer mismatch for subscription")
change_scope = "subscription"
old_unit_price = subscription.get("price")
if subscription_item_id is not None:
item = execute_query_single(
"""
SELECT id, unit_price
FROM sag_subscription_items
WHERE id = %s AND subscription_id = %s
""",
(subscription_item_id, subscription_id)
)
if not item:
raise HTTPException(status_code=400, detail="subscription_item_id not found on this subscription")
change_scope = "item"
old_unit_price = item.get("unit_price")
result = execute_query(
"""
INSERT INTO subscription_price_changes (
subscription_id,
subscription_item_id,
sag_id,
change_scope,
old_unit_price,
new_unit_price,
effective_date,
approval_status,
reason,
created_by_user_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s)
RETURNING *
""",
(
subscription_id,
subscription_item_id,
sag_id,
change_scope,
old_unit_price,
new_unit_price,
effective_date,
reason,
created_by_user_id,
)
)
return result[0] if result else {}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating subscription price change: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/sag-subscriptions/price-changes/{change_id}/approve", response_model=Dict[str, Any])
async def approve_subscription_price_change(change_id: int, payload: Dict[str, Any]):
"""Approve or reject a planned price change."""
try:
approval_status = (payload.get("approval_status") or "approved").strip().lower()
approved_by_user_id = payload.get("approved_by_user_id")
if approval_status not in ALLOWED_PRICE_CHANGE_STATUSES:
raise HTTPException(status_code=400, detail="Invalid approval_status")
if approval_status == "applied":
raise HTTPException(status_code=400, detail="Use apply endpoint to set applied status")
result = execute_query(
"""
UPDATE subscription_price_changes
SET approval_status = %s,
approved_by_user_id = %s,
approved_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
AND deleted_at IS NULL
RETURNING *
""",
(approval_status, approved_by_user_id, change_id)
)
if not result:
raise HTTPException(status_code=404, detail="Price change not found")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error approving subscription price change: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/sag-subscriptions/price-changes/{change_id}/apply", response_model=Dict[str, Any])
async def apply_subscription_price_change(change_id: int):
"""Apply an approved price change to subscription or item pricing."""
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
SELECT *
FROM subscription_price_changes
WHERE id = %s
AND deleted_at IS NULL
""",
(change_id,)
)
change = cursor.fetchone()
if not change:
raise HTTPException(status_code=404, detail="Price change not found")
if change.get("approval_status") not in ("approved", "pending"):
raise HTTPException(status_code=400, detail="Price change must be approved or pending before apply")
subscription_id = int(change["subscription_id"])
change_scope = change.get("change_scope")
new_unit_price = float(change.get("new_unit_price") or 0)
if change_scope == "item" and change.get("subscription_item_id"):
cursor.execute(
"""
UPDATE sag_subscription_items
SET unit_price = %s,
line_total = ROUND((quantity * %s)::numeric, 2),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(new_unit_price, new_unit_price, change["subscription_item_id"])
)
else:
cursor.execute(
"""
UPDATE sag_subscription_items
SET unit_price = %s,
line_total = ROUND((quantity * %s)::numeric, 2),
updated_at = CURRENT_TIMESTAMP
WHERE subscription_id = %s
""",
(new_unit_price, new_unit_price, subscription_id)
)
cursor.execute(
"""
SELECT COALESCE(SUM(line_total), 0) AS total
FROM sag_subscription_items
WHERE subscription_id = %s
""",
(subscription_id,)
)
row = cursor.fetchone() or {"total": 0}
cursor.execute(
"""
UPDATE sag_subscriptions
SET price = %s,
price_change_case_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(row.get("total") or 0, change.get("sag_id"), subscription_id)
)
cursor.execute(
"""
UPDATE subscription_price_changes
SET approval_status = 'applied',
approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
""",
(change_id,)
)
updated_change = cursor.fetchone()
conn.commit()
return updated_change or {}
except HTTPException:
conn.rollback()
raise
except Exception as e:
conn.rollback()
logger.error(f"❌ Error applying subscription price change: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
release_db_connection(conn)
@router.get("/sag-subscriptions/{subscription_id}/asset-bindings", response_model=List[Dict[str, Any]])
async def list_subscription_asset_bindings(subscription_id: int):
"""List asset bindings attached to a subscription."""
try:
return execute_query(
"""
SELECT
b.id,
b.subscription_id,
b.asset_id,
b.shared_binding_key,
b.binding_months,
b.start_date,
b.end_date,
b.notice_period_days,
b.status,
b.sag_id,
b.created_by_user_id,
b.created_at,
b.updated_at,
h.brand,
h.model,
h.serial_number AS asset_serial_number,
h.internal_asset_id,
h.status AS asset_status
FROM subscription_asset_bindings b
LEFT JOIN hardware_assets h ON h.id = b.asset_id
WHERE b.subscription_id = %s
AND b.deleted_at IS NULL
ORDER BY b.start_date DESC, b.id DESC
""",
(subscription_id,)
) or []
except Exception as e:
logger.error(f"❌ Error listing subscription asset bindings: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sag-subscriptions/{subscription_id}/asset-bindings", response_model=Dict[str, Any])
async def create_subscription_asset_binding(subscription_id: int, payload: Dict[str, Any]):
"""Create a binding for one asset under a subscription."""
try:
asset_id = payload.get("asset_id")
start_date_raw = payload.get("start_date")
end_date_raw = payload.get("end_date")
binding_months = int(payload.get("binding_months") or 0)
shared_binding_key = payload.get("shared_binding_key")
notice_period_days = int(payload.get("notice_period_days") or 30)
sag_id = payload.get("sag_id")
created_by_user_id = payload.get("created_by_user_id")
if not asset_id:
raise HTTPException(status_code=400, detail="asset_id is required")
if notice_period_days < 0:
raise HTTPException(status_code=400, detail="notice_period_days must be >= 0")
if binding_months < 0:
raise HTTPException(status_code=400, detail="binding_months must be >= 0")
subscription = execute_query_single(
"SELECT id, customer_id, start_date FROM sag_subscriptions WHERE id = %s",
(subscription_id,)
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
asset = execute_query_single(
"SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
(asset_id,)
)
if not asset:
raise HTTPException(status_code=400, detail="Asset not found")
if sag_id:
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,)
)
if not sag:
raise HTTPException(status_code=400, detail="Sag not found")
if int(sag.get("customer_id") or 0) != int(subscription.get("customer_id") or 0):
raise HTTPException(status_code=400, detail="Sag customer mismatch for subscription")
start_date = _safe_date(start_date_raw) or _safe_date(subscription.get("start_date")) or date.today()
end_date = _safe_date(end_date_raw)
if not end_date and binding_months > 0:
end_date = start_date + relativedelta(months=binding_months)
result = execute_query(
"""
INSERT INTO subscription_asset_bindings (
subscription_id,
asset_id,
shared_binding_key,
binding_months,
start_date,
end_date,
notice_period_days,
status,
sag_id,
created_by_user_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'active', %s, %s)
RETURNING *
""",
(
subscription_id,
asset_id,
shared_binding_key,
binding_months,
start_date,
end_date,
notice_period_days,
sag_id,
created_by_user_id,
)
)
if not result:
raise HTTPException(status_code=500, detail="Could not create binding")
execute_query(
"""
UPDATE sag_subscription_items
SET asset_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE subscription_id = %s
AND asset_id IS NULL
""",
(asset_id, subscription_id)
)
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating subscription asset binding: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/sag-subscriptions/asset-bindings/{binding_id}", response_model=Dict[str, Any])
async def update_subscription_asset_binding(binding_id: int, payload: Dict[str, Any]):
"""Update status/dates/notice for a subscription asset binding."""
try:
allowed_fields = {
"shared_binding_key",
"binding_months",
"start_date",
"end_date",
"notice_period_days",
"status",
"sag_id",
}
updates = []
values = []
for field, value in payload.items():
if field in allowed_fields:
updates.append(f"{field} = %s")
values.append(value)
if "status" in payload and payload.get("status") not in {"active", "ended", "cancelled"}:
raise HTTPException(status_code=400, detail="Invalid binding status")
if "notice_period_days" in payload and int(payload.get("notice_period_days") or 0) < 0:
raise HTTPException(status_code=400, detail="notice_period_days must be >= 0")
if not updates:
existing = execute_query_single(
"SELECT * FROM subscription_asset_bindings WHERE id = %s AND deleted_at IS NULL",
(binding_id,)
)
if not existing:
raise HTTPException(status_code=404, detail="Binding not found")
return existing
values.append(binding_id)
result = execute_query(
f"""
UPDATE subscription_asset_bindings
SET {', '.join(updates)},
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
AND deleted_at IS NULL
RETURNING *
""",
tuple(values)
)
if not result:
raise HTTPException(status_code=404, detail="Binding not found")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating subscription asset binding: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/sag-subscriptions/asset-bindings/{binding_id}", response_model=Dict[str, Any])
async def delete_subscription_asset_binding(binding_id: int):
"""Soft-delete a subscription asset binding."""
try:
result = execute_query(
"""
UPDATE subscription_asset_bindings
SET deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
AND deleted_at IS NULL
RETURNING id
""",
(binding_id,)
)
if not result:
raise HTTPException(status_code=404, detail="Binding not found")
return {"status": "deleted", "id": result[0].get("id")}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error deleting subscription asset binding: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/simply-subscription-staging/import", response_model=Dict[str, Any]) @router.post("/simply-subscription-staging/import", response_model=Dict[str, Any])
async def import_simply_subscriptions_to_staging(): async def import_simply_subscriptions_to_staging():
"""Import recurring Simply CRM SalesOrders into staging (parking area).""" """Import recurring Simply CRM SalesOrders into staging (parking area)."""

View File

@ -6,7 +6,7 @@ from typing import Optional, List, Literal
from datetime import datetime from datetime import datetime
# Tag types # Tag types
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type'] TagType = Literal['workflow', 'status', 'category', 'priority', 'billing']
TagGroupBehavior = Literal['multi', 'single', 'toggle'] TagGroupBehavior = Literal['multi', 'single', 'toggle']
@ -37,7 +37,6 @@ class TagBase(BaseModel):
icon: Optional[str] = None icon: Optional[str] = None
is_active: bool = True is_active: bool = True
tag_group_id: Optional[int] = None tag_group_id: Optional[int] = None
catch_words: Optional[List[str]] = None
class TagCreate(TagBase): class TagCreate(TagBase):
"""Tag creation model""" """Tag creation model"""
@ -60,7 +59,6 @@ class TagUpdate(BaseModel):
icon: Optional[str] = None icon: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
tag_group_id: Optional[int] = None tag_group_id: Optional[int] = None
catch_words: Optional[List[str]] = None
class EntityTagBase(BaseModel): class EntityTagBase(BaseModel):

View File

@ -1,10 +1,8 @@
""" """
Tag system API endpoints Tag system API endpoints
""" """
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException
from typing import List, Optional from typing import List, Optional
import json
import re
from app.tags.backend.models import ( from app.tags.backend.models import (
Tag, TagCreate, TagUpdate, Tag, TagCreate, TagUpdate,
EntityTag, EntityTagCreate, EntityTag, EntityTagCreate,
@ -16,197 +14,6 @@ from app.core.database import execute_query, execute_query_single, execute_updat
router = APIRouter(prefix="/tags") router = APIRouter(prefix="/tags")
MODULE_LABELS = {
"case": "Sager",
"email": "Email",
"ticket": "Tickets",
"customer": "Kunder",
"contact": "Kontakter",
"time_entry": "Tid",
"order": "Ordrer",
"comment": "Ticket kommentarer",
"worklog": "Ticket worklog",
}
def _module_label_for_entity_type(entity_type: Optional[str]) -> str:
key = str(entity_type or "").strip().lower()
if not key:
return "Ukendt modul"
return MODULE_LABELS.get(key, f"Ukendt modul ({key})")
def _entity_reference_payload(entity_type: Optional[str], entity_id: Optional[int]) -> dict:
etype = str(entity_type or "").strip().lower()
eid = int(entity_id or 0)
default_label = f"#{eid}" if eid else "Ukendt"
if not etype or not eid:
return {"entity_title": default_label, "entity_url": None}
try:
if etype == "case":
row = execute_query_single(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(eid,),
)
if row:
title = str(row.get("titel") or "Sag").strip()
return {"entity_title": title, "entity_url": f"/sag/{eid}"}
elif etype == "email":
row = execute_query_single(
"SELECT id, subject FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(eid,),
)
if row:
title = str(row.get("subject") or "Email").strip()
return {"entity_title": title, "entity_url": f"/emails?id={eid}"}
elif etype == "ticket":
row = execute_query_single(
"SELECT id, ticket_number, subject FROM tticket_tickets WHERE id = %s",
(eid,),
)
if row:
ticket_number = str(row.get("ticket_number") or "").strip()
subject = str(row.get("subject") or "Ticket").strip()
title = f"{ticket_number} - {subject}" if ticket_number else subject
return {"entity_title": title, "entity_url": f"/ticket/tickets/{eid}"}
elif etype == "customer":
row = execute_query_single("SELECT id, name FROM customers WHERE id = %s", (eid,))
if row:
title = str(row.get("name") or "Kunde").strip()
return {"entity_title": title, "entity_url": f"/customers/{eid}"}
elif etype == "contact":
row = execute_query_single(
"SELECT id, first_name, last_name, email FROM contacts WHERE id = %s",
(eid,),
)
if row:
name = " ".join(
[str(row.get("first_name") or "").strip(), str(row.get("last_name") or "").strip()]
).strip()
title = name or str(row.get("email") or "Kontakt").strip()
return {"entity_title": title, "entity_url": f"/contacts/{eid}"}
elif etype == "time_entry":
row = execute_query_single(
"SELECT id, description, worked_date FROM tmodule_times WHERE id = %s",
(eid,),
)
if row:
description = str(row.get("description") or "Tidsregistrering").strip()
return {"entity_title": description[:90], "entity_url": "/timetracking/registrations"}
elif etype == "order":
row = execute_query_single(
"SELECT id, order_number, total_amount FROM tmodule_orders WHERE id = %s",
(eid,),
)
if row:
order_number = str(row.get("order_number") or "Ordre").strip()
total_amount = row.get("total_amount")
suffix = f" ({total_amount} kr.)" if total_amount is not None else ""
return {"entity_title": f"{order_number}{suffix}", "entity_url": "/timetracking/orders"}
elif etype == "worklog":
row = execute_query_single(
"""
SELECT w.id, w.description, w.ticket_id, t.ticket_number
FROM tticket_worklog w
LEFT JOIN tticket_tickets t ON t.id = w.ticket_id
WHERE w.id = %s
""",
(eid,),
)
if row:
ticket_id = row.get("ticket_id")
ticket_number = str(row.get("ticket_number") or "Ticket").strip()
description = str(row.get("description") or "Worklog").strip()
url = f"/ticket/tickets/{ticket_id}" if ticket_id else None
return {"entity_title": f"{ticket_number} - {description[:70]}", "entity_url": url}
elif etype == "comment":
row = execute_query_single(
"""
SELECT c.id, c.comment_text, c.ticket_id, t.ticket_number
FROM tticket_comments c
LEFT JOIN tticket_tickets t ON t.id = c.ticket_id
WHERE c.id = %s
""",
(eid,),
)
if row:
ticket_id = row.get("ticket_id")
ticket_number = str(row.get("ticket_number") or "Ticket").strip()
comment_text = str(row.get("comment_text") or "Kommentar").strip()
url = f"/ticket/tickets/{ticket_id}" if ticket_id else None
return {"entity_title": f"{ticket_number} - {comment_text[:70]}", "entity_url": url}
except Exception:
pass
return {"entity_title": default_label, "entity_url": None}
def _normalize_catch_words(value) -> List[str]:
"""Normalize catch words from JSON/text/list to a clean lowercase list."""
if value is None:
return []
if isinstance(value, list):
words = value
elif isinstance(value, str):
stripped = value.strip()
if not stripped:
return []
if stripped.startswith("["):
try:
parsed = json.loads(stripped)
words = parsed if isinstance(parsed, list) else []
except Exception:
words = [w.strip() for w in stripped.replace("\n", ",").split(",")]
else:
words = [w.strip() for w in stripped.replace("\n", ",").split(",")]
else:
words = []
cleaned = []
seen = set()
for word in words:
normalized = str(word or "").strip().lower()
if len(normalized) < 2:
continue
if normalized in seen:
continue
seen.add(normalized)
cleaned.append(normalized)
return cleaned
def _tag_row_to_response(row: dict) -> dict:
"""Ensure API response always exposes catch_words as a list."""
if not row:
return row
out = dict(row)
valid_types = {"workflow", "status", "category", "priority", "billing", "brand", "type"}
tag_type = str(out.get("type") or "").strip().lower()
if tag_type not in valid_types:
tag_type = "category"
out["type"] = tag_type
color = str(out.get("color") or "").strip()
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", color):
out["color"] = "#0f4c75"
if not out.get("name"):
out["name"] = "Unnamed tag"
out["catch_words"] = _normalize_catch_words(out.get("catch_words"))
return out
# ============= TAG GROUPS ============= # ============= TAG GROUPS =============
@router.get("/groups", response_model=List[TagGroup]) @router.get("/groups", response_model=List[TagGroup])
@ -227,131 +34,13 @@ async def create_tag_group(group: TagGroupCreate):
# ============= TAG CRUD ============= # ============= TAG CRUD =============
@router.get("/usage")
async def list_tag_usage(
tag_name: Optional[str] = Query(None),
tag_type: Optional[TagType] = Query(None),
module: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=200),
sort_by: str = Query("tagged_at"),
sort_dir: str = Query("desc"),
):
"""List tag usage across modules with server-side filtering and pagination."""
where_parts = ["1=1"]
params: List[object] = []
if tag_name:
where_parts.append("LOWER(t.name) LIKE LOWER(%s)")
params.append(f"%{tag_name.strip()}%")
if tag_type:
where_parts.append("t.type = %s")
params.append(tag_type)
if module:
where_parts.append("LOWER(et.entity_type) = LOWER(%s)")
params.append(module.strip())
where_clause = " AND ".join(where_parts)
sortable = {
"tagged_at": "et.tagged_at",
"tag_name": "t.name",
"tag_type": "t.type",
"module": "et.entity_type",
"entity_id": "et.entity_id",
}
order_column = sortable.get(sort_by, "et.tagged_at")
order_direction = "ASC" if str(sort_dir).lower() == "asc" else "DESC"
count_query = f"""
SELECT COUNT(*) AS total
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE {where_clause}
"""
count_row = execute_query_single(count_query, tuple(params)) or {"total": 0}
total = int(count_row.get("total") or 0)
offset = (page - 1) * page_size
data_query = f"""
SELECT
et.id AS entity_tag_id,
et.entity_type,
et.entity_id,
et.tagged_at,
t.id AS tag_id,
t.name AS tag_name,
t.type AS tag_type,
t.color AS tag_color,
t.is_active AS tag_is_active
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE {where_clause}
ORDER BY {order_column} {order_direction}, et.id DESC
LIMIT %s OFFSET %s
"""
rows = execute_query(data_query, tuple(params + [page_size, offset])) or []
items = []
for row in rows:
entity_type = row.get("entity_type")
entity_ref = _entity_reference_payload(entity_type, row.get("entity_id"))
items.append(
{
"entity_tag_id": row.get("entity_tag_id"),
"tag_id": row.get("tag_id"),
"tag_name": row.get("tag_name"),
"tag_type": row.get("tag_type"),
"tag_color": row.get("tag_color"),
"tag_is_active": bool(row.get("tag_is_active")),
"module": _module_label_for_entity_type(entity_type),
"entity_type": entity_type,
"entity_id": row.get("entity_id"),
"entity_title": entity_ref.get("entity_title"),
"entity_url": entity_ref.get("entity_url"),
"tagged_at": row.get("tagged_at"),
}
)
module_rows = execute_query(
"SELECT DISTINCT entity_type FROM entity_tags ORDER BY entity_type",
(),
) or []
module_options = [
{
"value": row.get("entity_type"),
"label": _module_label_for_entity_type(row.get("entity_type")),
}
for row in module_rows
]
return {
"items": items,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": (total + page_size - 1) // page_size if total else 0,
},
"sort": {"sort_by": sort_by, "sort_dir": order_direction.lower()},
"module_options": module_options,
}
@router.get("", response_model=List[Tag]) @router.get("", response_model=List[Tag])
async def list_tags( async def list_tags(
type: Optional[TagType] = None, type: Optional[TagType] = None,
is_active: Optional[bool] = None is_active: Optional[bool] = None
): ):
"""List all tags with optional filtering""" """List all tags with optional filtering"""
query = """ query = "SELECT * FROM tags WHERE 1=1"
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE 1=1
"""
params = [] params = []
if type: if type:
@ -365,52 +54,32 @@ async def list_tags(
query += " ORDER BY type, name" query += " ORDER BY type, name"
results = execute_query(query, tuple(params) if params else ()) results = execute_query(query, tuple(params) if params else ())
return [_tag_row_to_response(row) for row in (results or [])] return results
@router.get("/{tag_id}", response_model=Tag) @router.get("/{tag_id}", response_model=Tag)
async def get_tag(tag_id: int): async def get_tag(tag_id: int):
"""Get single tag by ID""" """Get single tag by ID"""
result = execute_query_single( result = execute_query_single(
""" "SELECT * FROM tags WHERE id = %s",
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE id = %s
""",
(tag_id,) (tag_id,)
) )
if not result: if not result:
raise HTTPException(status_code=404, detail="Tag not found") raise HTTPException(status_code=404, detail="Tag not found")
return _tag_row_to_response(result) return result
@router.post("", response_model=Tag) @router.post("", response_model=Tag)
async def create_tag(tag: TagCreate): async def create_tag(tag: TagCreate):
"""Create new tag""" """Create new tag"""
query = """ query = """
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id, catch_words_json) INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb) VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, type, description, color, icon, is_active, tag_group_id, RETURNING *
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
""" """
catch_words = _normalize_catch_words(tag.catch_words)
result = execute_query_single( result = execute_query_single(
query, query,
( (tag.name, tag.type, tag.description, tag.color, tag.icon, tag.is_active, tag.tag_group_id)
tag.name,
tag.type,
tag.description,
tag.color,
tag.icon,
tag.is_active,
tag.tag_group_id,
json.dumps(catch_words),
)
) )
if not result: return result
raise HTTPException(status_code=500, detail="Failed to create tag")
return _tag_row_to_response(result)
@router.put("/{tag_id}", response_model=Tag) @router.put("/{tag_id}", response_model=Tag)
async def update_tag(tag_id: int, tag: TagUpdate): async def update_tag(tag_id: int, tag: TagUpdate):
@ -437,9 +106,6 @@ async def update_tag(tag_id: int, tag: TagUpdate):
if tag.tag_group_id is not None: if tag.tag_group_id is not None:
updates.append("tag_group_id = %s") updates.append("tag_group_id = %s")
params.append(tag.tag_group_id) params.append(tag.tag_group_id)
if tag.catch_words is not None:
updates.append("catch_words_json = %s::jsonb")
params.append(json.dumps(_normalize_catch_words(tag.catch_words)))
if not updates: if not updates:
raise HTTPException(status_code=400, detail="No fields to update") raise HTTPException(status_code=400, detail="No fields to update")
@ -451,15 +117,13 @@ async def update_tag(tag_id: int, tag: TagUpdate):
UPDATE tags UPDATE tags
SET {', '.join(updates)} SET {', '.join(updates)}
WHERE id = %s WHERE id = %s
RETURNING id, name, type, description, color, icon, is_active, tag_group_id, RETURNING *
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
""" """
result = execute_query_single(query, tuple(params)) result = execute_query_single(query, tuple(params))
if not result: if not result:
raise HTTPException(status_code=404, detail="Tag not found") raise HTTPException(status_code=404, detail="Tag not found")
return _tag_row_to_response(result) return result
@router.delete("/{tag_id}") @router.delete("/{tag_id}")
async def delete_tag(tag_id: int): async def delete_tag(tag_id: int):
@ -550,91 +214,20 @@ async def remove_tag_from_entity_path(
async def get_entity_tags(entity_type: str, entity_id: int): async def get_entity_tags(entity_type: str, entity_id: int):
"""Get all tags for a specific entity""" """Get all tags for a specific entity"""
query = """ query = """
SELECT t.id, t.name, t.type, t.description, t.color, t.icon, t.is_active, t.tag_group_id, SELECT t.*
COALESCE(t.catch_words_json, '[]'::jsonb) AS catch_words,
t.created_at, t.updated_at
FROM tags t FROM tags t
JOIN entity_tags et ON et.tag_id = t.id JOIN entity_tags et ON et.tag_id = t.id
WHERE et.entity_type = %s AND et.entity_id = %s WHERE et.entity_type = %s AND et.entity_id = %s
ORDER BY t.type, t.name ORDER BY t.type, t.name
""" """
results = execute_query(query, (entity_type, entity_id)) results = execute_query(query, (entity_type, entity_id))
return [_tag_row_to_response(row) for row in (results or [])] return results
@router.get("/entity/{entity_type}/{entity_id}/suggestions")
async def suggest_entity_tags(entity_type: str, entity_id: int):
"""Suggest tags based on catch words for brand/type tags."""
if entity_type != "case":
return []
case_row = execute_query_single(
"SELECT id, titel, beskrivelse, template_key FROM sag_sager WHERE id = %s",
(entity_id,),
)
if not case_row:
raise HTTPException(status_code=404, detail="Entity not found")
existing_rows = execute_query(
"SELECT tag_id FROM entity_tags WHERE entity_type = %s AND entity_id = %s",
(entity_type, entity_id),
) or []
existing_tag_ids = {int(row.get("tag_id")) for row in existing_rows if row.get("tag_id") is not None}
candidate_rows = execute_query(
"""
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE is_active = true
AND type IN ('brand', 'type')
ORDER BY type, name
""",
(),
) or []
haystack = " ".join(
[
str(case_row.get("titel") or ""),
str(case_row.get("beskrivelse") or ""),
str(case_row.get("template_key") or ""),
]
).lower()
suggestions = []
for row in candidate_rows:
tag_id = int(row.get("id"))
if tag_id in existing_tag_ids:
continue
catch_words = _normalize_catch_words(row.get("catch_words"))
if not catch_words:
continue
matched_words = [word for word in catch_words if word in haystack]
if not matched_words:
continue
suggestions.append(
{
"tag": _tag_row_to_response(row),
"matched_words": matched_words,
"score": len(matched_words),
}
)
suggestions.sort(key=lambda item: (-item["score"], item["tag"]["type"], item["tag"]["name"]))
return suggestions
@router.get("/search") @router.get("/search")
async def search_tags(q: str, type: Optional[TagType] = None): async def search_tags(q: str, type: Optional[TagType] = None):
"""Search tags by name (fuzzy search)""" """Search tags by name (fuzzy search)"""
query = """ query = """
SELECT id, name, type, description, color, icon, is_active, tag_group_id, SELECT * FROM tags
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE is_active = true WHERE is_active = true
AND LOWER(name) LIKE LOWER(%s) AND LOWER(name) LIKE LOWER(%s)
""" """
@ -647,7 +240,7 @@ async def search_tags(q: str, type: Optional[TagType] = None):
query += " ORDER BY name LIMIT 20" query += " ORDER BY name LIMIT 20"
results = execute_query(query, tuple(params)) results = execute_query(query, tuple(params))
return [_tag_row_to_response(row) for row in (results or [])] return results
# ============= WORKFLOW MANAGEMENT ============= # ============= WORKFLOW MANAGEMENT =============

View File

@ -1,8 +1,11 @@
{% extends "shared/frontend/base.html" %} <!DOCTYPE html>
<html lang="da">
{% block title %}Tag Administration - BMC Hub{% endblock %} <head>
<meta charset="UTF-8">
{% block extra_css %} <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tag Administration - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style> <style>
:root { :root {
--primary-color: #0f4c75; --primary-color: #0f4c75;
@ -11,8 +14,6 @@
--category-color: #0f4c75; --category-color: #0f4c75;
--priority-color: #dc3545; --priority-color: #dc3545;
--billing-color: #2d6a4f; --billing-color: #2d6a4f;
--brand-color: #006d77;
--type-color: #5c677d;
} }
.tag-badge { .tag-badge {
@ -36,8 +37,6 @@
.tag-type-category { background-color: var(--category-color); color: white; } .tag-type-category { background-color: var(--category-color); color: white; }
.tag-type-priority { background-color: var(--priority-color); color: white; } .tag-type-priority { background-color: var(--priority-color); color: white; }
.tag-type-billing { background-color: var(--billing-color); color: white; } .tag-type-billing { background-color: var(--billing-color); color: white; }
.tag-type-brand { background-color: var(--brand-color); color: white; }
.tag-type-type { background-color: var(--type-color); color: white; }
.tag-list-item { .tag-list-item {
padding: 1rem; padding: 1rem;
@ -54,8 +53,6 @@
.tag-list-item[data-type="category"] { border-left-color: var(--category-color); } .tag-list-item[data-type="category"] { border-left-color: var(--category-color); }
.tag-list-item[data-type="priority"] { border-left-color: var(--priority-color); } .tag-list-item[data-type="priority"] { border-left-color: var(--priority-color); }
.tag-list-item[data-type="billing"] { border-left-color: var(--billing-color); } .tag-list-item[data-type="billing"] { border-left-color: var(--billing-color); }
.tag-list-item[data-type="brand"] { border-left-color: var(--brand-color); }
.tag-list-item[data-type="type"] { border-left-color: var(--type-color); }
.color-preview { .color-preview {
width: 40px; width: 40px;
@ -63,68 +60,9 @@
border-radius: 8px; border-radius: 8px;
border: 2px solid #dee2e6; border: 2px solid #dee2e6;
} }
.section-tabs .nav-link {
color: var(--primary-color);
font-weight: 600;
}
.section-tabs .nav-link.active {
background-color: var(--primary-color);
color: #fff;
border-color: var(--primary-color);
}
.module-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
background: #e7f1f8;
color: #0b3552;
border: 1px solid #c7dceb;
}
.usage-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: #fff;
white-space: nowrap;
}
.usage-table .filter-cell {
min-width: 160px;
}
.usage-sort-btn {
border: 0;
background: transparent;
color: inherit;
font-weight: 600;
padding: 0;
}
.usage-sort-btn .bi {
font-size: 0.75rem;
opacity: 0.55;
}
.usage-sort-btn.active .bi {
opacity: 1;
}
@media (max-width: 991px) {
.usage-table .filter-cell {
min-width: 130px;
}
}
</style> </style>
{% endblock %} </head>
<body>
{% block content %}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <div class="col">
@ -138,17 +76,6 @@
</div> </div>
</div> </div>
<ul class="nav nav-pills section-tabs mb-4" id="sectionTabs">
<li class="nav-item">
<button type="button" class="nav-link active" data-section="admin">Tag administration</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" data-section="search">Tag søgning</button>
</li>
</ul>
<div id="tagAdminSection">
<!-- Type Filter Tabs --> <!-- Type Filter Tabs -->
<ul class="nav nav-tabs mb-4" id="typeFilter"> <ul class="nav nav-tabs mb-4" id="typeFilter">
<li class="nav-item"> <li class="nav-item">
@ -179,16 +106,6 @@
<span class="tag-badge tag-type-billing">Billing</span> <span class="tag-badge tag-type-billing">Billing</span>
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="brand">
<span class="tag-badge tag-type-brand">Brand</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="type">
<span class="tag-badge tag-type-type">Type</span>
</a>
</li>
</ul> </ul>
<!-- Tags List --> <!-- Tags List -->
@ -203,98 +120,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div id="tagSearchSection" class="d-none">
<div class="card mb-3">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1">Tag søgning på tværs af moduler</h5>
<p class="text-muted mb-0 small">Filtrer efter tag-navn, type og modul. Hver række viser tydeligt hvilket modul tagningen kommer fra.</p>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" id="resetUsageFiltersBtn">
<i class="bi bi-arrow-counterclockwise"></i> Nulstil filtre
</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle usage-table mb-2">
<thead>
<tr>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="tag_name">
Tag <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="tag_type">
Type <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="module">
Modul <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>Objekt</th>
<th>Entity type</th>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="entity_id">
Entity ID <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>
<button type="button" class="usage-sort-btn active" data-sort-by="tagged_at">
Tagget <i class="bi bi-sort-down"></i>
</button>
</th>
</tr>
<tr>
<th class="filter-cell">
<input id="usageFilterTagName" type="search" class="form-control form-control-sm" placeholder="Søg tag-navn">
</th>
<th class="filter-cell">
<select id="usageFilterTagType" class="form-select form-select-sm">
<option value="">Alle typer</option>
<option value="workflow">workflow</option>
<option value="status">status</option>
<option value="category">category</option>
<option value="priority">priority</option>
<option value="billing">billing</option>
<option value="brand">brand</option>
<option value="type">type</option>
</select>
</th>
<th class="filter-cell">
<select id="usageFilterModule" class="form-select form-select-sm">
<option value="">Alle moduler</option>
</select>
</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody id="usageTableBody">
<tr>
<td colspan="7" class="text-center text-muted py-4">Indlæser...</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="small text-muted" id="usageSummary">-</div>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" id="usagePrevBtn">Forrige</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="usageNextBtn">Næste</button>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Create/Edit Tag Modal --> <!-- Create/Edit Tag Modal -->
@ -323,17 +148,9 @@
<option value="category">Category - Emne/område</option> <option value="category">Category - Emne/område</option>
<option value="priority">Priority - Hastighed</option> <option value="priority">Priority - Hastighed</option>
<option value="billing">Billing - Økonomi</option> <option value="billing">Billing - Økonomi</option>
<option value="brand">Brand - Leverandør/produktbrand</option>
<option value="type">Type - Sagstype/arbejdstype</option>
</select> </select>
</div> </div>
<div class="mb-3">
<label for="tagCatchWords" class="form-label">Catch words</label>
<textarea class="form-control" id="tagCatchWords" rows="3" placeholder="fx: office 365, outlook, smtp"></textarea>
<small class="text-muted">Brug komma eller ny linje mellem ord. Bruges til auto-forslag på sager.</small>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="tagDescription" class="form-label">Beskrivelse</label> <label for="tagDescription" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="tagDescription" rows="3"></textarea> <textarea class="form-control" id="tagDescription" rows="3"></textarea>
@ -369,59 +186,19 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% block extra_js %} <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
let allTags = []; let allTags = [];
let currentFilter = 'all'; let currentFilter = 'all';
let usageDebounceTimer = null;
const usageState = {
filters: {
tag_name: '',
tag_type: '',
module: ''
},
page: 1,
page_size: 25,
sort_by: 'tagged_at',
sort_dir: 'desc',
total: 0,
total_pages: 0
};
// Load tags on page load // Load tags on page load
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadTags(); loadTags();
loadTagUsage();
setupEventListeners(); setupEventListeners();
const initialSection = window.location.hash === '#search' ? 'search' : 'admin';
switchTagSection(initialSection, false);
}); });
function switchTagSection(section, updateHash = true) {
const normalized = section === 'search' ? 'search' : 'admin';
document.querySelectorAll('#sectionTabs .nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.section === normalized);
});
document.getElementById('tagAdminSection').classList.toggle('d-none', normalized !== 'admin');
document.getElementById('tagSearchSection').classList.toggle('d-none', normalized !== 'search');
if (updateHash) {
const hash = normalized === 'search' ? '#search' : '#admin';
window.history.replaceState(null, '', hash);
}
}
function setupEventListeners() { function setupEventListeners() {
// Section tabs
document.querySelectorAll('#sectionTabs button').forEach(btn => {
btn.addEventListener('click', () => {
switchTagSection(btn.dataset.section);
});
});
// Type filter tabs // Type filter tabs
document.querySelectorAll('#typeFilter a').forEach(tab => { document.querySelectorAll('#typeFilter a').forEach(tab => {
tab.addEventListener('click', (e) => { tab.addEventListener('click', (e) => {
@ -452,9 +229,7 @@
'status': '#ffd700', 'status': '#ffd700',
'category': '#0f4c75', 'category': '#0f4c75',
'priority': '#dc3545', 'priority': '#dc3545',
'billing': '#2d6a4f', 'billing': '#2d6a4f'
'brand': '#006d77',
'type': '#5c677d'
}; };
if (colorMap[type]) { if (colorMap[type]) {
document.getElementById('tagColor').value = colorMap[type]; document.getElementById('tagColor').value = colorMap[type];
@ -465,61 +240,6 @@
// Save button // Save button
document.getElementById('saveTagBtn').addEventListener('click', saveTag); document.getElementById('saveTagBtn').addEventListener('click', saveTag);
// Usage filters
document.getElementById('usageFilterTagName').addEventListener('input', () => {
usageState.filters.tag_name = document.getElementById('usageFilterTagName').value.trim();
usageState.page = 1;
debounceUsageLoad();
});
document.getElementById('usageFilterTagType').addEventListener('change', () => {
usageState.filters.tag_type = document.getElementById('usageFilterTagType').value;
usageState.page = 1;
loadTagUsage();
});
document.getElementById('usageFilterModule').addEventListener('change', () => {
usageState.filters.module = document.getElementById('usageFilterModule').value;
usageState.page = 1;
loadTagUsage();
});
document.getElementById('resetUsageFiltersBtn').addEventListener('click', () => {
usageState.filters = { tag_name: '', tag_type: '', module: '' };
usageState.page = 1;
document.getElementById('usageFilterTagName').value = '';
document.getElementById('usageFilterTagType').value = '';
document.getElementById('usageFilterModule').value = '';
loadTagUsage();
});
document.getElementById('usagePrevBtn').addEventListener('click', () => {
if (usageState.page > 1) {
usageState.page -= 1;
loadTagUsage();
}
});
document.getElementById('usageNextBtn').addEventListener('click', () => {
if (usageState.page < usageState.total_pages) {
usageState.page += 1;
loadTagUsage();
}
});
document.querySelectorAll('.usage-sort-btn').forEach(btn => {
btn.addEventListener('click', () => {
const sortBy = btn.dataset.sortBy;
if (usageState.sort_by === sortBy) {
usageState.sort_dir = usageState.sort_dir === 'asc' ? 'desc' : 'asc';
} else {
usageState.sort_by = sortBy;
usageState.sort_dir = sortBy === 'tagged_at' ? 'desc' : 'asc';
}
usageState.page = 1;
updateSortIndicators();
loadTagUsage();
});
});
// Modal reset on close // Modal reset on close
document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => { document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => {
document.getElementById('tagForm').reset(); document.getElementById('tagForm').reset();
@ -544,131 +264,6 @@
} }
} }
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function debounceUsageLoad() {
if (usageDebounceTimer) {
clearTimeout(usageDebounceTimer);
}
usageDebounceTimer = setTimeout(() => loadTagUsage(), 280);
}
function updateSortIndicators() {
document.querySelectorAll('.usage-sort-btn').forEach(btn => {
const icon = btn.querySelector('i');
if (!icon) return;
btn.classList.remove('active');
icon.className = 'bi bi-chevron-expand';
if (btn.dataset.sortBy === usageState.sort_by) {
btn.classList.add('active');
icon.className = usageState.sort_dir === 'asc' ? 'bi bi-sort-up' : 'bi bi-sort-down';
}
});
}
function renderUsageTable(items) {
const tbody = document.getElementById('usageTableBody');
if (!Array.isArray(items) || !items.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Ingen taggede rækker matcher filtrene.</td></tr>';
return;
}
tbody.innerHTML = items.map(row => {
const taggedAt = row.tagged_at ? new Date(row.tagged_at).toLocaleString('da-DK') : '-';
const color = /^#[0-9A-Fa-f]{6}$/.test(String(row.tag_color || '')) ? row.tag_color : '#0f4c75';
const inactiveBadge = row.tag_is_active ? '' : '<span class="badge bg-secondary ms-2">Inaktiv</span>';
const entityTitle = escapeHtml(row.entity_title || `#${row.entity_id || ''}`);
const entityCell = row.entity_url
? `<a href="${escapeHtml(row.entity_url)}" class="text-decoration-none fw-semibold">${entityTitle}</a>`
: `<span class="fw-semibold">${entityTitle}</span>`;
return `
<tr>
<td>
<span class="tag-badge" style="background:${color}; color:#fff; margin:0;">${escapeHtml(row.tag_name)}</span>
${inactiveBadge}
</td>
<td><span class="badge bg-light text-dark text-uppercase">${escapeHtml(row.tag_type)}</span></td>
<td><span class="module-badge"><i class="bi bi-box"></i>${escapeHtml(row.module)}</span></td>
<td>${entityCell}</td>
<td><span class="text-muted">${escapeHtml(row.entity_type)}</span></td>
<td><strong>#${escapeHtml(row.entity_id)}</strong></td>
<td class="small text-muted">${escapeHtml(taggedAt)}</td>
</tr>
`;
}).join('');
}
function renderUsageSummary() {
const summary = document.getElementById('usageSummary');
const prevBtn = document.getElementById('usagePrevBtn');
const nextBtn = document.getElementById('usageNextBtn');
const total = usageState.total;
const page = usageState.page;
const pageSize = usageState.page_size;
const from = total ? ((page - 1) * pageSize + 1) : 0;
const to = total ? Math.min(page * pageSize, total) : 0;
summary.textContent = `Viser ${from}-${to} af ${total} rækker`;
prevBtn.disabled = page <= 1;
nextBtn.disabled = page >= usageState.total_pages;
}
function fillModuleFilter(options) {
const select = document.getElementById('usageFilterModule');
const currentValue = usageState.filters.module;
const base = '<option value="">Alle moduler</option>';
const rows = (options || []).map(option => {
return `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`;
}).join('');
select.innerHTML = `${base}${rows}`;
select.value = currentValue || '';
}
async function loadTagUsage() {
const tbody = document.getElementById('usageTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Indlæser...</td></tr>';
try {
const params = new URLSearchParams({
page: String(usageState.page),
page_size: String(usageState.page_size),
sort_by: usageState.sort_by,
sort_dir: usageState.sort_dir
});
if (usageState.filters.tag_name) params.set('tag_name', usageState.filters.tag_name);
if (usageState.filters.tag_type) params.set('tag_type', usageState.filters.tag_type);
if (usageState.filters.module) params.set('module', usageState.filters.module);
const response = await fetch(`/api/v1/tags/usage?${params.toString()}`);
if (!response.ok) {
throw new Error('Kunne ikke hente tag søgning');
}
const payload = await response.json();
usageState.total = Number(payload?.pagination?.total || 0);
usageState.total_pages = Number(payload?.pagination?.total_pages || 0);
usageState.page = Number(payload?.pagination?.page || usageState.page);
fillModuleFilter(payload.module_options || []);
renderUsageTable(payload.items || []);
renderUsageSummary();
updateSortIndicators();
} catch (error) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">Fejl ved indlæsning af tag søgning: ${escapeHtml(error.message)}</td></tr>`;
document.getElementById('usageSummary').textContent = 'Fejl ved datahentning';
}
}
function renderTags() { function renderTags() {
const container = document.getElementById('tagsList'); const container = document.getElementById('tagsList');
const filteredTags = currentFilter === 'all' const filteredTags = currentFilter === 'all'
@ -698,7 +293,6 @@
${!tag.is_active ? '<span class="badge bg-secondary ms-2">Inaktiv</span>' : ''} ${!tag.is_active ? '<span class="badge bg-secondary ms-2">Inaktiv</span>' : ''}
</div> </div>
${tag.description ? `<p class="text-muted mb-0 small">${tag.description}</p>` : ''} ${tag.description ? `<p class="text-muted mb-0 small">${tag.description}</p>` : ''}
${Array.isArray(tag.catch_words) && tag.catch_words.length ? `<p class="mb-0 mt-1"><small class="text-muted">Catch words: ${tag.catch_words.join(', ')}</small></p>` : ''}
</div> </div>
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})"> <button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})">
@ -721,11 +315,7 @@
description: document.getElementById('tagDescription').value || null, description: document.getElementById('tagDescription').value || null,
color: document.getElementById('tagColorHex').value, color: document.getElementById('tagColorHex').value,
icon: document.getElementById('tagIcon').value || null, icon: document.getElementById('tagIcon').value || null,
is_active: document.getElementById('tagActive').checked, is_active: document.getElementById('tagActive').checked
catch_words: document.getElementById('tagCatchWords').value
.split(/[\n,]+/)
.map(v => v.trim().toLowerCase())
.filter(v => v.length > 1)
}; };
try { try {
@ -762,7 +352,6 @@
document.getElementById('tagColorHex').value = tag.color; document.getElementById('tagColorHex').value = tag.color;
document.getElementById('tagIcon').value = tag.icon || ''; document.getElementById('tagIcon').value = tag.icon || '';
document.getElementById('tagActive').checked = tag.is_active; document.getElementById('tagActive').checked = tag.is_active;
document.getElementById('tagCatchWords').value = Array.isArray(tag.catch_words) ? tag.catch_words.join(', ') : '';
document.querySelector('#createTagModal .modal-title').textContent = 'Rediger Tag'; document.querySelector('#createTagModal .modal-title').textContent = 'Rediger Tag';
new bootstrap.Modal(document.getElementById('createTagModal')).show(); new bootstrap.Modal(document.getElementById('createTagModal')).show();
@ -785,4 +374,5 @@
} }
} }
</script> </script>
{% endblock %} </body>
</html>

View File

@ -2,21 +2,6 @@
{% block title %}Tekniker Dashboard V1 - Overblik{% endblock %} {% block title %}Tekniker Dashboard V1 - Overblik{% endblock %}
{% block extra_css %}
<style>
#caseTable thead th {
white-space: nowrap;
font-size: 0.78rem;
letter-spacing: 0.02em;
}
#caseTable tbody td {
font-size: 0.84rem;
vertical-align: top;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4"> <div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
@ -80,22 +65,15 @@
<table class="table table-sm table-hover mb-0" id="caseTable"> <table class="table table-sm table-hover mb-0" id="caseTable">
<thead class="table-light" id="tableHead"> <thead class="table-light" id="tableHead">
<tr> <tr>
<th>SagsID</th> <th>ID</th>
<th>Virksom.</th> <th>Titel</th>
<th>Kontakt</th> <th>Kunde</th>
<th>Beskr.</th> <th>Status</th>
<th>Type</th> <th>Dato</th>
<th>Prioritet</th>
<th>Ansvarl.</th>
<th>Gruppe/Level</th>
<th>Opret.</th>
<th>Start arbejde</th>
<th>Start inden</th>
<th>Deadline</th>
</tr> </tr>
</thead> </thead>
<tbody id="tableBody"> <tbody id="tableBody">
<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr> <tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -189,16 +167,8 @@ const allData = {
{ {
id: {{ item.id }}, id: {{ item.id }},
titel: {{ item.titel | tojson | safe }}, titel: {{ item.titel | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }}, customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }}, created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }}, status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }} deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %} }{% if not loop.last %},{% endif %}
@ -209,16 +179,7 @@ const allData = {
{ {
id: {{ item.id }}, id: {{ item.id }},
titel: {{ item.titel | tojson | safe }}, titel: {{ item.titel | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }}, customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }}, status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }} deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %} }{% if not loop.last %},{% endif %}
@ -230,16 +191,9 @@ const allData = {
item_type: {{ item.item_type | tojson | safe }}, item_type: {{ item.item_type | tojson | safe }},
item_id: {{ item.item_id }}, item_id: {{ item.item_id }},
title: {{ item.title | tojson | safe }}, title: {{ item.title | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }}, customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
task_reason: {{ item.task_reason | tojson | safe if item.task_reason else 'null' }}, task_reason: {{ item.task_reason | tojson | safe if item.task_reason else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }}, created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }}, priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }} status: {{ item.status | tojson | safe if item.status else 'null' }}
}{% if not loop.last %},{% endif %} }{% if not loop.last %},{% endif %}
@ -251,16 +205,7 @@ const allData = {
id: {{ item.id }}, id: {{ item.id }},
titel: {{ item.titel | tojson | safe }}, titel: {{ item.titel | tojson | safe }},
group_name: {{ item.group_name | tojson | safe }}, group_name: {{ item.group_name | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }}, customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }}, status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }} deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %} }{% if not loop.last %},{% endif %}
@ -280,32 +225,6 @@ function formatShortDate(dateStr) {
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' }); return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
} }
function renderCaseTableRow(item, idField = 'id', typeField = 'case') {
const itemId = item[idField];
const openType = typeField === 'item_type' ? item.item_type : 'case';
const description = item.beskrivelse || item.titel || item.title || '-';
const typeValue = item.case_type || item.item_type || '-';
const groupLevel = item.assigned_group_name || item.group_name || '-';
const priorityValue = item.priority || 'normal';
return `
<tr onclick="showCaseDetails(${itemId}, '${openType}')" style="cursor:pointer;">
<td>#${itemId}</td>
<td>${item.customer_name || '-'}</td>
<td>${item.kontakt_navn || '-'}</td>
<td>${description}</td>
<td>${typeValue}</td>
<td>${priorityValue}</td>
<td>${item.ansvarlig_navn || '-'}</td>
<td>${groupLevel}</td>
<td>${formatShortDate(item.created_at)}</td>
<td>${formatShortDate(item.start_date)}</td>
<td>${formatShortDate(item.deferred_until)}</td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`;
}
function toggleSection(filterName) { function toggleSection(filterName) {
const kpiCard = document.getElementById('kpi' + filterName.charAt(0).toUpperCase() + filterName.slice(1)); const kpiCard = document.getElementById('kpi' + filterName.charAt(0).toUpperCase() + filterName.slice(1));
const listTitle = document.getElementById('listTitle'); const listTitle = document.getElementById('listTitle');
@ -323,7 +242,7 @@ function toggleSection(filterName) {
if (currentFilter === filterName) { if (currentFilter === filterName) {
currentFilter = null; currentFilter = null;
listTitle.textContent = 'Alle sager'; listTitle.textContent = 'Alle sager';
tableBody.innerHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>'; tableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
return; return;
} }
@ -347,43 +266,70 @@ function filterAndPopulateTable(filterName) {
listTitle.innerHTML = '<i class="bi bi-inbox-fill text-primary"></i> Nye sager'; listTitle.innerHTML = '<i class="bi bi-inbox-fill text-primary"></i> Nye sager';
const data = allData.newCases || []; const data = allData.newCases || [];
if (data.length === 0) { if (data.length === 0) {
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen nye sager</td></tr>'; bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
} else { } else {
bodyHTML = data.map(item => renderCaseTableRow(item)).join(''); bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-secondary">${item.status || 'Ny'}</span></td>
<td>${formatDate(item.created_at)}</td>
</tr>
`).join('');
} }
} else if (filterName === 'myCases') { } else if (filterName === 'myCases') {
listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager'; listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager';
const data = allData.myCases || []; const data = allData.myCases || [];
if (data.length === 0) { if (data.length === 0) {
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>'; bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
} else { } else {
bodyHTML = data.map(item => renderCaseTableRow(item)).join(''); bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
} }
} else if (filterName === 'todayTasks') { } else if (filterName === 'todayTasks') {
listTitle.innerHTML = '<i class="bi bi-calendar-check text-primary"></i> Dagens opgaver'; listTitle.innerHTML = '<i class="bi bi-calendar-check text-primary"></i> Dagens opgaver';
const data = allData.todayTasks || []; const data = allData.todayTasks || [];
if (data.length === 0) { if (data.length === 0) {
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>'; bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
} else { } else {
bodyHTML = data.map(item => { bodyHTML = data.map(item => {
const normalized = { const badge = item.item_type === 'case'
...item, ? '<span class="badge bg-primary">Sag</span>'
id: item.item_id, : '<span class="badge bg-info">Ticket</span>';
titel: item.title, return `
beskrivelse: item.task_reason || item.beskrivelse, <tr onclick="showCaseDetails(${item.item_id}, '${item.item_type}')" style="cursor:pointer;">
deadline: item.deadline || item.due_at, <td>#${item.item_id}</td>
case_type: item.case_type || item.item_type <td>${item.title || '-'}<br><small class="text-muted">${item.task_reason || ''}</small></td>
}; <td>${item.customer_name || '-'}</td>
return renderCaseTableRow(normalized, 'id', 'item_type'); <td>${badge}</td>
<td>${formatDate(item.created_at)}</td>
</tr>
`;
}).join(''); }).join('');
} }
} else if (filterName === 'groupCases') { } else if (filterName === 'groupCases') {
listTitle.innerHTML = '<i class="bi bi-people-fill text-info"></i> Gruppe-sager'; listTitle.innerHTML = '<i class="bi bi-people-fill text-info"></i> Gruppe-sager';
const data = allData.groupCases || []; const data = allData.groupCases || [];
if (data.length === 0) { if (data.length === 0) {
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>'; bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
} else { } else {
bodyHTML = data.map(item => renderCaseTableRow(item)).join(''); bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}<br><span class="badge bg-secondary">${item.group_name || '-'}</span></td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
} }
} }

View File

@ -86,38 +86,14 @@
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover mb-0"> <table class="table table-sm table-hover mb-0">
<thead class="table-light"> <thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Kunde</th><th>Oprettet</th></tr></thead>
<tr>
<th>SagsID</th>
<th>Virksom.</th>
<th>Kontakt</th>
<th>Beskr.</th>
<th>Type</th>
<th>Ansvarl.</th>
<th>Gruppe/Level</th>
<th>Opret.</th>
<th>Start arbejde</th>
<th>Start inden</th>
<th>Deadline</th>
</tr>
</thead>
<tbody> <tbody>
{% for item in new_cases %} {% for item in new_cases %}
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;"> <tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
<td>#{{ item.id }}</td> <td>#{{ item.id }}</td><td>{{ item.titel }}</td><td>{{ item.customer_name }}</td><td>{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
<td>{{ item.beskrivelse or item.titel or '-' }}</td>
<td>{{ item.case_type or '-' }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="11" class="text-center text-muted py-3">Ingen nye sager</td></tr> <tr><td colspan="4" class="text-center text-muted py-3">Ingen nye sager</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -32,71 +32,59 @@
<table class="table table-hover table-sm mb-0 align-middle"> <table class="table table-hover table-sm mb-0 align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>SagsID</th>
<th>Virksom.</th>
<th>Kontakt</th>
<th>Beskr.</th>
<th>Type</th> <th>Type</th>
<th>Ansvarl.</th> <th>ID</th>
<th>Gruppe/Level</th> <th>Titel</th>
<th>Opret.</th> <th>Kunde</th>
<th>Start arbejde</th> <th>Status</th>
<th>Start inden</th> <th>Prioritet/Reason</th>
<th>Deadline</th> <th>Deadline</th>
<th>Handling</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for item in urgent_overdue %} {% for item in urgent_overdue %}
<tr> <tr>
<td><span class="badge bg-danger">Haste</span></td>
<td>#{{ item.item_id }}</td> <td>#{{ item.item_id }}</td>
<td>{{ item.customer_name or '-' }}</td> <td>{{ item.title }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td> <td>{{ item.customer_name }}</td>
<td>{{ item.beskrivelse or item.title or '-' }}</td> <td>{{ item.status }}</td>
<td>{{ item.case_type or item.item_type or '-' }}</td> <td>{{ item.attention_reason }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td> <td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-danger">Åbn</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
{% for item in today_tasks %} {% for item in today_tasks %}
<tr> <tr>
<td><span class="badge bg-primary">I dag</span></td>
<td>#{{ item.item_id }}</td> <td>#{{ item.item_id }}</td>
<td>{{ item.customer_name or '-' }}</td> <td>{{ item.title }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td> <td>{{ item.customer_name }}</td>
<td>{{ item.beskrivelse or item.title or item.task_reason or '-' }}</td> <td>{{ item.status }}</td>
<td>{{ item.case_type or item.item_type or '-' }}</td> <td>{{ item.task_reason }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td> <td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-outline-primary">Åbn</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
{% for item in my_cases %} {% for item in my_cases %}
<tr> <tr>
<td><span class="badge bg-secondary">Min sag</span></td>
<td>#{{ item.id }}</td> <td>#{{ item.id }}</td>
<td>{{ item.customer_name or '-' }}</td> <td>{{ item.titel }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td> <td>{{ item.customer_name }}</td>
<td>{{ item.beskrivelse or item.titel or '-' }}</td> <td>{{ item.status }}</td>
<td>{{ item.case_type or '-' }}</td> <td>-</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td> <td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
<td><a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary">Åbn</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if not urgent_overdue and not today_tasks and not my_cases %} {% if not urgent_overdue and not today_tasks and not my_cases %}
<tr> <tr>
<td colspan="11" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td> <td colspan="8" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>

View File

@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from datetime import date from datetime import date
from app.core.database import execute_query, execute_update, execute_query_single, table_has_column from app.core.database import execute_query, execute_update, execute_query_single
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -18,20 +18,6 @@ router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
def _case_start_date_sql(alias: str = "s") -> str:
"""Select start_date only when the live schema actually has it."""
if table_has_column("sag_sager", "start_date"):
return f"{alias}.start_date"
return "NULL::date AS start_date"
def _case_type_sql(alias: str = "s") -> str:
"""Select case type across old/new sag schemas."""
if table_has_column("sag_sager", "type"):
return f"COALESCE({alias}.template_key, {alias}.type, 'ticket') AS case_type"
return f"COALESCE({alias}.template_key, 'ticket') AS case_type"
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
async def ticket_root_redirect(): async def ticket_root_redirect():
return RedirectResponse(url="/sag", status_code=302) return RedirectResponse(url="/sag", status_code=302)
@ -376,8 +362,6 @@ async def new_ticket_page(request: Request):
def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]: def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""Collect live data slices for technician-focused dashboard variants.""" """Collect live data slices for technician-focused dashboard variants."""
case_start_date_sql = _case_start_date_sql()
case_type_sql = _case_type_sql()
user_query = """ user_query = """
SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
FROM users FROM users
@ -387,34 +371,16 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
user_result = execute_query(user_query, (technician_user_id,)) user_result = execute_query(user_query, (technician_user_id,))
technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}" technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}"
new_cases_query = f""" new_cases_query = """
SELECT SELECT
s.id, s.id,
s.titel, s.titel,
s.beskrivelse,
s.priority,
s.status, s.status,
s.created_at, s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline, s.deadline,
{case_type_sql}, COALESCE(c.name, 'Ukendt kunde') AS customer_name
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND s.status = 'åben' AND s.status = 'åben'
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
@ -422,34 +388,16 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
""" """
new_cases = execute_query(new_cases_query) new_cases = execute_query(new_cases_query)
my_cases_query = f""" my_cases_query = """
SELECT SELECT
s.id, s.id,
s.titel, s.titel,
s.beskrivelse,
s.priority,
s.status, s.status,
s.created_at, s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline, s.deadline,
{case_type_sql}, COALESCE(c.name, 'Ukendt kunde') AS customer_name
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket' AND s.status <> 'lukket'
@ -458,36 +406,19 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
""" """
my_cases = execute_query(my_cases_query, (technician_user_id,)) my_cases = execute_query(my_cases_query, (technician_user_id,))
today_tasks_query = f""" today_tasks_query = """
SELECT SELECT
'case' AS item_type, 'case' AS item_type,
s.id AS item_id, s.id AS item_id,
s.titel AS title, s.titel AS title,
s.beskrivelse,
s.status, s.status,
s.deadline AS due_at, s.deadline AS due_at,
s.created_at, s.created_at,
{case_start_date_sql},
s.deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name, COALESCE(c.name, 'Ukendt kunde') AS customer_name,
{case_type_sql}, NULL::text AS priority,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
COALESCE(s.priority::text, 'normal') AS priority,
'Sag deadline i dag' AS task_reason 'Sag deadline i dag' AS task_reason
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket' AND s.status <> 'lukket'
@ -499,22 +430,14 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
'ticket' AS item_type, 'ticket' AS item_type,
t.id AS item_id, t.id AS item_id,
t.subject AS title, t.subject AS title,
NULL::text AS beskrivelse,
t.status, t.status,
NULL::date AS due_at, NULL::date AS due_at,
t.created_at, t.created_at,
NULL::date AS start_date,
NULL::date AS deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name, COALESCE(c.name, 'Ukendt kunde') AS customer_name,
'ticket' AS case_type,
NULL::text AS kontakt_navn,
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
COALESCE(t.priority, 'normal') AS priority, COALESCE(t.priority, 'normal') AS priority,
'Ticket oprettet i dag' AS task_reason 'Ticket oprettet i dag' AS task_reason
FROM tticket_tickets t FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
WHERE t.assigned_to_user_id = %s WHERE t.assigned_to_user_id = %s
AND t.status IN ('open', 'in_progress', 'pending_customer') AND t.status IN ('open', 'in_progress', 'pending_customer')
AND DATE(t.created_at) = CURRENT_DATE AND DATE(t.created_at) = CURRENT_DATE
@ -524,36 +447,19 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
""" """
today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id)) today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id))
urgent_overdue_query = f""" urgent_overdue_query = """
SELECT SELECT
'case' AS item_type, 'case' AS item_type,
s.id AS item_id, s.id AS item_id,
s.titel AS title, s.titel AS title,
s.beskrivelse,
s.status, s.status,
s.deadline AS due_at, s.deadline AS due_at,
s.created_at, s.created_at,
{case_start_date_sql},
s.deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name, COALESCE(c.name, 'Ukendt kunde') AS customer_name,
{case_type_sql},
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
NULL::text AS priority, NULL::text AS priority,
'Over deadline' AS attention_reason 'Over deadline' AS attention_reason
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND s.status <> 'lukket' AND s.status <> 'lukket'
AND s.deadline IS NOT NULL AND s.deadline IS NOT NULL
@ -565,17 +471,10 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
'ticket' AS item_type, 'ticket' AS item_type,
t.id AS item_id, t.id AS item_id,
t.subject AS title, t.subject AS title,
NULL::text AS beskrivelse,
t.status, t.status,
NULL::date AS due_at, NULL::date AS due_at,
t.created_at, t.created_at,
NULL::date AS start_date,
NULL::date AS deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name, COALESCE(c.name, 'Ukendt kunde') AS customer_name,
'ticket' AS case_type,
NULL::text AS kontakt_navn,
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
COALESCE(t.priority, 'normal') AS priority, COALESCE(t.priority, 'normal') AS priority,
CASE CASE
WHEN t.priority = 'urgent' THEN 'Urgent prioritet' WHEN t.priority = 'urgent' THEN 'Urgent prioritet'
@ -583,7 +482,6 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
END AS attention_reason END AS attention_reason
FROM tticket_tickets t FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
WHERE t.status IN ('open', 'in_progress', 'pending_customer') WHERE t.status IN ('open', 'in_progress', 'pending_customer')
AND COALESCE(t.priority, '') IN ('urgent', 'high') AND COALESCE(t.priority, '') IN ('urgent', 'high')
AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL) AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL)
@ -644,36 +542,19 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
# Get group cases (cases assigned to user's groups) # Get group cases (cases assigned to user's groups)
group_cases = [] group_cases = []
if user_group_ids: if user_group_ids:
group_cases_query = f""" group_cases_query = """
SELECT SELECT
s.id, s.id,
s.titel, s.titel,
s.beskrivelse,
s.priority,
s.status, s.status,
s.created_at, s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline, s.deadline,
{case_type_sql},
s.assigned_group_id, s.assigned_group_id,
g.name AS group_name, g.name AS group_name,
COALESCE(c.name, 'Ukendt kunde') AS customer_name, COALESCE(c.name, 'Ukendt kunde') AS customer_name
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN groups g ON g.id = s.assigned_group_id LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND s.assigned_group_id = ANY(%s) AND s.assigned_group_id = ANY(%s)
AND s.status <> 'lukket' AND s.status <> 'lukket'

View File

@ -1,195 +0,0 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kompakte Designforslag - Sagsdetaljer</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding-bottom: 50px;
font-size: 0.9rem; /* Generelt lidt mindre tekststørrelse for helheden */
}
.container { max-width: 900px; }
.section-title {
color: #0f4c75;
border-bottom: 2px solid #0f4c75;
padding-bottom: 4px;
margin-top: 40px;
margin-bottom: 15px;
font-size: 1.1rem;
font-weight: 600;
}
/* Værktøjs-klasser for at fjerne default p-margins */
p:last-child { margin-bottom: 0; }
/* ---------------------------------------------------
Forslag 1: Inline Chat / Slack Kompakt
Alt på (næsten) én linje, nul spildplads.
--------------------------------------------------- */
.f1-container { background: white; border: 1px solid #ddd; border-radius: 4px; padding: 10px; }
.f1-row { padding: 4px 8px; border-radius: 4px; line-height: 1.4; border-bottom: 1px dashed #f0f0f0; }
.f1-row:last-child { border-bottom: none; }
.f1-row:hover { background-color: #f8f9fa; }
.f1-meta { color: #888; font-size: 0.8rem; margin-right: 8px; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.f1-author { font-weight: 600; margin-right: 8px; }
.f1-author.tech { color: #0f4c75; }
.f1-author.cust { color: #b33939; }
.f1-content { color: #222; }
/* ---------------------------------------------------
Forslag 2: Helpdesk Split (Venstre/Højre)
Tydelig opdeling mellem forfatter og tekst, men kompakt font.
--------------------------------------------------- */
.f2-container { border: 1px solid #dee2e6; border-radius: 4px; background: white; }
.f2-row { display: flex; border-bottom: 1px solid #e9ecef; }
.f2-row:last-child { border-bottom: none; }
.f2-left { width: 140px; background: #f8f9fa; padding: 8px 10px; flex-shrink: 0; border-right: 1px solid #e9ecef; }
.f2-right { padding: 8px 12px; flex-grow: 1; color: #333; }
.f2-name { font-weight: 600; font-size: 0.85rem; margin-bottom: 2px; }
.f2-time { font-size: 0.75rem; color: #6c757d; }
.f2-badge-tech { color: #0f4c75; font-size: 0.75rem; font-weight: bold; }
.f2-badge-cust { color: #6c757d; font-size: 0.75rem; font-weight: bold; }
/* ---------------------------------------------------
Forslag 3: Minimalistisk Logbog
Information-dense liste. Små headere, tekst umiddelbart under.
--------------------------------------------------- */
.f3-container { background: white; border: 1px solid #ccc; border-radius: 4px; }
.f3-item { border-bottom: 1px solid #eee; }
.f3-item:last-child { border-bottom: none; }
.f3-header { display: flex; justify-content: space-between; align-items: center; background: #fdfdfd; padding: 3px 8px; border-left: 3px solid #ccc; font-size: 0.85rem; font-weight: 600; color: #444; }
.f3-header.tech { border-left-color: #0f4c75; background: #f0f4f8; }
.f3-body { padding: 6px 8px 8px 11px; color: #222; }
.f3-small { font-weight: normal; color: #777; font-size: 0.75rem; }
</style>
</head>
<body>
<div class="container mt-4">
<h2 class="mb-4 text-center">Fokuseret på Minimal Plads (Kompakt)</h2>
<p class="text-muted text-center mb-5">Her er 3 designs uden store ikoner, uden navnebobler og med minimal whitespace, præcis som i en professionel log eller et tæt ticket-system.</p>
<!-- ==============================================
FORSLAG 1: INLINE CHAT (SLACK KOMPAKT STYLE)
============================================== -->
<h3 class="section-title">Forslag 1: Inline Log (Slack Kompakt style)</h3>
<p class="text-muted small mb-2">Minder om terminal-output eller kompakt chat. Alt udover teksten står på én linje, og marginer er næsten fjernet.</p>
<div class="f1-container">
<div class="f1-row">
<span class="f1-meta">I dag 10:00</span>
<span class="f1-author cust">Jens Jensen:</span>
<span class="f1-content">Vi har et problem med at vores to printere på kontoret ikke vil forbinde til netværket siden fredag. Skærmene lyser, men de melder offline på printserveren.</span>
</div>
<div class="f1-row">
<span class="f1-meta">I dag 10:15</span>
<span class="f1-author tech">Christian Thomas:</span>
<span class="f1-content">Hej Jens. Jeg kan se at switchen port 4 & 5 var nede hurtigt i nat. Har I prøvet at genstarte dem, så de fanger ny DHCP IP?</span>
</div>
<div class="f1-row">
<span class="f1-meta">I dag 10:35</span>
<span class="f1-author cust">Jens Jensen:</span>
<span class="f1-content">Ja, det har vi nu og det løste det mærkeligt nok for den ene, men HP printeren driller stadig.</span>
</div>
<div class="f1-row">
<span class="f1-meta">I dag 10:45</span>
<span class="f1-author tech">Christian Thomas:</span>
<span class="f1-content">Jeg logger lige på jeres firewall udefra om 5 minutter og tjekker om HP'en er blokeret i MAC-filteret.</span>
</div>
</div>
<!-- ==============================================
FORSLAG 2: HELPDESK SPLIT (2-KOLONNER)
============================================== -->
<h3 class="section-title">Forslag 2: Helpdesk Split (ITSM style)</h3>
<p class="text-muted small mb-2">Klassisk 2-kolonne layout. Venstre side har fast bredde til metadata, højre side udnytter hele bredden til ren tekst. Ingen tidsspilde vertikalt.</p>
<div class="f2-container">
<!-- Oprindelig sag -->
<div class="f2-row">
<div class="f2-left">
<div class="f2-name">Jens Jensen</div>
<div class="f2-badge-cust">KUNDE</div>
<div class="f2-time mt-1">I dag, kl. 10:00</div>
</div>
<div class="f2-right">
Vi har et problem med at vores to printere på kontoret ikke vil forbinde til netværket siden fredag. Skærmene lyser, men de melder offline på printserveren.
</div>
</div>
<!-- Svar -->
<div class="f2-row">
<div class="f2-left">
<div class="f2-name text-primary">Christian Thomas</div>
<div class="f2-badge-tech">BMC NETWORKS</div>
<div class="f2-time mt-1">I dag, kl. 10:15</div>
</div>
<div class="f2-right">
Hej Jens.<br>Jeg kan se at switchen port 4 & 5 var nede hurtigt i nat. Har I prøvet at genstarte dem, så de fanger ny DHCP IP?
</div>
</div>
<!-- Svar -->
<div class="f2-row">
<div class="f2-left">
<div class="f2-name">Jens Jensen</div>
<div class="f2-badge-cust">KUNDE</div>
<div class="f2-time mt-1">I dag, kl. 10:35</div>
</div>
<div class="f2-right">
Ja, det har vi nu og det løste det mærkeligt nok for den ene, men HP printeren driller stadig.
</div>
</div>
</div>
<!-- ==============================================
FORSLAG 3: MINIMALISTISK LOGBOG
============================================== -->
<h3 class="section-title">Forslag 3: Minimalistisk Logbog</h3>
<p class="text-muted small mb-2">Hver tråd adskilles af en meget tynd grå overskrift. Ingen kasser rundt om teksten, den flyder frit for maksimal informationsdensitet.</p>
<div class="f3-container">
<div class="f3-item">
<div class="f3-header">
<span>Jens Jensen <span class="fw-normal text-muted">(Kunde)</span></span>
<span class="f3-small">I dag, kl. 10:00</span>
</div>
<div class="f3-body">
Vi har et problem med at vores to printere på kontoret ikke vil forbinde til netværket siden fredag. Skærmene lyser, men de melder offline på printserveren.
</div>
</div>
<div class="f3-item">
<div class="f3-header tech">
<span>Christian Thomas <span class="fw-normal text-muted" style="color: #0f4c75 !important;">(Tekniker)</span></span>
<span class="f3-small">I dag, kl. 10:15</span>
</div>
<div class="f3-body">
Hej Jens.<br>Jeg kan se at switchen port 4 & 5 var nede hurtigt i nat. Har I prøvet at genstarte dem, så de fanger ny DHCP IP?
</div>
</div>
<div class="f3-item">
<div class="f3-header">
<span>Jens Jensen <span class="fw-normal text-muted">(Kunde)</span></span>
<span class="f3-small">I dag, kl. 10:35</span>
</div>
<div class="f3-body">
Ja, det har vi nu og det løste det mærkeligt nok for den ene, men HP printeren driller stadig.
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,338 +0,0 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Designforslag - Sagsdetaljer & Kommentarer</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body {
background: #f0f2f5;
padding: 3rem 1rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.container {
max-width: 900px;
}
.proposal-wrapper {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
padding: 2rem;
margin-bottom: 4rem;
}
.proposal-title {
border-bottom: 2px solid #f0f2f5;
padding-bottom: 1rem;
margin-bottom: 2rem;
color: #0f4c75;
}
/* Fælles: Opgavebeskrivelse kort */
.desc-card {
background: #fff;
border: 1px solid #e1e4e8;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.desc-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
color: #6c757d;
font-weight: 700;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
}
/* ---------------------------------------------------
FORSLAG 1: CHAT / MESSENGER STYLE
--------------------------------------------------- */
.chat-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.chat-msg {
max-width: 85%;
padding: 1rem;
border-radius: 12px;
position: relative;
}
.chat-internal {
align-self: flex-end;
background-color: #e3f2fd;
border-bottom-right-radius: 2px;
color: #084298;
}
.chat-customer {
align-self: flex-start;
background-color: #f1f3f5;
border-bottom-left-radius: 2px;
color: #212529;
border: 1px solid #e9ecef;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
margin-bottom: 0.5rem;
font-weight: 600;
opacity: 0.8;
}
/* ---------------------------------------------------
FORSLAG 2: TIMELINE / ACTIVITY FEED
--------------------------------------------------- */
.timeline {
position: relative;
padding-left: 3rem;
margin-top: 1rem;
}
.timeline::before {
content: '';
position: absolute;
left: 17px;
top: 0;
bottom: 0;
width: 2px;
background: #e9ecef;
}
.timeline-item {
position: relative;
margin-bottom: 2rem;
}
.timeline-icon {
position: absolute;
left: -3rem;
width: 36px;
height: 36px;
border-radius: 50%;
background: white;
border: 2px solid #e9ecef;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
font-size: 1.1rem;
}
.icon-internal { border-color: #ffc107; color: #ffc107; }
.icon-customer { border-color: #198754; color: #198754; }
.icon-system { border-color: #6c757d; color: #6c757d; }
.timeline-content {
background: white;
border: 1px solid #e9ecef;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
padding: 1.25rem;
}
.timeline-meta {
font-size: 0.85rem;
color: #6c757d;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* ---------------------------------------------------
FORSLAG 3: CLEAN CARDS MED FARVEKODER (Trello/Jira style)
--------------------------------------------------- */
.comment-card {
background: white;
border: 1px solid #e9ecef;
border-radius: 8px;
margin-bottom: 1.5rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.02);
}
.comment-card.type-internal {
border-left: 4px solid #ffc107; /* Gul venstre kant for intern */
}
.comment-card.type-customer {
border-left: 4px solid #0dcaf0; /* Blå/grøn venstre kant for kunde */
}
.card-header-clean {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header-clean .author {
font-weight: 600;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge-type {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="mb-5 text-center">
<h1 class="fw-bold" style="color: #0f4c75;">UI Forslag: Sagsdetaljer & Kommentarer</h1>
<p class="text-muted">3 forskellige måder at redesigne "Opgavebeskrivelse" og "Kommentarer" på, uden at røre live-koden endnu.</p>
</div>
<!-- FORSLAG 1 -->
<div class="proposal-wrapper">
<h3 class="proposal-title"><i class="bi bi-chat-dots me-2"></i>Forslag 1: Chat / Messenger UI</h3>
<p class="text-muted mb-4">Gør det nemt at adskille hvem der siger hvad. Interne noter (højre, blå), kundens svar (venstre, grå). Beskrivelsen er "låst" i toppen som opgavens udgangspunkt.</p>
<div class="desc-card">
<div class="desc-label">
<span><i class="bi bi-card-text me-2"></i>Opgavebeskrivelse</span>
<a href="#" class="text-decoration-none text-muted"><i class="bi bi-pencil-square"></i> Rediger</a>
</div>
<p class="mb-0">awrtqerqerg</p>
</div>
<div class="chat-container mt-4">
<div class="chat-msg chat-internal">
<div class="chat-header">
<span><i class="bi bi-lock-fill me-1"></i> Hurtig kommentar (Intern)</span>
<span class="small fw-normal">19/03-2026 06:34</span>
</div>
<div>tiest</div>
</div>
<div class="chat-msg chat-internal">
<div class="chat-header">
<span><i class="bi bi-lock-fill me-1"></i> Hurtig kommentar (Intern)</span>
<span class="small fw-normal">19/03-2026 07:30</span>
</div>
<div>test</div>
</div>
<div class="chat-msg chat-customer">
<div class="chat-header">
<span><i class="bi bi-person-circle me-1"></i> Bruger / Kunde svar</span>
<span class="small fw-normal">19/03-2026 08:03</span>
</div>
<div>sdfsdfsdfgsg</div>
</div>
</div>
</div>
<!-- FORSLAG 2 -->
<div class="proposal-wrapper">
<h3 class="proposal-title"><i class="bi bi-clock-history me-2"></i>Forslag 2: Timeline / Activity Feed</h3>
<p class="text-muted mb-4">Inspireret af GitHub Issues. En lodret historik-streg samler oprettelse, kommentarer og ændringer i et nemt læsbart flow.</p>
<div class="timeline">
<!-- Beskrivelsen som første post i timelinen -->
<div class="timeline-item">
<div class="timeline-icon icon-system"><i class="bi bi-flag-fill"></i></div>
<div class="timeline-content">
<div class="timeline-meta">
<strong>Sag oprettet</strong> &bull; Beskrivelse tilføjet
<div class="ms-auto"><a href="#" class="text-muted"><i class="bi bi-pencil"></i></a></div>
</div>
<div class="p-3 bg-light rounded border">
awrtqerqerg
</div>
</div>
</div>
<div class="timeline-item">
<div class="timeline-icon icon-internal"><i class="bi bi-chat-square-text"></i></div>
<div class="timeline-content">
<div class="timeline-meta">
<strong>Hurtig kommentar</strong> (Intern note)
<span class="ms-auto text-muted small">19/03-2026 06:34</span>
</div>
<div>tiest</div>
</div>
</div>
<div class="timeline-item">
<div class="timeline-icon icon-customer"><i class="bi bi-envelope"></i></div>
<div class="timeline-content" style="border-color: #c3e6cb;">
<div class="timeline-meta text-success">
<strong>Bruger</strong> (Svar fra kunde)
<span class="ms-auto text-muted small">19/03-2026 08:03</span>
</div>
<div>sdfsdfsdfgsg</div>
</div>
</div>
</div>
</div>
<!-- FORSLAG 3 -->
<div class="proposal-wrapper">
<h3 class="proposal-title"><i class="bi bi-card-list me-2"></i>Forslag 3: Clean Cards (Farvekodet venstre kant)</h3>
<p class="text-muted mb-4">Meget stilrent design til CRM / Enterprise systemer. Bevarer fuld bredde for lang tekst, men bruger en tyk farvekode på venstre kant til at identificere typen lynhurtigt.</p>
<div class="desc-card shadow-sm" style="border-top: 4px solid #0f4c75;">
<div class="desc-label">
<span>Opgavebeskrivelse</span>
<button class="btn btn-sm btn-outline-secondary py-0"><i class="bi bi-pencil"></i> Rediger</button>
</div>
<p class="mb-0 fs-5">awrtqerqerg</p>
</div>
<h5 class="mb-3 mt-5" style="color: #6c757d; font-size: 0.9rem; text-transform: uppercase;">Kommentarer & Historik</h5>
<div class="comment-card type-internal">
<div class="card-header-clean">
<div class="author">
<div class="bg-warning text-dark rounded-circle d-flex align-items-center justify-content-center" style="width:24px; height:24px; font-size:0.75rem;"><i class="bi bi-lock-fill"></i></div>
Hurtig kommentar
<span class="badge bg-warning text-dark badge-type ms-2">Intern Note</span>
</div>
<div class="text-muted small">19/03-2026 06:34</div>
</div>
<div class="card-body p-3">
tiest
</div>
</div>
<div class="comment-card type-internal">
<div class="card-header-clean">
<div class="author">
<div class="bg-warning text-dark rounded-circle d-flex align-items-center justify-content-center" style="width:24px; height:24px; font-size:0.75rem;"><i class="bi bi-lock-fill"></i></div>
Hurtig kommentar
<span class="badge bg-warning text-dark badge-type ms-2">Intern Note</span>
</div>
<div class="text-muted small">19/03-2026 07:59</div>
</div>
<div class="card-body p-3">
adfgaegea hsrhsh
</div>
</div>
<div class="comment-card type-customer">
<div class="card-header-clean">
<div class="author">
<div class="bg-info text-white rounded-circle d-flex align-items-center justify-content-center" style="width:24px; height:24px; font-size:0.75rem;"><i class="bi bi-person-fill"></i></div>
Bruger
<span class="badge bg-info text-dark badge-type ms-2">Kunde</span>
</div>
<div class="text-muted small">19/03-2026 08:03</div>
</div>
<div class="card-body p-3">
sdfsdfsdfgsg
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,290 +0,0 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3 Kompakte Forslag - Sagsdetaljer</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<style>
:root {
--accent: #0f4c75;
--bg: #f4f6f8;
--card: #ffffff;
--muted: #6c757d;
--border: #e3e7eb;
}
body {
background: var(--bg);
color: #1f2937;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 0.9rem;
}
.wrap {
max-width: 980px;
margin: 24px auto 48px;
padding: 0 12px;
}
.hero {
background: linear-gradient(145deg, #fafdff, #eef5fb);
border: 1px solid #d7e7f7;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 16px;
}
.hero h1 {
font-size: 1.15rem;
margin: 0 0 4px;
color: var(--accent);
font-weight: 700;
}
.hero p {
margin: 0;
color: var(--muted);
font-size: 0.83rem;
}
.proposal {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 14px;
overflow: hidden;
}
.proposal-head {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: #fbfdff;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.proposal-head h2 {
margin: 0;
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--accent);
font-weight: 700;
}
.proposal-head small {
color: #6b7280;
font-size: 0.73rem;
}
.proposal-body {
padding: 10px 12px;
}
.example {
border: 1px solid #edf0f3;
border-radius: 8px;
overflow: hidden;
}
.msg-row {
padding: 7px 10px;
border-bottom: 1px solid #f2f4f6;
}
.msg-row:last-child {
border-bottom: none;
}
.meta {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 2px;
font-size: 0.78rem;
color: #64748b;
}
.meta .name {
color: #0f172a;
font-weight: 600;
}
.meta .time {
margin-left: auto;
color: #9aa3ad;
font-size: 0.72rem;
}
.content {
color: #1f2937;
line-height: 1.43;
font-size: 0.86rem;
}
/* Forslag 1 */
.avatar {
width: 21px;
height: 21px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.63rem;
font-weight: 700;
color: white;
background: var(--accent);
flex-shrink: 0;
}
.sys {
background: #7d8793;
font-style: italic;
color: #5f6b76;
}
/* Forslag 2 */
.log-line {
display: flex;
gap: 0;
border-bottom: 1px solid #f1f3f5;
}
.log-line:last-child {
border-bottom: none;
}
.stripe {
width: 3px;
background: #cfd6de;
flex-shrink: 0;
}
.stripe.tech {
background: var(--accent);
}
.stripe.system {
background: #9da8b5;
}
.log-inner {
padding: 6px 9px;
width: 100%;
}
/* Forslag 3 */
.inbox {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
background: #fbfdff;
}
.bubble {
max-width: 78%;
border: 1px solid #dee5ed;
border-radius: 7px;
padding: 6px 9px;
font-size: 0.86rem;
line-height: 1.43;
}
.left { align-self: flex-start; background: #eef3f8; }
.right { align-self: flex-end; background: #e4eef8; border-color: #c7d9eb; }
.center { align-self: center; max-width: 90%; background: #f4f4f4; border-color: #e3e3e3; color: #69727d; font-style: italic; }
@media (max-width: 768px) {
.proposal-head {
flex-direction: column;
align-items: flex-start;
}
.bubble {
max-width: 92%;
}
}
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<h1>3 nye forslag på én side</h1>
<p>Fokus: kompakt layout, mindre vertikalt spild, hurtig læsning i drift.</p>
</div>
<section class="proposal">
<div class="proposal-head">
<h2>Forslag 1: Kompakt Thread</h2>
<small>Bedst balance mellem læsbarhed og tæthed</small>
</div>
<div class="proposal-body">
<div class="example">
<div class="msg-row">
<div class="meta"><span class="avatar">JJ</span><span class="name">Jens Jensen</span><span class="time">10:00</span></div>
<div class="content" style="padding-left:28px;">Vi har et problem med printerne siden fredag. De melder offline.</div>
</div>
<div class="msg-row">
<div class="meta"><span class="avatar">CT</span><span class="name">Christian Thomas</span><span class="time">10:15</span></div>
<div class="content" style="padding-left:28px;">Jeg kan se port 4 og 5 har været nede. Kan I genstarte printerne?</div>
</div>
<div class="msg-row sys">
<div class="meta"><span class="avatar" style="background:#7d8793;">SY</span><span class="name">System</span><span class="time">10:20</span></div>
<div class="content" style="padding-left:28px;">Status ændret: Åben -> Under behandling</div>
</div>
</div>
</div>
</section>
<section class="proposal">
<div class="proposal-head">
<h2>Forslag 2: Activity Log</h2>
<small>Maksimal informationsdensitet</small>
</div>
<div class="proposal-body">
<div class="example">
<div class="log-line">
<div class="stripe"></div>
<div class="log-inner">
<div class="meta"><span class="name">Jens Jensen</span><span class="time">10:00</span></div>
<div class="content">Vi har et problem med printerne siden fredag. De melder offline.</div>
</div>
</div>
<div class="log-line">
<div class="stripe tech"></div>
<div class="log-inner">
<div class="meta"><span class="name">Christian Thomas</span><span class="time">10:15</span></div>
<div class="content">Jeg kan se port 4 og 5 har været nede. Kan I genstarte printerne?</div>
</div>
</div>
<div class="log-line">
<div class="stripe system"></div>
<div class="log-inner">
<div class="meta"><span class="name">System</span><span class="time">10:20</span></div>
<div class="content" style="color:#6b7280; font-style:italic;">Status ændret: Åben -> Under behandling</div>
</div>
</div>
</div>
</div>
</section>
<section class="proposal">
<div class="proposal-head">
<h2>Forslag 3: Split Inbox</h2>
<small>Tydelig kunde/tekniker-retning</small>
</div>
<div class="proposal-body">
<div class="example inbox">
<div class="bubble left">Vi har et problem med printerne siden fredag. De melder offline.<div class="meta" style="margin-top:4px; margin-bottom:0;"><span class="name">Jens Jensen</span><span class="time">10:00</span></div></div>
<div class="bubble right">Jeg kan se port 4 og 5 har været nede. Kan I genstarte printerne?<div class="meta" style="margin-top:4px; margin-bottom:0;"><span class="name">Christian Thomas</span><span class="time">10:15</span></div></div>
<div class="bubble center">Status ændret: Åben -> Under behandling<div class="meta" style="justify-content:center; margin-top:4px; margin-bottom:0;"><span class="name">System</span><span class="time">10:20</span></div></div>
</div>
</div>
</section>
</div>
</body>
</html>

View File

@ -1,197 +0,0 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Top 3 Forslag Kommentarfelt</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"/>
<style>
:root{--accent:#0f4c75;--bg:#f4f6f8;--card:#fff;--border:#e3e7eb;--muted:#6c757d}
body{background:var(--bg);color:#1f2937;font:.875rem/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;margin:0}
.wrap{max-width:920px;margin:20px auto 60px;padding:0 14px}
h1{font-size:1.05rem;color:var(--accent);font-weight:700;margin:0 0 2px}
.sub{color:var(--muted);font-size:.78rem;margin-bottom:18px}
/* Tabs */
.tab-row{display:flex;gap:6px;margin-bottom:14px}
.tab-btn{padding:6px 14px;border:1px solid var(--border);border-radius:6px;background:var(--card);font-size:.8rem;font-weight:600;cursor:pointer;color:#555;transition:all .15s}
.tab-btn:hover{background:#edf2f7}
.tab-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
.panel{display:none}
.panel.active{display:block}
/* Fælles */
.box{background:var(--card);border:1px solid var(--border);border-radius:8px;overflow:hidden}
.input-bar{padding:6px 8px;border-top:1px solid var(--border);background:#fafbfc;display:flex;gap:6px}
.input-bar textarea{flex:1;resize:none;border:1px solid #d1d5db;border-radius:4px;padding:5px 8px;font-size:.84rem;font-family:inherit}
.input-bar button{background:var(--accent);color:#fff;border:none;border-radius:4px;padding:4px 12px;font-size:.82rem;cursor:pointer}
/* === FORSLAG 1: THREAD === */
.t-row{padding:5px 10px;border-bottom:1px solid #f0f2f4}
.t-row:last-of-type{border-bottom:none}
.t-head{display:flex;align-items:center;gap:6px;font-size:.78rem;color:#64748b}
.t-av{width:20px;height:20px;border-radius:50%;background:var(--accent);color:#fff;font-size:.58rem;font-weight:700;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
.t-av.sys{background:#94a3b8}
.t-name{font-weight:600;color:#0f172a}
.t-time{margin-left:auto;font-size:.7rem;color:#a1a8b3}
.t-body{padding-left:26px;color:#1e293b;line-height:1.4;font-size:.84rem}
.t-row.is-sys{background:#f8fafc}
.t-row.is-sys .t-body{color:#64748b;font-style:italic;font-size:.8rem}
/* === FORSLAG 2: LOG === */
.l-row{display:flex;border-bottom:1px solid #f0f2f4}
.l-row:last-of-type{border-bottom:none}
.l-stripe{width:3px;flex-shrink:0;background:#d1d5db}
.l-stripe.tech{background:var(--accent)}
.l-stripe.sys{background:#94a3b8}
.l-inner{padding:4px 9px;flex:1}
.l-meta{font-size:.72rem;color:#64748b}
.l-meta b{color:#0f172a;font-weight:600}
.l-text{color:#1e293b;font-size:.84rem;line-height:1.4}
.l-row.is-sys .l-text{color:#64748b;font-style:italic;font-size:.8rem}
/* === FORSLAG 3: INBOX === */
.ib-feed{display:flex;flex-direction:column;gap:5px;padding:8px 10px}
.ib-m{max-width:78%}
.ib-m.left{align-self:flex-start}
.ib-m.right{align-self:flex-end}
.ib-m.ctr{align-self:center;max-width:90%}
.ib-b{padding:5px 9px;border-radius:6px;font-size:.84rem;line-height:1.4;color:#1e293b}
.ib-m.left .ib-b{background:#eef3f8;border:1px solid #d4e0ed}
.ib-m.right .ib-b{background:#e2ecf5;border:1px solid #c1d4e8}
.ib-m.ctr .ib-b{background:#f3f3f3;border:1px solid #ddd;color:#64748b;font-style:italic;font-size:.78rem;text-align:center}
.ib-info{font-size:.68rem;color:#94a3b8;margin-top:2px;padding:0 2px}
.ib-m.right .ib-info{text-align:right}
.ib-m.ctr .ib-info{text-align:center}
</style>
</head>
<body>
<div class="wrap">
<h1>Mine 3 bedste forslag til kommentarfeltet</h1>
<p class="sub">Klik på fanerne for at skifte mellem forslagene. Alle er kompakte og klar til at implementere direkte.</p>
<div class="tab-row">
<button class="tab-btn active" onclick="show(0)">1 · Thread</button>
<button class="tab-btn" onclick="show(1)">2 · Activity Log</button>
<button class="tab-btn" onclick="show(2)">3 · Split Inbox</button>
</div>
<!-- ========== FORSLAG 1: THREAD ========== -->
<div class="panel active" id="p0">
<div class="box">
<div class="t-row">
<div class="t-head"><span class="t-av">JJ</span><span class="t-name">Jens Jensen</span><span class="t-time">21/03 10:00</span></div>
<div class="t-body">Vi har et problem med vores to printere de melder offline siden fredag.</div>
</div>
<div class="t-row">
<div class="t-head"><span class="t-av">CT</span><span class="t-name">Christian Thomas</span><span class="t-time">21/03 10:15</span></div>
<div class="t-body">Port 4 og 5 var nede kort i nat. Har I prøvet at genstarte printerne så de fanger ny DHCP?</div>
</div>
<div class="t-row is-sys">
<div class="t-head"><span class="t-av sys">&#9881;</span><span class="t-name" style="color:var(--muted)">System</span><span class="t-time">21/03 10:20</span></div>
<div class="t-body">Status ændret: Åben → Under behandling</div>
</div>
<div class="t-row">
<div class="t-head"><span class="t-av">JJ</span><span class="t-name">Jens Jensen</span><span class="t-time">21/03 10:35</span></div>
<div class="t-body">HP'en virker stadig ikke den anden kom op igen efter genstart.</div>
</div>
<div class="t-row">
<div class="t-head"><span class="t-av">CT</span><span class="t-name">Christian Thomas</span><span class="t-time">21/03 10:45</span></div>
<div class="t-body">Jeg logger på jeres firewall nu og tjekker MAC-filteret.</div>
</div>
<div class="input-bar">
<textarea rows="1" placeholder="Skriv en kommentar..."></textarea>
<button>Send</button>
</div>
</div>
<p style="margin:8px 0 0;font-size:.76rem;color:var(--muted)"><b>Fordel:</b> Bedste balance mellem kompakt og læsbar. Lille initial-cirkel gør det nemt at følge hvem der skriver. Virker godt med mange beskeder.</p>
</div>
<!-- ========== FORSLAG 2: LOG ========== -->
<div class="panel" id="p1">
<div class="box">
<div class="l-row">
<div class="l-stripe"></div>
<div class="l-inner">
<div class="l-meta"><b>Jens Jensen</b> · 21/03 10:00</div>
<div class="l-text">Vi har et problem med vores to printere de melder offline siden fredag.</div>
</div>
</div>
<div class="l-row">
<div class="l-stripe tech"></div>
<div class="l-inner">
<div class="l-meta"><b>Christian Thomas</b> · 21/03 10:15</div>
<div class="l-text">Port 4 og 5 var nede kort i nat. Har I prøvet at genstarte printerne så de fanger ny DHCP?</div>
</div>
</div>
<div class="l-row is-sys">
<div class="l-stripe sys"></div>
<div class="l-inner">
<div class="l-meta"><b>System</b> · 21/03 10:20</div>
<div class="l-text">Status ændret: Åben → Under behandling</div>
</div>
</div>
<div class="l-row">
<div class="l-stripe"></div>
<div class="l-inner">
<div class="l-meta"><b>Jens Jensen</b> · 21/03 10:35</div>
<div class="l-text">HP'en virker stadig ikke den anden kom op igen efter genstart.</div>
</div>
</div>
<div class="l-row">
<div class="l-stripe tech"></div>
<div class="l-inner">
<div class="l-meta"><b>Christian Thomas</b> · 21/03 10:45</div>
<div class="l-text">Jeg logger på jeres firewall nu og tjekker MAC-filteret.</div>
</div>
</div>
<div class="input-bar">
<textarea rows="1" placeholder="Skriv en kommentar..."></textarea>
<button>Send</button>
</div>
</div>
<p style="margin:8px 0 0;font-size:.76rem;color:var(--muted)"><b>Fordel:</b> Absolut tættest muligt. Blå streg = intern tekniker, grå = system, ingen streg = kunde. Perfekt til teknikere der scanner hurtigt.</p>
</div>
<!-- ========== FORSLAG 3: INBOX ========== -->
<div class="panel" id="p2">
<div class="box">
<div class="ib-feed">
<div class="ib-m left">
<div class="ib-b">Vi har et problem med vores to printere de melder offline siden fredag.</div>
<div class="ib-info">Jens Jensen · 21/03 10:00</div>
</div>
<div class="ib-m right">
<div class="ib-b">Port 4 og 5 var nede kort i nat. Har I prøvet at genstarte printerne?</div>
<div class="ib-info">Christian Thomas · 21/03 10:15</div>
</div>
<div class="ib-m ctr">
<div class="ib-b">Status ændret: Åben → Under behandling</div>
<div class="ib-info">System · 21/03 10:20</div>
</div>
<div class="ib-m left">
<div class="ib-b">HP'en virker stadig ikke den anden kom op igen efter genstart.</div>
<div class="ib-info">Jens Jensen · 21/03 10:35</div>
</div>
<div class="ib-m right">
<div class="ib-b">Jeg logger på jeres firewall nu og tjekker MAC-filteret.</div>
<div class="ib-info">Christian Thomas · 21/03 10:45</div>
</div>
</div>
<div class="input-bar">
<textarea rows="1" placeholder="Skriv en kommentar..."></textarea>
<button>Send</button>
</div>
</div>
<p style="margin:8px 0 0;font-size:.76rem;color:var(--muted)"><b>Fordel:</b> Gør kunde/tekniker-retningen tydelig uden farver eller headers. System-beskeder centreres. Godt til dialog-tunge sager.</p>
</div>
</div>
<script>
function show(i){
document.querySelectorAll('.panel').forEach((p,j)=>p.classList.toggle('active',j===i));
document.querySelectorAll('.tab-btn').forEach((b,j)=>b.classList.toggle('active',j===i));
}
</script>
</body>
</html>

64
main.py
View File

@ -16,29 +16,6 @@ from app.core.database import init_db
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from app.core.database import execute_query_single from app.core.database import execute_query_single
_users_column_cache: dict[str, bool] = {}
def _users_column_exists(column_name: str) -> bool:
if column_name in _users_column_cache:
return _users_column_cache[column_name]
result = execute_query_single(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = %s
LIMIT 1
""",
(column_name,),
)
exists = bool(result)
_users_column_cache[column_name] = exists
return exists
def get_version(): def get_version():
"""Read version from VERSION file""" """Read version from VERSION file"""
try: try:
@ -170,20 +147,6 @@ async def lifespan(app: FastAPI):
) )
logger.info("✅ Subscription invoice job scheduled (daily at 04:00)") logger.info("✅ Subscription invoice job scheduled (daily at 04:00)")
# Register ordre draft sync reconcile job
from app.jobs.reconcile_ordre_drafts import reconcile_ordre_drafts_sync_status
backup_scheduler.scheduler.add_job(
func=reconcile_ordre_drafts_sync_status,
trigger=CronTrigger(hour=4, minute=30),
kwargs={"apply_changes": True},
id='reconcile_ordre_drafts_sync_status',
name='Reconcile Ordre Draft Sync Status',
max_instances=1,
replace_existing=True
)
logger.info("✅ Ordre draft reconcile job scheduled (daily at 04:30)")
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED: if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
from app.jobs.eset_sync import run_eset_sync from app.jobs.eset_sync import run_eset_sync
@ -295,31 +258,18 @@ async def auth_middleware(request: Request, call_next):
request.state.user_id = None request.state.user_id = None
if path.startswith("/api") and not payload.get("shadow_admin"): if path.startswith("/api") and not payload.get("shadow_admin"):
sub_value = payload.get("sub") if not payload.get("sub"):
if not sub_value:
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content={"detail": "Invalid token"} content={"detail": "Invalid token"}
) )
try: user_id = int(payload.get("sub"))
user_id = int(sub_value) user = execute_query_single(
except (TypeError, ValueError): "SELECT is_2fa_enabled FROM users WHERE user_id = %s",
from fastapi.responses import JSONResponse (user_id,)
return JSONResponse( )
status_code=401, is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
content={"detail": "Invalid token"}
)
if _users_column_exists("is_2fa_enabled"):
user = execute_query_single(
"SELECT COALESCE(is_2fa_enabled, FALSE) AS is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,),
)
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
else:
# Older schemas without 2FA columns should not block authenticated requests.
is_2fa_enabled = False
if not is_2fa_enabled: if not is_2fa_enabled:
allowed_2fa_paths = ( allowed_2fa_paths = (

View File

@ -37,7 +37,7 @@ CREATE TABLE email_rules (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_user_id INTEGER, created_by_user_id INTEGER,
FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) ON DELETE SET NULL FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
); );
-- Email Messages Table (main storage) -- Email Messages Table (main storage)
@ -183,7 +183,7 @@ SELECT
COUNT(ea.id) as attachment_count_actual, COUNT(ea.id) as attachment_count_actual,
er.name as rule_name, er.name as rule_name,
v.name as supplier_name, v.name as supplier_name,
tc.name as customer_name, tc.customer_name,
tcase.title as case_title tcase.title as case_title
FROM email_messages em FROM email_messages em
LEFT JOIN email_attachments ea ON em.id = ea.email_id LEFT JOIN email_attachments ea ON em.id = ea.email_id
@ -193,7 +193,7 @@ LEFT JOIN tmodule_customers tc ON em.customer_id = tc.id
LEFT JOIN tmodule_cases tcase ON em.linked_case_id = tcase.id LEFT JOIN tmodule_cases tcase ON em.linked_case_id = tcase.id
WHERE em.deleted_at IS NULL WHERE em.deleted_at IS NULL
AND em.status IN ('new', 'error') AND em.status IN ('new', 'error')
GROUP BY em.id, er.name, v.name, tc.name, tcase.title GROUP BY em.id, er.name, v.name, tc.customer_name, tcase.title
ORDER BY em.received_date DESC; ORDER BY em.received_date DESC;
-- View for recent email activity -- View for recent email activity

View File

@ -27,9 +27,9 @@ CREATE TABLE IF NOT EXISTS tticket_relations (
CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id) CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id)
); );
CREATE INDEX IF NOT EXISTS idx_tticket_relations_ticket ON tticket_relations(ticket_id); CREATE INDEX idx_tticket_relations_ticket ON tticket_relations(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_relations_related ON tticket_relations(related_ticket_id); CREATE INDEX idx_tticket_relations_related ON tticket_relations(related_ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_relations_type ON tticket_relations(relation_type); CREATE INDEX idx_tticket_relations_type ON tticket_relations(relation_type);
-- View for at finde alle relationer for en ticket (begge retninger) -- View for at finde alle relationer for en ticket (begge retninger)
CREATE OR REPLACE VIEW tticket_all_relations AS CREATE OR REPLACE VIEW tticket_all_relations AS
@ -90,10 +90,10 @@ CREATE TABLE IF NOT EXISTS tticket_calendar_events (
completed_at TIMESTAMP completed_at TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id); CREATE INDEX idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_date ON tticket_calendar_events(event_date); CREATE INDEX idx_tticket_calendar_date ON tticket_calendar_events(event_date);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_type ON tticket_calendar_events(event_type); CREATE INDEX idx_tticket_calendar_type ON tticket_calendar_events(event_type);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_status ON tticket_calendar_events(status); CREATE INDEX idx_tticket_calendar_status ON tticket_calendar_events(status);
-- ============================================================================ -- ============================================================================
-- TEMPLATES (svarskabeloner, guides, standardbreve) -- TEMPLATES (svarskabeloner, guides, standardbreve)
@ -128,8 +128,8 @@ CREATE TABLE IF NOT EXISTS tticket_templates (
usage_count INTEGER DEFAULT 0 usage_count INTEGER DEFAULT 0
); );
CREATE INDEX IF NOT EXISTS idx_tticket_templates_category ON tticket_templates(category); CREATE INDEX idx_tticket_templates_category ON tticket_templates(category);
CREATE INDEX IF NOT EXISTS idx_tticket_templates_active ON tticket_templates(is_active); CREATE INDEX idx_tticket_templates_active ON tticket_templates(is_active);
-- ============================================================================ -- ============================================================================
-- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt) -- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt)
@ -143,8 +143,8 @@ CREATE TABLE IF NOT EXISTS tticket_template_usage (
was_modified BOOLEAN DEFAULT false -- Blev template redigeret før afsendelse? was_modified BOOLEAN DEFAULT false -- Blev template redigeret før afsendelse?
); );
CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_template ON tticket_template_usage(template_id); CREATE INDEX idx_tticket_template_usage_template ON tticket_template_usage(template_id);
CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id); CREATE INDEX idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
-- ============================================================================ -- ============================================================================
-- AI SUGGESTIONS (forslag til actions - aldrig automatisk) -- AI SUGGESTIONS (forslag til actions - aldrig automatisk)
@ -186,10 +186,10 @@ CREATE TABLE IF NOT EXISTS tticket_ai_suggestions (
expires_at TIMESTAMP -- Forslag udløber efter X dage expires_at TIMESTAMP -- Forslag udløber efter X dage
); );
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id); CREATE INDEX idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type); CREATE INDEX idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status); CREATE INDEX idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at); CREATE INDEX idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
-- ============================================================================ -- ============================================================================
-- EMAIL METADATA (udvidet til contact identification) -- EMAIL METADATA (udvidet til contact identification)
@ -227,9 +227,9 @@ CREATE TABLE IF NOT EXISTS tticket_email_metadata (
updated_at TIMESTAMP updated_at TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_tticket_email_ticket ON tticket_email_metadata(ticket_id); CREATE INDEX idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_email_message_id ON tticket_email_metadata(message_id); CREATE INDEX idx_tticket_email_message_id ON tticket_email_metadata(message_id);
CREATE INDEX IF NOT EXISTS idx_tticket_email_from ON tticket_email_metadata(from_email); CREATE INDEX idx_tticket_email_from ON tticket_email_metadata(from_email);
-- ============================================================================ -- ============================================================================
-- Tilføj manglende kolonner til existing tticket_tickets -- Tilføj manglende kolonner til existing tticket_tickets
@ -265,15 +265,9 @@ CREATE TABLE IF NOT EXISTS tticket_audit_log (
metadata JSONB -- Additional context metadata JSONB -- Additional context
); );
ALTER TABLE tticket_audit_log CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
ADD COLUMN IF NOT EXISTS field_name VARCHAR(100), CREATE INDEX idx_tticket_audit_action ON tticket_audit_log(action);
ADD COLUMN IF NOT EXISTS performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CREATE INDEX idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
ADD COLUMN IF NOT EXISTS reason TEXT,
ADD COLUMN IF NOT EXISTS metadata JSONB;
CREATE INDEX IF NOT EXISTS idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_audit_action ON tticket_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
-- ============================================================================ -- ============================================================================
-- TRIGGERS for audit logging -- TRIGGERS for audit logging

View File

@ -24,17 +24,7 @@ ADD COLUMN IF NOT EXISTS time_date DATE;
ALTER TABLE tmodule_order_lines ALTER TABLE tmodule_order_lines
ADD COLUMN IF NOT EXISTS is_travel BOOLEAN DEFAULT false; ADD COLUMN IF NOT EXISTS is_travel BOOLEAN DEFAULT false;
-- Log migration when the legacy tracking table exists -- Log migration
DO $$ INSERT INTO migration_log (migration_name, applied_at)
BEGIN VALUES ('031_add_is_travel_column', CURRENT_TIMESTAMP)
IF EXISTS ( ON CONFLICT DO NOTHING;
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'migration_log'
) THEN
INSERT INTO migration_log (migration_name, applied_at)
VALUES ('031_add_is_travel_column', CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING;
END IF;
END $$;

View File

@ -4,13 +4,13 @@
-- Add import_method column -- Add import_method column
ALTER TABLE email_messages ALTER TABLE email_messages
ADD COLUMN IF NOT EXISTS import_method VARCHAR(50) DEFAULT 'imap'; ADD COLUMN import_method VARCHAR(50) DEFAULT 'imap';
-- Add comment -- Add comment
COMMENT ON COLUMN email_messages.import_method IS 'How the email was imported: imap, graph_api, or manual_upload'; COMMENT ON COLUMN email_messages.import_method IS 'How the email was imported: imap, graph_api, or manual_upload';
-- Create index for filtering by import method -- Create index for filtering by import method
CREATE INDEX IF NOT EXISTS idx_email_messages_import_method ON email_messages(import_method); CREATE INDEX idx_email_messages_import_method ON email_messages(import_method);
-- Update existing records to reflect their actual source -- Update existing records to reflect their actual source
-- (all existing emails were fetched via IMAP or Graph API) -- (all existing emails were fetched via IMAP or Graph API)
@ -20,8 +20,5 @@ WHERE import_method IS NULL;
-- Add constraint to ensure valid values -- Add constraint to ensure valid values
ALTER TABLE email_messages ALTER TABLE email_messages
DROP CONSTRAINT IF EXISTS chk_email_import_method; ADD CONSTRAINT chk_email_import_method
ALTER TABLE email_messages
ADD CONSTRAINT chk_email_import_method
CHECK (import_method IN ('imap', 'graph_api', 'manual_upload')); CHECK (import_method IN ('imap', 'graph_api', 'manual_upload'));

View File

@ -1,5 +1,5 @@
-- 069_conversation_category.sql -- 069_conversation_category.sql
-- Add category column for conversation classification -- Add category column for conversation classification
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General'; ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting'; COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting';

View File

@ -1,4 +1,4 @@
-- 072_add_category_to_conversations.sql -- 072_add_category_to_conversations.sql
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General'; ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)'; COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)';

View File

@ -11,4 +11,4 @@ CREATE TABLE IF NOT EXISTS sag_kommentarer (
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
); );
CREATE INDEX IF NOT EXISTS idx_sag_kommentarer_sag_id ON sag_kommentarer(sag_id); CREATE INDEX idx_sag_kommentarer_sag_id ON sag_comments(sag_id);

View File

@ -1,116 +0,0 @@
-- Migration 1002: Asset-first subscriptions, billing controls, and price change tracking
-- Adds fields and tables needed for asset-linked subscriptions and flexible billing.
ALTER TABLE sag_subscriptions
ADD COLUMN IF NOT EXISTS billing_direction VARCHAR(20) NOT NULL DEFAULT 'forward'
CHECK (billing_direction IN ('forward', 'backward')),
ADD COLUMN IF NOT EXISTS advance_months INTEGER NOT NULL DEFAULT 1
CHECK (advance_months >= 1 AND advance_months <= 24),
ADD COLUMN IF NOT EXISTS first_full_period_start DATE,
ADD COLUMN IF NOT EXISTS binding_months INTEGER NOT NULL DEFAULT 0
CHECK (binding_months >= 0),
ADD COLUMN IF NOT EXISTS binding_start_date DATE,
ADD COLUMN IF NOT EXISTS binding_end_date DATE,
ADD COLUMN IF NOT EXISTS binding_group_key VARCHAR(80),
ADD COLUMN IF NOT EXISTS billing_blocked BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS billing_block_reason TEXT,
ADD COLUMN IF NOT EXISTS invoice_merge_key VARCHAR(120),
ADD COLUMN IF NOT EXISTS price_change_case_id INTEGER REFERENCES sag_sager(id),
ADD COLUMN IF NOT EXISTS renewal_case_id INTEGER REFERENCES sag_sager(id);
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_billing_direction ON sag_subscriptions(billing_direction);
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_billing_blocked ON sag_subscriptions(billing_blocked) WHERE billing_blocked = true;
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_binding_end_date ON sag_subscriptions(binding_end_date) WHERE binding_end_date IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_invoice_merge_key ON sag_subscriptions(invoice_merge_key);
ALTER TABLE sag_subscription_items
ADD COLUMN IF NOT EXISTS asset_id INTEGER REFERENCES hardware_assets(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS period_from DATE,
ADD COLUMN IF NOT EXISTS period_to DATE,
ADD COLUMN IF NOT EXISTS requires_serial_number BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS serial_number VARCHAR(100),
ADD COLUMN IF NOT EXISTS billing_blocked BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS billing_block_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_asset_id ON sag_subscription_items(asset_id);
CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_blocked ON sag_subscription_items(billing_blocked) WHERE billing_blocked = true;
CREATE TABLE IF NOT EXISTS subscription_asset_bindings (
id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES sag_subscriptions(id) ON DELETE CASCADE,
asset_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE RESTRICT,
shared_binding_key VARCHAR(80),
binding_months INTEGER NOT NULL DEFAULT 0 CHECK (binding_months >= 0),
start_date DATE NOT NULL,
end_date DATE,
notice_period_days INTEGER NOT NULL DEFAULT 30 CHECK (notice_period_days >= 0),
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'ended', 'cancelled')),
sag_id INTEGER REFERENCES sag_sager(id),
created_by_user_id INTEGER REFERENCES users(user_id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE (subscription_id, asset_id, start_date)
);
CREATE INDEX IF NOT EXISTS idx_subscription_asset_bindings_subscription ON subscription_asset_bindings(subscription_id);
CREATE INDEX IF NOT EXISTS idx_subscription_asset_bindings_asset ON subscription_asset_bindings(asset_id);
CREATE INDEX IF NOT EXISTS idx_subscription_asset_bindings_end_date ON subscription_asset_bindings(end_date);
CREATE TRIGGER trigger_subscription_asset_bindings_updated_at
BEFORE UPDATE ON subscription_asset_bindings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TABLE IF NOT EXISTS subscription_price_changes (
id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES sag_subscriptions(id) ON DELETE CASCADE,
subscription_item_id INTEGER REFERENCES sag_subscription_items(id) ON DELETE SET NULL,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE RESTRICT,
change_scope VARCHAR(20) NOT NULL DEFAULT 'subscription' CHECK (change_scope IN ('subscription', 'item')),
old_unit_price DECIMAL(10,2),
new_unit_price DECIMAL(10,2) NOT NULL CHECK (new_unit_price >= 0),
effective_date DATE NOT NULL,
approval_status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (approval_status IN ('pending', 'approved', 'rejected', 'applied')),
reason TEXT,
approved_by_user_id INTEGER REFERENCES users(user_id),
approved_at TIMESTAMP,
created_by_user_id INTEGER REFERENCES users(user_id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_subscription_price_changes_subscription ON subscription_price_changes(subscription_id);
CREATE INDEX IF NOT EXISTS idx_subscription_price_changes_effective_date ON subscription_price_changes(effective_date);
CREATE INDEX IF NOT EXISTS idx_subscription_price_changes_status ON subscription_price_changes(approval_status);
CREATE TRIGGER trigger_subscription_price_changes_updated_at
BEFORE UPDATE ON subscription_price_changes
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
ALTER TABLE ordre_drafts
ADD COLUMN IF NOT EXISTS coverage_start DATE,
ADD COLUMN IF NOT EXISTS coverage_end DATE,
ADD COLUMN IF NOT EXISTS billing_direction VARCHAR(20)
CHECK (billing_direction IN ('forward', 'backward')),
ADD COLUMN IF NOT EXISTS source_subscription_ids INTEGER[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS invoice_aggregate_key VARCHAR(120),
ADD COLUMN IF NOT EXISTS sync_status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (sync_status IN ('pending', 'exported', 'failed', 'posted', 'paid')),
ADD COLUMN IF NOT EXISTS economic_order_number VARCHAR(80),
ADD COLUMN IF NOT EXISTS economic_invoice_number VARCHAR(80),
ADD COLUMN IF NOT EXISTS last_sync_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_ordre_drafts_sync_status ON ordre_drafts(sync_status);
CREATE INDEX IF NOT EXISTS idx_ordre_drafts_invoice_aggregate_key ON ordre_drafts(invoice_aggregate_key);
ALTER TABLE products
ADD COLUMN IF NOT EXISTS serial_number_required BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS asset_required BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS rental_asset_enabled BOOLEAN NOT NULL DEFAULT false;
COMMENT ON COLUMN products.serial_number_required IS 'If true, subscription line billing requires serial number data.';
COMMENT ON COLUMN products.asset_required IS 'If true, subscription line billing requires linked hardware asset.';
COMMENT ON COLUMN products.rental_asset_enabled IS 'If true, product is eligible for asset-first rental subscription flows.';

View File

@ -1,25 +0,0 @@
-- Migration 1003: Ordre draft sync audit + idempotency safeguards
ALTER TABLE ordre_drafts
ADD COLUMN IF NOT EXISTS export_idempotency_key VARCHAR(120);
CREATE UNIQUE INDEX IF NOT EXISTS uq_ordre_drafts_export_idempotency_key
ON ordre_drafts(export_idempotency_key)
WHERE export_idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS ordre_draft_sync_events (
id SERIAL PRIMARY KEY,
draft_id INTEGER NOT NULL REFERENCES ordre_drafts(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL,
from_status VARCHAR(20),
to_status VARCHAR(20),
event_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ordre_draft_sync_events_draft_id
ON ordre_draft_sync_events(draft_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ordre_draft_sync_events_type
ON ordre_draft_sync_events(event_type, created_at DESC);

View File

@ -51,7 +51,7 @@ SELECT
s.customer_id, s.customer_id,
cust.name as customer_name, cust.name as customer_name,
s.sag_id, s.sag_id,
sag.titel as sag_title, sag.title as sag_title,
s.session_link, s.session_link,
s.started_at, s.started_at,
s.ended_at, s.ended_at,

View File

@ -1,40 +0,0 @@
-- Migration 142: Persistent thread key for reliable inbound case routing
ALTER TABLE email_messages
ADD COLUMN IF NOT EXISTS thread_key VARCHAR(500);
-- Backfill thread_key for existing rows.
-- Priority:
-- 1) First token from References (root message id)
-- 2) In-Reply-To
-- 3) Message-ID
UPDATE email_messages
SET thread_key = LOWER(
REGEXP_REPLACE(
COALESCE(
NULLIF(
SPLIT_PART(
REGEXP_REPLACE(COALESCE(email_references, ''), '^[\s<>,]+', ''),
' ',
1
),
''
),
NULLIF(in_reply_to, ''),
NULLIF(message_id, '')
),
'[<>\s]',
'',
'g'
)
)
WHERE (thread_key IS NULL OR TRIM(thread_key) = '')
AND (
COALESCE(NULLIF(email_references, ''), NULLIF(in_reply_to, ''), NULLIF(message_id, '')) IS NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email_messages_thread_key
ON email_messages(thread_key)
WHERE thread_key IS NOT NULL;
COMMENT ON COLUMN email_messages.thread_key IS 'Stable normalized thread key (root message-id/in-reply-to) for case routing';

View File

@ -1,39 +0,0 @@
-- Migration 144: Extend tags with brand/type classes and catch words
-- Add catch words storage for tag suggestion matching
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS catch_words_json JSONB NOT NULL DEFAULT '[]'::jsonb;
-- Extend allowed tag types to include brand and type
DO $$
DECLARE
constraint_name text;
BEGIN
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_type_check;
FOR constraint_name IN
SELECT con.conname
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
WHERE rel.relname = 'tags'
AND con.contype = 'c'
AND pg_get_constraintdef(con.oid) ILIKE '%type IN (%'
LOOP
EXECUTE format('ALTER TABLE tags DROP CONSTRAINT %I', constraint_name);
END LOOP;
END $$;
ALTER TABLE tags
ADD CONSTRAINT tags_type_check
CHECK (type IN ('workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type'));
-- Seed a couple of starter tags for the new classes
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('Microsoft', 'brand', 'Brand tag for Microsoft related cases', '#006d77', 'bi-microsoft', true, '["microsoft","ms 365","office 365","azure"]'::jsonb),
('Adobe', 'brand', 'Brand tag for Adobe related cases', '#006d77', 'bi-box', true, '["adobe","acrobat","creative cloud"]'::jsonb),
('Printer', 'type', 'Type tag for printer related work', '#5c677d', 'bi-printer', true, '["printer","toner","print","scanner"]'::jsonb),
('Email', 'type', 'Type tag for mail related work', '#5c677d', 'bi-envelope', true, '["mail","email","outlook","smtp","imap"]'::jsonb)
ON CONFLICT (name, type) DO NOTHING;
COMMENT ON COLUMN tags.catch_words_json IS 'JSON array of catch words used for automated tag suggestions';

View File

@ -1,51 +0,0 @@
-- Migration 145: Seed brand tags (A-Z starter set)
-- DEPRECATED: Superseded by migration 147 (master brand + type seed).
-- Keep for historical traceability; do not run together with 147.
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('3 Mobil', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["3 mobil", "3"]'::jsonb),
('ABA', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["aba"]'::jsonb),
('Android', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["android"]'::jsonb),
('Anydesk', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["anydesk"]'::jsonb),
('Apple', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["apple", "iphone", "ipad", "macbook"]'::jsonb),
('Bitwarden', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bitwarden"]'::jsonb),
('BMC Networks', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc networks", "bmc"]'::jsonb),
('BMC Webhosting', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc webhosting"]'::jsonb),
('Brother', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["brother"]'::jsonb),
('Canon', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["canon"]'::jsonb),
('Cisco', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cisco"]'::jsonb),
('Clickshare', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["clickshare", "barco"]'::jsonb),
('CTS', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cts"]'::jsonb),
('Dropbox', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["dropbox"]'::jsonb),
('Epson', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["epson"]'::jsonb),
('ESET', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["eset"]'::jsonb),
('GlobalConnect', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["globalconnect"]'::jsonb),
('Google', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["google", "gmail", "workspace"]'::jsonb),
('HP', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["hp", "hewlett packard"]'::jsonb),
('IBAK', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ibak"]'::jsonb),
('IP Nordic', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ip nordic"]'::jsonb),
('Lenovo', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["lenovo", "thinkpad"]'::jsonb),
('Microsoft', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["microsoft", "windows", "azure", "teams"]'::jsonb),
('Nextcloud', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NFTV', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nftv"]'::jsonb),
('Office 365', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["office 365", "o365", "m365", "microsoft 365"]'::jsonb),
('Philips', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["philips"]'::jsonb),
('Pronestor/Planner', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["pronestor", "planner"]'::jsonb),
('Refurb', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["refurb"]'::jsonb),
('Samsung', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["samsung"]'::jsonb),
('Sentia', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["sentia"]'::jsonb),
('Simply-CRM', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["simply-crm", "simply crm"]'::jsonb),
('Syncplify', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["syncplify"]'::jsonb),
('TDC', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["tdc"]'::jsonb),
('Teltonika', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["teltonika"]'::jsonb),
('The Union', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["the union"]'::jsonb),
('Ubiquiti', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ubiquiti", "unifi"]'::jsonb),
('Vincentz', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["vincentz"]'::jsonb),
('VisionLine', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["visionline"]'::jsonb),
('Yealink', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["yealink"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;

View File

@ -1,101 +0,0 @@
-- Migration 146: Seed type tags (case type starter set)
-- DEPRECATED: Superseded by migration 147 (master brand + type seed).
-- Keep for historical traceability; do not run together with 147.
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('4g / 5g modem', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["4g", "5g", "modem"]'::jsonb),
('Accounting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["accounting", "bogholderi"]'::jsonb),
('Adgangskode', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["adgangskode", "password"]'::jsonb),
('Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["andet"]'::jsonb),
('Antivirus', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["antivirus", "virus"]'::jsonb),
('Arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["arkiv"]'::jsonb),
('AV udstyr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["av udstyr", "av"]'::jsonb),
('Backup', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["backup"]'::jsonb),
('BMC Mobil recorder', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["bmc mobil recorder", "mobil recorder"]'::jsonb),
('Booking system', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["booking system", "booking"]'::jsonb),
('DHCP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dhcp"]'::jsonb),
('DNS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dns"]'::jsonb),
('Domæne/Web', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["domæne", "domain", "web"]'::jsonb),
('Drift', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["drift", "operations"]'::jsonb),
('Dropbox', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dropbox"]'::jsonb),
('Email', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["email", "e-mail"]'::jsonb),
('Faktura spørgsmål', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura spørgsmål", "invoice question"]'::jsonb),
('Faktura til betaling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura til betaling", "invoice payment"]'::jsonb),
('Hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware"]'::jsonb),
('Hardware order', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware order", "hardware ordre"]'::jsonb),
('Headset', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["headset"]'::jsonb),
('Hosting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hosting"]'::jsonb),
('IBAK', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ibak"]'::jsonb),
('Info/møde skærm', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["infoskærm", "møde skærm", "info skærm"]'::jsonb),
('Installation af hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["installation af hardware", "hardware installation"]'::jsonb),
('Internet forbindelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["internet forbindelse", "internet"]'::jsonb),
('Invoice', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["invoice", "faktura"]'::jsonb),
('IP telefon', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ip telefon", "voip telefon"]'::jsonb),
('Kalender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalender", "calendar"]'::jsonb),
('Kalenderopsætning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalenderopsætning", "calendar setup"]'::jsonb),
('Kreditering', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kreditering", "credit note"]'::jsonb),
('Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["licenser", "licenses"]'::jsonb),
('M365 - Defender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 defender", "defender"]'::jsonb),
('M365 - Entra/Azure', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 entra", "entra", "azure"]'::jsonb),
('M365 - Intune', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 intune", "intune"]'::jsonb),
('M365 - Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 licenser", "m365 licenses"]'::jsonb),
('M365 - Office', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 office", "office"]'::jsonb),
('M365 - Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 sharepoint", "sharepoint"]'::jsonb),
('M365 - Users', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 users", "users"]'::jsonb),
('MacOS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["macos", "mac"]'::jsonb),
('Mail', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail", "email"]'::jsonb),
('Mail-arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail-arkiv", "mail arkiv"]'::jsonb),
('MFA / 2FA', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mfa", "2fa"]'::jsonb),
('Mobil', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mobil", "mobile"]'::jsonb),
('NAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nas"]'::jsonb),
('Nedbrud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nedbrud", "outage"]'::jsonb),
('Netværk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["netværk", "network"]'::jsonb),
('Nextcloud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NP ind/ud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["np ind/ud", "nummerportering"]'::jsonb),
('Ny fiber kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny fiber kunde", "fiber kunde"]'::jsonb),
('Ny hosting kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny hosting kunde", "hosting kunde"]'::jsonb),
('Ny IT kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny it kunde", "it kunde"]'::jsonb),
('Ny kontorhotel kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny kontorhotel kunde", "kontorhotel"]'::jsonb),
('Ny telefoni kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny telefoni kunde", "telefoni kunde"]'::jsonb),
('Offboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["offboarding"]'::jsonb),
('Onboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["onboarding"]'::jsonb),
('Oprettelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprettelse", "create"]'::jsonb),
('Oprydning / Geninstallation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprydning", "geninstallation"]'::jsonb),
('Opsætning / Installation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsætning", "installation", "setup"]'::jsonb),
('Opsigelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsigelse", "termination"]'::jsonb),
('Printer', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["printer"]'::jsonb),
('RDP/Fjernskrivebord', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["rdp", "fjernskrivebord", "remote desktop"]'::jsonb),
('Router / Firewall', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["router", "firewall"]'::jsonb),
('Send faktura', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["send faktura", "send invoice"]'::jsonb),
('Server - Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server andet", "server"]'::jsonb),
('Server - SFTP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server sftp", "sftp"]'::jsonb),
('Server - TrueNAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["truenas", "server truenas"]'::jsonb),
('Server - Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server windows", "windows server"]'::jsonb),
('Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sharepoint"]'::jsonb),
('Sikkerhed', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sikkerhed", "security"]'::jsonb),
('Simkort', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["simkort", "sim card"]'::jsonb),
('Små ændringer!!!!!!!', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["små ændringer", "small changes"]'::jsonb),
('Software', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["software"]'::jsonb),
('Spærring', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["spærring", "block"]'::jsonb),
('Switch', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["switch"]'::jsonb),
('Teams', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["teams", "microsoft teams"]'::jsonb),
('Telefonnr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["telefonnr", "telefonnummer"]'::jsonb),
('Udlejning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udlejning", "rental"]'::jsonb),
('Udvikling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udvikling", "development"]'::jsonb),
('Uisp', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["uisp"]'::jsonb),
('Unifi', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["unifi"]'::jsonb),
('Vagtkald', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vagtkald"]'::jsonb),
('Voip', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["voip"]'::jsonb),
('VPN', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vpn"]'::jsonb),
('WEB', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["web", "website"]'::jsonb),
('WIFI', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["wifi", "wi-fi"]'::jsonb),
('Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows"]'::jsonb),
('Windows AD', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows ad", "active directory", "ad"]'::jsonb),
('Workspace / Office365', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["workspace", "office365", "office 365"]'::jsonb),
('Anydesk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["anydesk"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;

View File

@ -1,148 +0,0 @@
-- Migration 147: Master seed for brand + type tags
-- Depends on migration 144 (brand/type + catch_words_json)
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('3 Mobil', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["3 mobil", "3"]'::jsonb),
('ABA', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["aba"]'::jsonb),
('Android', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["android"]'::jsonb),
('Anydesk', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["anydesk"]'::jsonb),
('Apple', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["apple", "iphone", "ipad", "macbook"]'::jsonb),
('Bitwarden', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bitwarden"]'::jsonb),
('BMC Networks', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc networks", "bmc"]'::jsonb),
('BMC Webhosting', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc webhosting"]'::jsonb),
('Brother', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["brother"]'::jsonb),
('Canon', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["canon"]'::jsonb),
('Cisco', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cisco"]'::jsonb),
('Clickshare', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["clickshare", "barco"]'::jsonb),
('CTS', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cts"]'::jsonb),
('Dropbox', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["dropbox"]'::jsonb),
('Epson', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["epson"]'::jsonb),
('ESET', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["eset"]'::jsonb),
('GlobalConnect', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["globalconnect"]'::jsonb),
('Google', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["google", "gmail", "workspace"]'::jsonb),
('HP', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["hp", "hewlett packard"]'::jsonb),
('IBAK', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ibak"]'::jsonb),
('IP Nordic', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ip nordic"]'::jsonb),
('Lenovo', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["lenovo", "thinkpad"]'::jsonb),
('Microsoft', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["microsoft", "windows", "azure", "teams"]'::jsonb),
('Nextcloud', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NFTV', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nftv"]'::jsonb),
('Office 365', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["office 365", "o365", "m365", "microsoft 365"]'::jsonb),
('Philips', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["philips"]'::jsonb),
('Pronestor/Planner', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["pronestor", "planner"]'::jsonb),
('Refurb', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["refurb"]'::jsonb),
('Samsung', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["samsung"]'::jsonb),
('Sentia', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["sentia"]'::jsonb),
('Simply-CRM', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["simply-crm", "simply crm"]'::jsonb),
('Syncplify', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["syncplify"]'::jsonb),
('TDC', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["tdc"]'::jsonb),
('Teltonika', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["teltonika"]'::jsonb),
('The Union', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["the union"]'::jsonb),
('Ubiquiti', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ubiquiti", "unifi"]'::jsonb),
('Vincentz', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["vincentz"]'::jsonb),
('VisionLine', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["visionline"]'::jsonb),
('Yealink', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["yealink"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('4g / 5g modem', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["4g", "5g", "modem"]'::jsonb),
('Accounting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["accounting", "bogholderi"]'::jsonb),
('Adgangskode', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["adgangskode", "password"]'::jsonb),
('Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["andet"]'::jsonb),
('Antivirus', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["antivirus", "virus"]'::jsonb),
('Arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["arkiv"]'::jsonb),
('AV udstyr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["av udstyr", "av"]'::jsonb),
('Backup', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["backup"]'::jsonb),
('BMC Mobil recorder', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["bmc mobil recorder", "mobil recorder"]'::jsonb),
('Booking system', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["booking system", "booking"]'::jsonb),
('DHCP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dhcp"]'::jsonb),
('DNS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dns"]'::jsonb),
('Domæne/Web', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["domæne", "domain", "web"]'::jsonb),
('Drift', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["drift", "operations"]'::jsonb),
('Dropbox', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dropbox"]'::jsonb),
('Email', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["email", "e-mail"]'::jsonb),
('Faktura spørgsmål', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura spørgsmål", "invoice question"]'::jsonb),
('Faktura til betaling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura til betaling", "invoice payment"]'::jsonb),
('Hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware"]'::jsonb),
('Hardware order', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware order", "hardware ordre"]'::jsonb),
('Headset', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["headset"]'::jsonb),
('Hosting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hosting"]'::jsonb),
('IBAK', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ibak"]'::jsonb),
('Info/møde skærm', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["infoskærm", "møde skærm", "info skærm"]'::jsonb),
('Installation af hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["installation af hardware", "hardware installation"]'::jsonb),
('Internet forbindelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["internet forbindelse", "internet"]'::jsonb),
('Invoice', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["invoice", "faktura"]'::jsonb),
('IP telefon', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ip telefon", "voip telefon"]'::jsonb),
('Kalender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalender", "calendar"]'::jsonb),
('Kalenderopsætning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalenderopsætning", "calendar setup"]'::jsonb),
('Kreditering', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kreditering", "credit note"]'::jsonb),
('Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["licenser", "licenses"]'::jsonb),
('M365 - Defender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 defender", "defender"]'::jsonb),
('M365 - Entra/Azure', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 entra", "entra", "azure"]'::jsonb),
('M365 - Intune', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 intune", "intune"]'::jsonb),
('M365 - Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 licenser", "m365 licenses"]'::jsonb),
('M365 - Office', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 office", "office"]'::jsonb),
('M365 - Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 sharepoint", "sharepoint"]'::jsonb),
('M365 - Users', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 users", "users"]'::jsonb),
('MacOS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["macos", "mac"]'::jsonb),
('Mail', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail", "email"]'::jsonb),
('Mail-arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail-arkiv", "mail arkiv"]'::jsonb),
('MFA / 2FA', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mfa", "2fa"]'::jsonb),
('Mobil', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mobil", "mobile"]'::jsonb),
('NAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nas"]'::jsonb),
('Nedbrud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nedbrud", "outage"]'::jsonb),
('Netværk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["netværk", "network"]'::jsonb),
('Nextcloud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NP ind/ud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["np ind/ud", "nummerportering"]'::jsonb),
('Ny fiber kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny fiber kunde", "fiber kunde"]'::jsonb),
('Ny hosting kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny hosting kunde", "hosting kunde"]'::jsonb),
('Ny IT kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny it kunde", "it kunde"]'::jsonb),
('Ny kontorhotel kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny kontorhotel kunde", "kontorhotel"]'::jsonb),
('Ny telefoni kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny telefoni kunde", "telefoni kunde"]'::jsonb),
('Offboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["offboarding"]'::jsonb),
('Onboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["onboarding"]'::jsonb),
('Oprettelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprettelse", "create"]'::jsonb),
('Oprydning / Geninstallation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprydning", "geninstallation"]'::jsonb),
('Opsætning / Installation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsætning", "installation", "setup"]'::jsonb),
('Opsigelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsigelse", "termination"]'::jsonb),
('Printer', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["printer"]'::jsonb),
('RDP/Fjernskrivebord', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["rdp", "fjernskrivebord", "remote desktop"]'::jsonb),
('Router / Firewall', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["router", "firewall"]'::jsonb),
('Send faktura', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["send faktura", "send invoice"]'::jsonb),
('Server - Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server andet", "server"]'::jsonb),
('Server - SFTP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server sftp", "sftp"]'::jsonb),
('Server - TrueNAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["truenas", "server truenas"]'::jsonb),
('Server - Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server windows", "windows server"]'::jsonb),
('Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sharepoint"]'::jsonb),
('Sikkerhed', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sikkerhed", "security"]'::jsonb),
('Simkort', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["simkort", "sim card"]'::jsonb),
('Små ændringer!!!!!!!', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["små ændringer", "small changes"]'::jsonb),
('Software', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["software"]'::jsonb),
('Spærring', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["spærring", "block"]'::jsonb),
('Switch', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["switch"]'::jsonb),
('Teams', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["teams", "microsoft teams"]'::jsonb),
('Telefonnr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["telefonnr", "telefonnummer"]'::jsonb),
('Udlejning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udlejning", "rental"]'::jsonb),
('Udvikling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udvikling", "development"]'::jsonb),
('Uisp', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["uisp"]'::jsonb),
('Unifi', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["unifi"]'::jsonb),
('Vagtkald', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vagtkald"]'::jsonb),
('Voip', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["voip"]'::jsonb),
('VPN', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vpn"]'::jsonb),
('WEB', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["web", "website"]'::jsonb),
('WIFI', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["wifi", "wi-fi"]'::jsonb),
('Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows"]'::jsonb),
('Windows AD', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows ad", "active directory", "ad"]'::jsonb),
('Workspace / Office365', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["workspace", "office365", "office 365"]'::jsonb),
('Anydesk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["anydesk"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;

View File

@ -1,5 +0,0 @@
-- Add persistent next-task selection for case todo steps
ALTER TABLE sag_todo_steps
ADD COLUMN IF NOT EXISTS is_next BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_sag_todo_steps_is_next ON sag_todo_steps (sag_id, is_next);

View File

@ -1,324 +0,0 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mine 3 anbefalinger Sag Kommentarer</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root {
--accent: #0f4c75;
--bg-card: #ffffff;
--bg-page: #f4f6f8;
}
body { background: var(--bg-page); font-size: 0.875rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.container { max-width: 860px; }
h2.proposal-title {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
font-weight: 700;
border-bottom: 2px solid var(--accent);
padding-bottom: 4px;
margin-bottom: 6px;
}
.proposal-desc { font-size: 0.78rem; color: #666; margin-bottom: 12px; }
/* =====================================================
MIN ANBEFALING #1: Linear / Notion Thread
- Lille initial-cirkel + navn + tid på én linje
- Tekst nedenunder med indent (ingen boks)
- Absolut mindst mulig vertikalt spild
===================================================== */
.ln-thread { background: var(--bg-card); border: 1px solid #e2e6ea; border-radius: 6px; overflow: hidden; }
.ln-msg { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
.ln-msg:last-child { border-bottom: none; }
.ln-msg.system-msg { background: #f8fbff; }
.ln-head { display: flex; align-items: center; gap: 7px; margin-bottom: 2px; }
.ln-avatar {
width: 22px; height: 22px; border-radius: 50%;
background: var(--accent); color: white;
font-size: 0.65rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.ln-avatar.sys { background: #6c757d; }
.ln-name { font-weight: 600; font-size: 0.82rem; color: #222; }
.ln-time { font-size: 0.72rem; color: #aaa; margin-left: auto; }
.ln-body { margin-left: 29px; color: #333; line-height: 1.45; }
/* Input */
.ln-input { border-top: 1px solid #e2e6ea; background: #fafafa; padding: 8px 12px; }
.ln-input textarea { resize: none; font-size: 0.875rem; border-radius: 4px; }
/* =====================================================
MIN ANBEFALING #2: Jira Activity Log
- INGEN avatarer. Bare tekst-rækker med tynd venstre-border.
- Tekniker = blå accent, System = grå, Kunde = default
- Ekstrem informationsdensitet
===================================================== */
.jira-log { background: var(--bg-card); border: 1px solid #ddd; border-radius: 4px; }
.jira-row { display: flex; gap: 0; border-bottom: 1px solid #f2f2f2; }
.jira-row:last-child { border-bottom: none; }
.jira-stripe { width: 3px; flex-shrink: 0; background: #dee2e6; }
.jira-stripe.tech { background: var(--accent); }
.jira-stripe.sys { background: #adb5bd; }
.jira-inner { padding: 5px 10px; flex-grow: 1; }
.jira-meta { font-size: 0.72rem; color: #888; line-height: 1.2; }
.jira-meta strong { color: #333; }
.jira-text { color: #222; line-height: 1.4; margin-top: 1px; }
/* Input */
.jira-input { border-top: 1px solid #ddd; background: #fafafa; padding: 8px; }
.jira-input textarea { resize: none; font-size: 0.875rem; }
/* =====================================================
MIN ANBEFALING #3: Flat Inbox (Zendesk/Freshdesk)
- Kunde-beskeder i lysere boks venstre
- Tekniker-svar højre-aligned i lysere boks
- Zero chrome: ingen card-headers, ingen colours
- Eneste differentieringsmarkør: alignment + subtil bg
===================================================== */
.inbox-feed { background: var(--bg-card); border: 1px solid #e2e6ea; border-radius: 6px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
.ib-msg { max-width: 78%; }
.ib-msg.left { align-self: flex-start; }
.ib-msg.right { align-self: flex-end; }
.ib-bubble {
padding: 6px 10px;
border-radius: 6px;
line-height: 1.45;
color: #222;
}
.ib-msg.left .ib-bubble { background: #f0f4f7; border: 1px solid #dde4ed; }
.ib-msg.right .ib-bubble { background: #e8f0f7; border: 1px solid #c5d8ea; }
.ib-msg.sys .ib-bubble { background: #f4f4f4; border: 1px solid #ddd; font-style: italic; color: #666; font-size: 0.8rem; text-align: center; }
.ib-meta { font-size: 0.7rem; color: #999; margin-top: 2px; padding: 0 2px; }
.ib-msg.right .ib-meta { text-align: right; }
.inbox-input { border-top: 1px solid #e2e6ea; background: #fafafa; padding: 8px 12px; border-radius: 0 0 6px 6px; }
.inbox-input textarea { resize: none; font-size: 0.875rem; }
</style>
</head>
<body>
<div class="container mt-4 pb-5">
<div class="mb-4">
<h1 style="font-size:1.2rem; color: var(--accent); font-weight:700;">Mine 3 anbefalinger til sag-kommentarer</h1>
<p class="text-muted" style="font-size:0.82rem;">Alle 3 er baseret på den faktiske Jinja-struktur i <code>detail.html</code> og er klar til at erstatte det eksisterende kort-layout direkte.</p>
</div>
<!-- =============================================
FORSLAG 1: LINEAR / NOTION THREAD
============================================= -->
<h2 class="proposal-title">Anbefaling 1 — Thread (Linear/Notion style) ⭐ min favorit</h2>
<p class="proposal-desc">Lille initial-cirkel + navn + tid på én linje. Tekst nedenunder med kun et fast indent. Ingen bokse, ingen farvegte headers. Virker kompakt men er stadig behagelig at læse.</p>
<div class="ln-thread">
<!-- Kommentar 1: kunde -->
<div class="ln-msg">
<div class="ln-head">
<div class="ln-avatar">JJ</div>
<span class="ln-name">Jens Jensen</span>
<span class="ln-time">21/03-2026 10:00</span>
</div>
<div class="ln-body">Vi har et problem med vores to printere på kontoret — de melder offline på printserveren siden fredag.</div>
</div>
<!-- Kommentar 2: tekniker -->
<div class="ln-msg">
<div class="ln-head">
<div class="ln-avatar">CT</div>
<span class="ln-name">Christian Thomas</span>
<span class="ln-time">21/03-2026 10:15</span>
</div>
<div class="ln-body">Jeg kan se at port 4 &amp; 5 på switchen var nede kort i nat. Har I prøvet at genstarte printerne så de fanger ny DHCP-adresse?</div>
</div>
<!-- Kommentar 3: system -->
<div class="ln-msg system-msg">
<div class="ln-head">
<div class="ln-avatar sys"><i class="bi bi-gear" style="font-size:0.6rem;"></i></div>
<span class="ln-name" style="color:#6c757d;">System</span>
<span class="ln-time">21/03-2026 10:20</span>
</div>
<div class="ln-body" style="color:#666; font-style:italic;">Status ændret fra <strong>Åben</strong> til <strong>Under behandling</strong></div>
</div>
<!-- Kommentar 4: kunde -->
<div class="ln-msg">
<div class="ln-head">
<div class="ln-avatar">JJ</div>
<span class="ln-name">Jens Jensen</span>
<span class="ln-time">21/03-2026 10:35</span>
</div>
<div class="ln-body">HP'en virker stadig ikke - den anden fik vi op at køre.</div>
</div>
<!-- Input -->
<div class="ln-input">
<div class="input-group input-group-sm">
<textarea class="form-control" rows="2" placeholder="Skriv en kommentar..."></textarea>
<button class="btn btn-primary"><i class="bi bi-send"></i></button>
</div>
</div>
</div>
<!-- Jinja kode hint -->
<details class="mt-2 mb-4">
<summary class="text-muted small" style="cursor:pointer;">Vis Jinja-kode til at erstatte det nuværende</summary>
<pre class="bg-light border rounded p-2 small mt-2" style="overflow-x:auto;">&lt;div class="ln-thread"&gt;
{% for comment in comments %}
&lt;div class="ln-msg {% if comment.er_system_besked %}system-msg{% endif %}"&gt;
&lt;div class="ln-head"&gt;
&lt;div class="ln-avatar {% if comment.er_system_besked %}sys{% endif %}"&gt;
{% if comment.er_system_besked %}&lt;i class="bi bi-gear"&gt;&lt;/i&gt;
{% else %}{{ comment.forfatter[:2]|upper }}{% endif %}
&lt;/div&gt;
&lt;span class="ln-name"&gt;{{ comment.forfatter }}&lt;/span&gt;
&lt;span class="ln-time"&gt;{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}&lt;/span&gt;
&lt;/div&gt;
&lt;div class="ln-body"&gt;{{ comment.indhold|replace('\n','&lt;br&gt;')|safe }}&lt;/div&gt;
&lt;/div&gt;
{% endfor %}
&lt;div class="ln-input"&gt;...form...&lt;/div&gt;
&lt;/div&gt;</pre>
</details>
<!-- =============================================
FORSLAG 2: JIRA ACTIVITY LOG
============================================= -->
<h2 class="proposal-title">Anbefaling 2 — Activity Log (Jira style)</h2>
<p class="proposal-desc">Ingen avatarer. Kun tekst-rækker. En 3px venstre-streg i blå viser hvem der er tekniker vs. hvid/grå for alle andre. Højeste mulige informationsdensitet — ligner en IT-log.</p>
<div class="jira-log">
<div class="jira-row">
<div class="jira-stripe"></div>
<div class="jira-inner">
<div class="jira-meta"><strong>Jens Jensen</strong> · 21/03-2026 kl. 10:00</div>
<div class="jira-text">Vi har et problem med vores to printere på kontoret — de melder offline på printserveren siden fredag.</div>
</div>
</div>
<div class="jira-row">
<div class="jira-stripe tech"></div>
<div class="jira-inner">
<div class="jira-meta"><strong>Christian Thomas</strong> <span class="badge bg-primary" style="font-size:0.62rem;">BMC</span> · 21/03-2026 kl. 10:15</div>
<div class="jira-text">Jeg kan se at port 4 &amp; 5 på switchen var nede kort i nat. Har I prøvet at genstarte printerne?</div>
</div>
</div>
<div class="jira-row">
<div class="jira-stripe sys"></div>
<div class="jira-inner">
<div class="jira-meta"><strong>System</strong> · 21/03-2026 kl. 10:20</div>
<div class="jira-text" style="font-style:italic; color:#666;">Status ændret: Åben → Under behandling</div>
</div>
</div>
<div class="jira-row">
<div class="jira-stripe"></div>
<div class="jira-inner">
<div class="jira-meta"><strong>Jens Jensen</strong> · 21/03-2026 kl. 10:35</div>
<div class="jira-text">HP'en virker stadig ikke — den anden fick vi op at køre.</div>
</div>
</div>
<div class="jira-input">
<div class="input-group input-group-sm">
<textarea class="form-control" rows="2" placeholder="Skriv en kommentar..."></textarea>
<button class="btn btn-primary"><i class="bi bi-send"></i></button>
</div>
</div>
</div>
<details class="mt-2 mb-4">
<summary class="text-muted small" style="cursor:pointer;">Vis Jinja-kode til at erstatte det nuværende</summary>
<pre class="bg-light border rounded p-2 small mt-2" style="overflow-x:auto;">&lt;div class="jira-log"&gt;
{% for comment in comments %}
&lt;div class="jira-row"&gt;
&lt;div class="jira-stripe {% if comment.er_system_besked %}sys
{%- elif comment.er_intern %}tech{% endif %}"&gt;&lt;/div&gt;
&lt;div class="jira-inner"&gt;
&lt;div class="jira-meta"&gt;
&lt;strong&gt;{{ comment.forfatter }}&lt;/strong&gt;
{% if comment.er_intern %}&lt;span class="badge bg-primary"&gt;BMC&lt;/span&gt;{% endif %}
· {{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}
&lt;/div&gt;
&lt;div class="jira-text"&gt;{{ comment.indhold|replace('\n','&lt;br&gt;')|safe }}&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
{% endfor %}
&lt;/div&gt;</pre>
</details>
<!-- =============================================
FORSLAG 3: FLAT INBOX
============================================= -->
<h2 class="proposal-title">Anbefaling 3 — Flat Inbox (Zendesk/Freshdesk style)</h2>
<p class="proposal-desc">Kunde-beskeder til venstre. BMC/tekniker-svar til højre. Ingen farvegte headers, ingen bobler — bare flad alignment og subtil baggrundsforskel. Klart at forstå hvem der sagde hvad.</p>
<div class="inbox-feed">
<!-- Kunde (venstre) -->
<div class="ib-msg left">
<div class="ib-bubble">Vi har et problem med vores to printere på kontoret — de melder offline på printserveren siden fredag.</div>
<div class="ib-meta">Jens Jensen · 21/03-2026 10:00</div>
</div>
<!-- Tekniker (højre) -->
<div class="ib-msg right">
<div class="ib-bubble">Jeg kan se at port 4 &amp; 5 på switchen var nede kort i nat. Har I prøvet at genstarte printerne så de fanger ny DHCP-adresse?</div>
<div class="ib-meta">Christian Thomas · 21/03-2026 10:15</div>
</div>
<!-- System (centreret) -->
<div class="ib-msg sys" style="align-self:center; max-width:90%;">
<div class="ib-bubble">Status ændret: Åben → Under behandling</div>
<div class="ib-meta" style="text-align:center;">System · 21/03-2026 10:20</div>
</div>
<!-- Kunde (venstre) -->
<div class="ib-msg left">
<div class="ib-bubble">HP'en virker stadig ikke — den anden fick vi op at køre.</div>
<div class="ib-meta">Jens Jensen · 21/03-2026 10:35</div>
</div>
</div>
<div class="inbox-input mt-1">
<div class="input-group input-group-sm">
<textarea class="form-control" rows="2" placeholder="Skriv en kommentar..."></textarea>
<button class="btn btn-primary"><i class="bi bi-send"></i></button>
</div>
</div>
<details class="mt-2 mb-4">
<summary class="text-muted small" style="cursor:pointer;">Vis Jinja-kode til at erstatte det nuværende</summary>
<pre class="bg-light border rounded p-2 small mt-2" style="overflow-x:auto;">&lt;div class="inbox-feed"&gt;
{% for comment in comments %}
{% if comment.er_system_besked %}
&lt;div class="ib-msg sys" style="align-self:center;max-width:90%"&gt;
{% elif comment.er_intern %}
&lt;div class="ib-msg right"&gt;
{% else %}
&lt;div class="ib-msg left"&gt;
{% endif %}
&lt;div class="ib-bubble"&gt;{{ comment.indhold|replace('\n','&lt;br&gt;')|safe }}&lt;/div&gt;
&lt;div class="ib-meta"&gt;{{ comment.forfatter }} · {{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}&lt;/div&gt;
&lt;/div&gt;
{% endfor %}
&lt;/div&gt;</pre>
</details>
<div class="alert alert-info mt-4" style="font-size:0.82rem;">
<strong>Hvornår vil jeg bruge hvilken?</strong><br>
<b>1 (Thread):</b> Bedst når der er blandede deltagere (intern + kunde + system). Nemmest at tilpasse.<br>
<b>2 (Log):</b> Bedst hvis I primært bruger systemet internt — minder om et driftslog og er meget kompakt.<br>
<b>3 (Inbox):</b> Bedst hvis I har et tydeligt skel mellem "kundekorrespondance" og "intern teknikerbesked".
</div>
</div>
</body>
</html>

View File

@ -1,640 +0,0 @@
<!DOCTYPE html>
<html lang="da" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opgavebeskrivelse - 3 designforsog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--accent: #0f4c75;
--accent-soft: #e5f1f9;
--bg-page: #f2f5f8;
--bg-card: #ffffff;
--bg-muted: #f8fafc;
--text-main: #1d2732;
--text-soft: #5f6c78;
--line: #dbe3ea;
--shadow: 0 8px 24px rgba(15, 76, 117, 0.08);
}
html[data-theme="dark"] {
--accent: #66b6e8;
--accent-soft: #153548;
--bg-page: #0f141a;
--bg-card: #17202a;
--bg-muted: #101821;
--text-main: #ecf2f8;
--text-soft: #9eb0c1;
--line: #2a3947;
--shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background:
radial-gradient(1200px 500px at -10% -10%, rgba(15, 76, 117, 0.12), transparent 55%),
radial-gradient(900px 380px at 120% 5%, rgba(15, 76, 117, 0.10), transparent 60%),
var(--bg-page);
color: var(--text-main);
line-height: 1.5;
}
.wrap {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px 48px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.title {
font-family: "Manrope", sans-serif;
font-size: clamp(1.15rem, 2vw, 1.5rem);
font-weight: 800;
letter-spacing: 0.01em;
margin: 0;
color: var(--accent);
}
.subtitle {
margin: 3px 0 0;
color: var(--text-soft);
font-size: 0.92rem;
}
.theme-btn {
border: 1px solid var(--line);
background: var(--bg-card);
color: var(--text-main);
border-radius: 10px;
padding: 8px 12px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
@media (min-width: 920px) {
.grid { grid-template-columns: repeat(3, 1fr); }
}
.card {
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow);
overflow: hidden;
min-height: 480px;
display: flex;
flex-direction: column;
}
.card-head {
padding: 14px 14px 10px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, var(--accent-soft), transparent);
}
.label {
display: inline-block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.09em;
font-weight: 700;
color: var(--accent);
margin-bottom: 4px;
}
.card h2 {
margin: 0;
font-family: "Manrope", sans-serif;
font-size: 1rem;
font-weight: 800;
}
.desc {
margin: 3px 0 0;
font-size: 0.82rem;
color: var(--text-soft);
}
.content {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
flex-grow: 1;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
color: var(--text-soft);
flex-wrap: wrap;
}
.badge {
border-radius: 999px;
border: 1px solid var(--line);
background: var(--bg-muted);
padding: 2px 8px;
font-size: 0.72rem;
font-weight: 600;
}
.actions {
margin-left: auto;
border: 1px solid var(--line);
background: transparent;
border-radius: 8px;
padding: 4px 8px;
font-size: 0.75rem;
cursor: pointer;
color: var(--text-main);
}
.sample {
font-size: 0.95rem;
color: var(--text-main);
}
.sample p { margin: 0 0 0.95em; }
.sample p:last-child { margin-bottom: 0; }
.hint {
margin-top: auto;
border-top: 1px dashed var(--line);
padding-top: 10px;
font-size: 0.76rem;
color: var(--text-soft);
}
/* Forsog 1: editorial */
.style-a .sample {
background: var(--bg-muted);
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px;
line-height: 1.68;
}
.style-a .sample p:first-child {
font-size: 1.12rem;
font-family: "Manrope", sans-serif;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.7em;
}
/* Forsog 2: timeline */
.style-b .sample {
border-left: 3px solid var(--accent);
padding: 0 0 0 12px;
}
.style-b .step {
position: relative;
margin: 0 0 12px;
padding-left: 14px;
}
.style-b .step::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
position: absolute;
left: -4px;
top: 0.5em;
box-shadow: 0 0 0 4px var(--accent-soft);
}
.style-b .step b {
display: block;
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-soft);
margin-bottom: 2px;
}
/* Forsog 3: split panel */
.style-c .sample {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.style-c .row {
display: grid;
grid-template-columns: 108px 1fr;
border-bottom: 1px solid var(--line);
min-height: 54px;
}
.style-c .row:last-child { border-bottom: none; }
.style-c .k {
background: var(--bg-muted);
color: var(--text-soft);
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 10px;
}
.style-c .v {
padding: 10px 12px;
font-size: 0.92rem;
}
/* Forsog 4: glassmorphism spotlight */
.style-d .sample {
background: linear-gradient(135deg, rgba(15, 76, 117, 0.08) 0%, rgba(15, 76, 117, 0.04) 100%);
border: 1px solid rgba(15, 76, 117, 0.2);
border-radius: 14px;
padding: 16px;
backdrop-filter: blur(4px);
position: relative;
overflow: hidden;
}
html[data-theme="dark"] .style-d .sample {
background: linear-gradient(135deg, rgba(102, 182, 232, 0.10) 0%, rgba(102, 182, 232, 0.04) 100%);
border-color: rgba(102, 182, 232, 0.22);
}
.style-d .sample::before {
content: "";
position: absolute;
top: -28px;
right: -28px;
width: 90px;
height: 90px;
border-radius: 50%;
background: var(--accent);
opacity: 0.07;
}
.style-d .big-title {
font-family: "Manrope", sans-serif;
font-size: 1.18rem;
font-weight: 800;
margin: 0 0 10px;
line-height: 1.3;
}
.style-d .chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.style-d .chip {
background: var(--accent);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
border-radius: 999px;
padding: 3px 10px;
opacity: 0.88;
}
.style-d .body-text {
font-size: 0.93rem;
color: var(--text-main);
line-height: 1.65;
margin: 0;
}
/* Forsog 5: alert/callout card */
.style-e .alert-band {
display: flex;
align-items: center;
gap: 10px;
background: rgba(220, 53, 69, 0.08);
border: 1px solid rgba(220, 53, 69, 0.22);
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 10px;
}
html[data-theme="dark"] .style-e .alert-band {
background: rgba(220, 53, 69, 0.13);
border-color: rgba(220, 53, 69, 0.3);
}
.style-e .alert-icon {
font-size: 1.3rem;
line-height: 1;
flex-shrink: 0;
}
.style-e .alert-msg {
font-family: "Manrope", sans-serif;
font-size: 0.88rem;
font-weight: 700;
color: #c82333;
}
html[data-theme="dark"] .style-e .alert-msg { color: #e37c87; }
.style-e .clean-text {
background: var(--bg-muted);
border-radius: 10px;
padding: 12px;
font-size: 0.93rem;
line-height: 1.65;
border: 1px solid var(--line);
}
.style-e .footnote {
font-size: 0.76rem;
color: var(--text-soft);
margin-top: 6px;
display: flex;
gap: 8px;
align-items: center;
}
/* Forsog 6: inline highlight prose */
.style-f .sample {
font-size: 0.95rem;
line-height: 1.72;
}
.style-f .hl {
background: var(--accent-soft);
color: var(--accent);
border-radius: 4px;
padding: 1px 5px;
font-weight: 600;
font-size: 0.88em;
}
.style-f .divider {
border: none;
border-top: 1px solid var(--line);
margin: 10px 0;
}
.style-f .update-row {
display: flex;
gap: 8px;
align-items: flex-start;
font-size: 0.88rem;
}
.style-f .update-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-top: 5px;
flex-shrink: 0;
}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div>
<h1 class="title">Opgavebeskrivelse - 3 laekre forsog</h1>
<p class="subtitle">Ny mockup-side kun til redesign af selve opgavebeskrivelses-feltet.</p>
</div>
<button id="themeToggle" class="theme-btn" type="button">Skift tema</button>
</div>
<div class="grid">
<section class="card style-a">
<div class="card-head">
<span class="label">Forsog 1</span>
<h2>Editorial Box</h2>
<p class="desc">Ren, luftig tekstlaesning med tydelig overskrift og faerre visuelle forstyrrelser.</p>
</div>
<div class="content">
<div class="meta">
<span class="badge">Aaben</span>
<span class="badge">Opgave</span>
<span>Redigeret 19/03-2026 06:31</span>
<button class="actions">Rediger</button>
</div>
<div class="sample">
<p>Printer virker ikke</p>
<p>Ny info: Det er alle printere, og fakturaen er der fejl i.</p>
<p>Vi har allerede genstartet printserveren uden effekt. Brugerne faar timeout ved udskrift.</p>
</div>
<p class="hint">Styrke: ser premium og rolig ud. God naar beskrivelsen er teksttung.</p>
</div>
</section>
<section class="card style-b">
<div class="card-head">
<span class="label">Forsog 2</span>
<h2>Problem Flow</h2>
<p class="desc">Beskrivelsen opdeles i mini-trin, saa teknikeren ser problemets progression med det samme.</p>
</div>
<div class="content">
<div class="meta">
<span class="badge">Aaben</span>
<span class="badge">Kritisk</span>
<span>Sidst opdateret 19/03-2026 06:31</span>
<button class="actions">Rediger</button>
</div>
<div class="sample">
<div class="step">
<b>Symptom</b>
Printer virker ikke.
</div>
<div class="step">
<b>Scope</b>
Det gaelder alle printere paa lokationen.
</div>
<div class="step">
<b>Ekstra fejl</b>
Fakturaen indeholder fejl og skal kontrolleres samtidigt.
</div>
<div class="step" style="margin-bottom:0;">
<b>Status nu</b>
Brugerne kan ikke sende printjobs gennem printserveren.
</div>
</div>
<p class="hint">Styrke: hurtig triage. God naar opgaver ofte indeholder flere delproblemer.</p>
</div>
</section>
<section class="card style-c">
<div class="card-head">
<span class="label">Forsog 3</span>
<h2>Structured Panel</h2>
<p class="desc">Semistruktureret visning med faste felter i samme boks, uden at lave en tung formular.</p>
</div>
<div class="content">
<div class="meta">
<span class="badge">Aaben</span>
<span class="badge">Opgave</span>
<span>Redigeret 19/03-2026 06:31</span>
<button class="actions">Rediger</button>
</div>
<div class="sample">
<div class="row">
<div class="k">Kort titel</div>
<div class="v"><b>Printer virker ikke</b></div>
</div>
<div class="row">
<div class="k">Scope</div>
<div class="v">Alle printere paa lokationen er paavirket.</div>
</div>
<div class="row">
<div class="k">Detaljer</div>
<div class="v">Ny info: Fakturaen er der fejl i, og printfejl opstod efter netvaerksudfald.</div>
</div>
<div class="row">
<div class="k">Prioritet</div>
<div class="v">Hoj - paavirker daglig drift.</div>
</div>
</div>
<p class="hint">Styrke: strukturerer kaotiske beskrivelser uden at tvinge brugeren over i et stort skema.</p>
</div>
</section>
</div>
<div style="margin-top: 14px; margin-bottom: 6px; padding: 10px 0 2px; border-top: 2px solid var(--line);">
<span style="font-size:0.7rem; text-transform:uppercase; letter-spacing:0.1em; font-weight:700; color:var(--accent);">Runde 2 &mdash; 3 nye forsog</span>
</div>
<div class="grid">
<section class="card style-d">
<div class="card-head">
<span class="label">Forsog 4</span>
<h2>Spotlight Card</h2>
<p class="desc">Frosted gradient-boks med embedded tags og stor overskrift. Giver et moderne app-look.</p>
</div>
<div class="content">
<div class="meta">
<span class="badge">Aaben</span>
<span class="badge">Kritisk</span>
<span>Oprettet 19/03-2026 07:14</span>
<button class="actions">Rediger</button>
</div>
<div class="sample">
<p class="big-title">Printer virker ikke</p>
<div class="chips">
<span class="chip">Alle printere</span>
<span class="chip">Fakturaer</span>
<span class="chip">Printserver</span>
</div>
<p class="body-text">Brugerne faar timeout ved udskrift. Printserveren er genstartet uden effekt. Fakturaen indeholder desuden fejl og skal kontrolleres.</p>
</div>
<p class="hint">Styrke: visuelt laedkert og skalerbart. Fungerer godt naar der er klare keywords at fremhaeve.</p>
</div>
</section>
<section class="card style-e">
<div class="card-head">
<span class="label">Forsog 5</span>
<h2>Alert Callout</h2>
<p class="desc">Starten af beskrivelsen er en farvet alert-boks der naestforst viser hvad problemet er.</p>
</div>
<div class="content">
<div class="meta">
<span class="badge">Aaben</span>
<span class="badge">Hoj prioritet</span>
<span>Redigeret 19/03-2026 08:02</span>
<button class="actions">Rediger</button>
</div>
<div class="sample">
<div class="alert-band">
<span class="alert-icon">&#9888;</span>
<span class="alert-msg">Alle printere paa lokationen er nede &mdash; paavirker daglig drift.</span>
</div>
<div class="clean-text">
Printserveren er genstartet uden effekt. Brugerne kan ikke sende printjobs. Fakturaen er der desuden fejl i og skal kontrolleres.
</div>
<p class="footnote">
<span>&#128336;</span>
Sidst aendret af <strong>ct</strong> &mdash; 19/03-2026 08:02
</p>
</div>
<p class="hint">Styrke: f&oslash;rste sekund giver teknikeren det vigtigste - ideal til kritiske sager.</p>
</div>
</section>
<section class="card style-f">
<div class="card-head">
<span class="label">Forsog 6</span>
<h2>Inline Highlight</h2>
<p class="desc">Fri prosa med inline highlight-ord der fanger oejnene hurtigt uden at bryde flyden.</p>
</div>
<div class="content">
<div class="meta">
<span class="badge">Aaben</span>
<span class="badge">Opgave</span>
<span>19/03-2026 06:31</span>
<button class="actions">Rediger</button>
</div>
<div class="sample">
<p>
<span class="hl">Printer virker ikke</span> &mdash;
alle printere paa lokationen er paavirket.
<span class="hl">Printserveren</span> er genstartet uden effekt og
brugerne faar timeout ved udskrift.
</p>
<hr class="divider">
<div class="update-row">
<div class="update-dot"></div>
<div>
<strong>Opdatering:</strong>
<span class="hl">Fakturaen</span> indeholder fejl og skal kontrolleres.
Ny fejl-rapport tilf&oslash;jet 19/03-2026 08:15.
</div>
</div>
</div>
<p class="hint">Styrke: ingen struktur-tvang &mdash; fri tekst men med visuel guidning til vigtige ord.</p>
</div>
</section>
</div>
</div>
<script>
(function () {
var root = document.documentElement;
var btn = document.getElementById("themeToggle");
var saved = localStorage.getItem("mockupTheme");
if (saved === "dark" || saved === "light") {
root.setAttribute("data-theme", saved);
}
btn.addEventListener("click", function () {
var next = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
root.setAttribute("data-theme", next);
localStorage.setItem("mockupTheme", next);
});
})();
</script>
</body>
</html>

View File

@ -1,500 +0,0 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Samlet Forslagsoversigt</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<style>
:root {
--accent: #0f4c75;
--accent-soft: #eaf3fb;
--bg: #f4f6f8;
--card: #ffffff;
--border: #e2e8f0;
--text: #1f2937;
--muted: #6b7280;
--success-soft: #edf8f1;
--warning-soft: #fff7e8;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.wrap {
max-width: 1180px;
margin: 24px auto 56px;
padding: 0 14px;
}
.hero {
background: linear-gradient(135deg, #fafdff, #edf5fb);
border: 1px solid #d6e5f5;
border-radius: 14px;
padding: 18px 18px 16px;
margin-bottom: 18px;
}
.hero h1 {
margin: 0 0 6px;
font-size: 1.2rem;
color: var(--accent);
font-weight: 700;
}
.hero p {
margin: 0;
color: var(--muted);
max-width: 860px;
font-size: 0.85rem;
}
.note {
margin-top: 10px;
background: var(--warning-soft);
border: 1px solid #f4dfad;
color: #7c5b14;
border-radius: 10px;
padding: 10px 12px;
font-size: 0.8rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 14px;
}
.cardx {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.cardx-head {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
background: #fbfdff;
}
.eyebrow {
display: inline-block;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
color: var(--accent);
margin-bottom: 5px;
}
.cardx-head h2 {
margin: 0 0 6px;
font-size: 0.95rem;
font-weight: 700;
color: #111827;
}
.meta-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
font-size: 0.72rem;
background: var(--accent-soft);
color: var(--accent);
border: 1px solid #d2e5f6;
font-weight: 600;
}
.pill.model {
background: var(--success-soft);
border-color: #cfe9d8;
color: #1f6a3d;
}
.cardx-body {
padding: 12px 14px 14px;
}
.cardx-body p {
margin: 0 0 10px;
color: var(--muted);
font-size: 0.82rem;
}
.pros {
margin: 0;
padding-left: 18px;
color: #334155;
font-size: 0.82rem;
}
.pros li + li {
margin-top: 4px;
}
.demo {
margin-top: 12px;
border: 1px solid #edf1f5;
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.demo-row {
padding: 7px 10px;
border-bottom: 1px solid #f1f4f7;
font-size: 0.82rem;
}
.demo-row:last-child {
border-bottom: none;
}
.demo-meta {
display: flex;
gap: 8px;
align-items: center;
color: #64748b;
margin-bottom: 2px;
font-size: 0.74rem;
}
.demo-meta b {
color: #0f172a;
}
.time {
margin-left: auto;
color: #9aa3af;
font-size: 0.7rem;
}
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: #fff;
font-size: 0.62rem;
font-weight: 700;
flex-shrink: 0;
}
.sysbg {
background: #94a3b8;
}
.split {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
background: #fbfdff;
}
.bubble {
max-width: 78%;
border: 1px solid #dce5ee;
border-radius: 8px;
padding: 6px 9px;
font-size: 0.82rem;
}
.left { align-self: flex-start; background: #eef3f8; }
.right { align-self: flex-end; background: #e5eef8; border-color: #c8d8ea; }
.center { align-self: center; max-width: 92%; background: #f4f4f5; color: #6b7280; font-style: italic; }
.footer-box {
margin-top: 18px;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
}
.footer-box h3 {
margin: 0 0 8px;
font-size: 0.9rem;
color: var(--accent);
}
.ranking {
margin: 0;
padding-left: 18px;
font-size: 0.83rem;
color: #334155;
}
.ranking li + li {
margin-top: 6px;
}
@media (max-width: 768px) {
.wrap {
margin-top: 14px;
}
}
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<h1>Samlet oversigt over alle forslag</h1>
<p>Her er mine 3 bedste forslag samlet med de tidligere forslag fra de forskellige runder i denne samtale. Jeg har også markeret modelkilden pr. forslag.</p>
<div class="note">
Alle forslag i denne samtale er lavet af den samme model: <strong>GPT-5.4</strong>. Der er derfor ikke flere forskellige modeltyper bag forslagene. Det, jeg har samlet her, er forslag fra forskellige runder/filer, men modelkilden er den samme for dem alle.
</div>
</div>
<div class="grid">
<section class="cardx">
<div class="cardx-head">
<div class="eyebrow">Top 1</div>
<h2>Kompakt Thread</h2>
<div class="meta-list">
<span class="pill model">Model: GPT-5.4</span>
<span class="pill">Runde: Seneste anbefalinger</span>
<span class="pill">Fil: forslag_kommentar.html</span>
</div>
</div>
<div class="cardx-body">
<p>Bedste balance mellem kompakthed og læsbarhed. Små initialer, én let metadata-linje og tekst lige nedenunder.</p>
<ul class="pros">
<li>Let at scanne hurtigt</li>
<li>Virker godt ved mange kommentarer</li>
<li>Systembeskeder kan tones ned uden at forsvinde</li>
</ul>
<div class="demo">
<div class="demo-row">
<div class="demo-meta"><span class="avatar">JJ</span><b>Jens Jensen</b><span class="time">10:00</span></div>
Printerne melder offline siden fredag.
</div>
<div class="demo-row">
<div class="demo-meta"><span class="avatar">CT</span><b>Christian Thomas</b><span class="time">10:15</span></div>
Port 4 og 5 var nede. Har I prøvet genstart?
</div>
<div class="demo-row" style="background:#f8fafc;color:#64748b;font-style:italic;">
<div class="demo-meta"><span class="avatar sysbg">SY</span><b>System</b><span class="time">10:20</span></div>
Status ændret: Åben til Under behandling
</div>
</div>
</div>
</section>
<section class="cardx">
<div class="cardx-head">
<div class="eyebrow">Top 2</div>
<h2>Activity Log</h2>
<div class="meta-list">
<span class="pill model">Model: GPT-5.4</span>
<span class="pill">Runde: Seneste anbefalinger</span>
<span class="pill">Fil: forslag_kommentar.html</span>
</div>
</div>
<div class="cardx-body">
<p>Højeste informationsdensitet. Ingen avatarer nødvendige, kun små metadata og en diskret venstremarkering pr. type.</p>
<ul class="pros">
<li>Mest effektivt for teknikere</li>
<li>Meget lidt vertikalt spild</li>
<li>Velegnet til lange sagsforløb</li>
</ul>
<div class="demo">
<div class="demo-row" style="display:flex;padding:0;">
<div style="width:3px;background:#d1d5db"></div>
<div style="padding:7px 10px;flex:1;">
<div class="demo-meta"><b>Jens Jensen</b><span class="time">10:00</span></div>
Printerne melder offline siden fredag.
</div>
</div>
<div class="demo-row" style="display:flex;padding:0;">
<div style="width:3px;background:#0f4c75"></div>
<div style="padding:7px 10px;flex:1;">
<div class="demo-meta"><b>Christian Thomas</b><span class="time">10:15</span></div>
Port 4 og 5 var nede. Har I prøvet genstart?
</div>
</div>
<div class="demo-row" style="display:flex;padding:0;background:#fafafa;">
<div style="width:3px;background:#94a3b8"></div>
<div style="padding:7px 10px;flex:1;color:#64748b;font-style:italic;">
<div class="demo-meta"><b>System</b><span class="time">10:20</span></div>
Status ændret: Åben til Under behandling
</div>
</div>
</div>
</div>
</section>
<section class="cardx">
<div class="cardx-head">
<div class="eyebrow">Top 3</div>
<h2>Split Inbox</h2>
<div class="meta-list">
<span class="pill model">Model: GPT-5.4</span>
<span class="pill">Runde: Seneste anbefalinger</span>
<span class="pill">Fil: forslag_kommentar.html</span>
</div>
</div>
<div class="cardx-body">
<p>Tydelig separation mellem kunde og tekniker via venstre/højre-retning, uden de tunge chat-bobler fra klassisk messenger-design.</p>
<ul class="pros">
<li>God når sagen er meget dialogbaseret</li>
<li>Klar retning i samtalen</li>
<li>Stadig forholdsvis kompakt</li>
</ul>
<div class="demo split">
<div class="bubble left">Printerne melder offline siden fredag.<div class="demo-meta" style="margin-top:4px;margin-bottom:0;"><b>Jens Jensen</b><span class="time">10:00</span></div></div>
<div class="bubble right">Port 4 og 5 var nede. Har I prøvet genstart?<div class="demo-meta" style="margin-top:4px;margin-bottom:0;"><b>Christian Thomas</b><span class="time">10:15</span></div></div>
<div class="bubble center">Status ændret: Åben til Under behandling<div class="demo-meta" style="margin-top:4px;margin-bottom:0;justify-content:center;"><b>System</b><span class="time">10:20</span></div></div>
</div>
</div>
</section>
<section class="cardx">
<div class="cardx-head">
<div class="eyebrow">Tidligere Runde</div>
<h2>Inline Chat / Slack Kompakt</h2>
<div class="meta-list">
<span class="pill model">Model: GPT-5.4</span>
<span class="pill">Runde: Kompakt forslag</span>
<span class="pill">Fil: design_forslag_kompakt.html</span>
</div>
</div>
<div class="cardx-body">
<p>Alt på næsten én linje. Minimal spacing og meget lille meta-del. Endnu mere kompakt end thread-layoutet.</p>
<ul class="pros">
<li>Ekstremt tæt layout</li>
<li>Godt til intern drift</li>
<li>Mere råt og mindre visuelt venligt</li>
</ul>
</div>
</section>
<section class="cardx">
<div class="cardx-head">
<div class="eyebrow">Tidligere Runde</div>
<h2>Helpdesk Split</h2>
<div class="meta-list">
<span class="pill model">Model: GPT-5.4</span>
<span class="pill">Runde: Kompakt forslag</span>
<span class="pill">Fil: design_forslag_kompakt.html</span>
</div>
</div>
<div class="cardx-body">
<p>To faste kolonner: metadata til venstre, tekst til højre. Klassisk helpdesk/ITSM-udtryk.</p>
<ul class="pros">
<li>Meget struktureret</li>
<li>Let at sammenligne forfatter og tidspunkt</li>
<li>Fylder lidt mere i bredden</li>
</ul>
</div>
</section>
<section class="cardx">
<div class="cardx-head">
<div class="eyebrow">Tidligere Runde</div>
<h2>Minimalistisk Logbog</h2>
<div class="meta-list">
<span class="pill model">Model: GPT-5.4</span>
<span class="pill">Runde: Kompakt forslag</span>
<span class="pill">Fil: design_forslag_kompakt.html</span>
</div>
</div>
<div class="cardx-body">
<p>En tynd headerlinje pr. besked og fri tekst nedenunder. Et mere neutralt, dokumentationsagtigt udtryk.</p>
<ul class="pros">
<li>Meget nøgternt design</li>
<li>Godt til sager med mange statusnoter</li>
<li>Mindre tydelig dialogretning</li>
</ul>
</div>
</section>
<section class="cardx">
<div class="cardx-head">
<div class="eyebrow">Første Runde</div>
<h2>Messenger / Chat Stil</h2>
<div class="meta-list">
<span class="pill model">Model: GPT-5.4</span>
<span class="pill">Runde: Første brede forslag</span>
<span class="pill">Fil: design_forslag_sagsdetaljer.html</span>
</div>
</div>
<div class="cardx-body">
<p>Et mere klassisk chat-udtryk med tydelige kort og større visuel separation mellem beskeder.</p>
<ul class="pros">
<li>Meget tydeligt for ikke-tekniske brugere</li>
<li>Fylder for meget vertikalt</li>
<li>Ikke optimal til driftstunge sager</li>
</ul>
</div>
</section>
<section class="cardx">
<div class="cardx-head">
<div class="eyebrow">Første Runde</div>
<h2>Timeline / Activity Feed</h2>
<div class="meta-list">
<span class="pill model">Model: GPT-5.4</span>
<span class="pill">Runde: Første brede forslag</span>
<span class="pill">Fil: design_forslag_sagsdetaljer.html</span>
</div>
</div>
<div class="cardx-body">
<p>Lodret tidslinje med fokus på hændelsesforløb. Mere velegnet til historik end til tæt kommunikation.</p>
<ul class="pros">
<li>God til historikforståelse</li>
<li>Mere visuelt end praktisk</li>
<li>Fylder mere end nødvendigt</li>
</ul>
</div>
</section>
<section class="cardx">
<div class="cardx-head">
<div class="eyebrow">Første Runde</div>
<h2>Clean Cards</h2>
<div class="meta-list">
<span class="pill model">Model: GPT-5.4</span>
<span class="pill">Runde: Første brede forslag</span>
<span class="pill">Fil: design_forslag_sagsdetaljer.html</span>
</div>
</div>
<div class="cardx-body">
<p>Store, rene kort med farvemarkering. Pænere præsentation, men ikke pladsøkonomisk nok til dit behov.</p>
<ul class="pros">
<li>Flot visuelt</li>
<li>Let at forstå</li>
<li>For tungt til tæt daglig brug</li>
</ul>
</div>
</section>
</div>
<div class="footer-box">
<h3>Min samlede rangering</h3>
<ol class="ranking">
<li><strong>Kompakt Thread</strong> — bedste samlede løsning til live-systemet.</li>
<li><strong>Activity Log</strong> — bedste løsning hvis fokus er intern effektivitet og mindst mulig plads.</li>
<li><strong>Split Inbox</strong> — bedste løsning hvis kundedialog skal være tydeligst muligt.</li>
</ol>
</div>
</div>
</body>
</html>

View File

@ -1,159 +0,0 @@
#!/usr/bin/env python3
import argparse
import logging
import os
import re
import sys
from pathlib import Path
import psycopg2
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.core.config import settings
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
NUMBERED_SQL_RE = re.compile(r"^\d+.*\.sql$")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run BMC Hub SQL migrations against the configured PostgreSQL database."
)
parser.add_argument(
"files",
nargs="*",
help="Specific SQL files to run, relative to repo root (for example migrations/145_sag_start_date.sql).",
)
parser.add_argument(
"--all",
action="store_true",
help="Run all numbered SQL files from ./migrations in numeric order. Default when no files are provided.",
)
parser.add_argument(
"--module",
action="append",
default=[],
help="Also run numbered SQL files from a module migration directory, relative to repo root.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the files that would run without executing them.",
)
parser.add_argument(
"--stop-on-error",
action="store_true",
help="Stop immediately on the first migration failure.",
)
return parser.parse_args()
def patch_database_url_for_local_dev() -> None:
if "@postgres" in settings.DATABASE_URL:
logger.info("Patching DATABASE_URL for local run")
settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433")
def collect_numbered_sql(directory: Path) -> list[Path]:
files = [p for p in directory.glob("*.sql") if NUMBERED_SQL_RE.match(p.name)]
files.sort(key=lambda p: (int(re.match(r"^(\d+)", p.name).group(1)), p.name))
return files
def resolve_explicit_files(file_args: list[str]) -> list[Path]:
resolved = []
for raw in file_args:
path = (ROOT / raw).resolve()
if not path.exists():
raise FileNotFoundError(f"Migration file not found: {raw}")
resolved.append(path)
return resolved
def build_file_list(args: argparse.Namespace) -> list[Path]:
files: list[Path] = []
if args.files:
files.extend(resolve_explicit_files(args.files))
else:
files.extend(collect_numbered_sql(ROOT / "migrations"))
for module_dir in args.module:
path = (ROOT / module_dir).resolve()
if not path.exists() or not path.is_dir():
raise FileNotFoundError(f"Module migration directory not found: {module_dir}")
files.extend(collect_numbered_sql(path))
# Preserve order but remove duplicates.
unique_files: list[Path] = []
seen: set[Path] = set()
for path in files:
if path not in seen:
unique_files.append(path)
seen.add(path)
return unique_files
def run_files(files: list[Path], dry_run: bool, stop_on_error: bool) -> int:
if not files:
logger.info("No migration files selected.")
return 0
if dry_run:
for path in files:
logger.info("DRY %s", path.relative_to(ROOT))
return 0
conn = psycopg2.connect(settings.DATABASE_URL)
conn.autocommit = False
cur = conn.cursor()
failures: list[tuple[Path, str]] = []
try:
for path in files:
rel = path.relative_to(ROOT)
sql = path.read_text(encoding="utf-8")
try:
cur.execute(sql)
conn.commit()
logger.info("OK %s", rel)
except Exception as exc:
conn.rollback()
message = str(exc).strip().splitlines()[0]
failures.append((path, message))
logger.error("FAIL %s: %s", rel, message)
if stop_on_error:
break
finally:
cur.close()
conn.close()
if failures:
logger.error("")
logger.error("Failed migrations:")
for path, message in failures:
logger.error("- %s: %s", path.relative_to(ROOT), message)
return 1
logger.info("")
logger.info("All selected migrations completed successfully.")
return 0
def main() -> int:
args = parse_args()
patch_database_url_for_local_dev()
files = build_file_list(args)
return run_files(files, dry_run=args.dry_run, stop_on_error=args.stop_on_error)
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View File

@ -212,12 +212,10 @@ class TagPicker {
'status': '📊 Status - Tilstand', 'status': '📊 Status - Tilstand',
'category': '📁 Kategori - Emne', 'category': '📁 Kategori - Emne',
'priority': '🔥 Prioritet - Hastighed', 'priority': '🔥 Prioritet - Hastighed',
'billing': '💰 Fakturering - Økonomi', 'billing': '💰 Fakturering - Økonomi'
'brand': '🏷️ Brand - Leverandør/produkt',
'type': '🧩 Type - Sagstype'
}; };
const typeOrder = ['workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type']; const typeOrder = ['workflow', 'status', 'category', 'priority', 'billing'];
let html = ''; let html = '';
typeOrder.forEach(type => { typeOrder.forEach(type => {