feat: improve billing, sag, orders, and email workflows
This commit is contained in:
parent
a8eaf6e2a9
commit
daf2f29471
@ -3,7 +3,14 @@ Billing Router
|
||||
API endpoints for billing operations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
@ -12,6 +19,83 @@ router = APIRouter()
|
||||
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")
|
||||
async def list_invoices():
|
||||
"""List all invoices"""
|
||||
@ -22,3 +106,390 @@ async def list_invoices():
|
||||
async def sync_to_economic():
|
||||
"""Sync data to e-conomic"""
|
||||
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}")
|
||||
|
||||
@ -110,6 +110,9 @@
|
||||
<p class="text-muted mb-0">Kassekladde - Integration med e-conomic</p>
|
||||
</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">
|
||||
<i class="bi bi-grid-3x3 me-2"></i>Se Templates
|
||||
</a>
|
||||
|
||||
408
app/billing/frontend/sync_dashboard.html
Normal file
408
app/billing/frontend/sync_dashboard.html
Normal file
@ -0,0 +1,408 @@
|
||||
{% 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 %}
|
||||
@ -45,3 +45,12 @@ async def templates_list_page(request: Request):
|
||||
"request": request,
|
||||
"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"
|
||||
})
|
||||
|
||||
@ -18,6 +18,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"}
|
||||
|
||||
|
||||
# Pydantic Models
|
||||
class EmailListItem(BaseModel):
|
||||
@ -36,6 +38,8 @@ class EmailListItem(BaseModel):
|
||||
rule_name: Optional[str] = None
|
||||
supplier_name: Optional[str] = None
|
||||
customer_name: Optional[str] = None
|
||||
linked_case_id: Optional[int] = None
|
||||
linked_case_title: Optional[str] = None
|
||||
|
||||
|
||||
class EmailAttachment(BaseModel):
|
||||
@ -79,6 +83,7 @@ class EmailDetail(BaseModel):
|
||||
attachments: List[EmailAttachment] = []
|
||||
customer_name: Optional[str] = None
|
||||
supplier_name: Optional[str] = None
|
||||
linked_case_title: Optional[str] = None
|
||||
|
||||
|
||||
class EmailRule(BaseModel):
|
||||
@ -160,14 +165,12 @@ class CreateSagFromEmailRequest(BaseModel):
|
||||
ansvarlig_bruger_id: Optional[int] = None
|
||||
assigned_group_id: Optional[int] = None
|
||||
created_by_user_id: int = 1
|
||||
relation_type: str = "kommentar"
|
||||
relation_type: str = "mail"
|
||||
|
||||
|
||||
class LinkEmailToSagRequest(BaseModel):
|
||||
sag_id: int
|
||||
relation_type: str = "kommentar"
|
||||
note: Optional[str] = None
|
||||
forfatter: str = "E-mail Motor"
|
||||
relation_type: str = "mail"
|
||||
mark_processed: bool = True
|
||||
|
||||
|
||||
@ -302,13 +305,16 @@ async def list_emails(
|
||||
em.received_date, em.classification, em.confidence_score, em.status,
|
||||
em.is_read, em.has_attachments, em.attachment_count,
|
||||
em.body_text, em.body_html,
|
||||
em.linked_case_id,
|
||||
er.name as rule_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
|
||||
LEFT JOIN email_rules er ON em.rule_id = er.id
|
||||
LEFT JOIN vendors v ON em.supplier_id = v.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}
|
||||
ORDER BY em.received_date DESC
|
||||
LIMIT %s OFFSET %s
|
||||
@ -331,10 +337,12 @@ async def get_email(email_id: int):
|
||||
query = """
|
||||
SELECT em.*,
|
||||
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
|
||||
LEFT JOIN customers c ON em.customer_id = c.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
|
||||
"""
|
||||
result = execute_query(query, (email_id,))
|
||||
@ -580,23 +588,9 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
|
||||
(sag_id, payload.contact_id, 'primary')
|
||||
)
|
||||
|
||||
relation_type = (payload.relation_type or 'kommentar').strip().lower()
|
||||
if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
|
||||
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)
|
||||
)
|
||||
relation_type = (payload.relation_type or 'mail').strip().lower()
|
||||
if relation_type not in ALLOWED_SAG_EMAIL_RELATION_TYPES:
|
||||
raise HTTPException(status_code=400, detail="relation_type must be 'mail'")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@ -663,20 +657,9 @@ async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
|
||||
(payload.sag_id, email_id)
|
||||
)
|
||||
|
||||
relation_type = (payload.relation_type or 'kommentar').strip().lower()
|
||||
if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
|
||||
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)
|
||||
)
|
||||
relation_type = (payload.relation_type or 'mail').strip().lower()
|
||||
if relation_type not in ALLOWED_SAG_EMAIL_RELATION_TYPES:
|
||||
raise HTTPException(status_code=400, detail="relation_type must be 'mail'")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@ -555,6 +555,27 @@
|
||||
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 {
|
||||
position: relative;
|
||||
}
|
||||
@ -1702,6 +1723,7 @@ function renderEmailList(emailList) {
|
||||
<span class="classification-badge classification-${classification}">
|
||||
${formatClassification(classification)}
|
||||
</span>
|
||||
${getCaseBadge(email)}
|
||||
${getStatusBadge(email)}
|
||||
${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>` : ''}
|
||||
@ -1864,6 +1886,13 @@ function renderEmailDetail(email) {
|
||||
<i class="bi bi-clock me-1"></i>${timestamp}
|
||||
</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 class="email-actions d-flex justify-content-between align-items-center">
|
||||
@ -1880,6 +1909,11 @@ function renderEmailDetail(email) {
|
||||
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail()" title="Kør Workflows">
|
||||
<i class="bi bi-diagram-3 me-1"></i>Workflows
|
||||
</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">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
@ -2055,7 +2089,7 @@ function renderEmailAnalysis(email) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-action-row mt-3">
|
||||
<div class="quick-action-row quick-action-row-case mt-3">
|
||||
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
|
||||
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
|
||||
</button>
|
||||
@ -2073,15 +2107,7 @@ function renderEmailAnalysis(email) {
|
||||
<datalist id="existingSagResults"></datalist>
|
||||
<input id="existingSagId" type="hidden" value="">
|
||||
</div>
|
||||
<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()">
|
||||
<button class="btn btn-sm btn-primary mt-2 w-100" onclick="linkCurrentEmailToExistingSag()">
|
||||
<i class="bi bi-link me-1"></i>Tilknyt Sag
|
||||
</button>
|
||||
</div>
|
||||
@ -2283,7 +2309,7 @@ function getCaseFormPayload() {
|
||||
priority: document.getElementById('casePriority')?.value || 'normal',
|
||||
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,
|
||||
relation_type: 'kommentar'
|
||||
relation_type: 'mail'
|
||||
};
|
||||
}
|
||||
|
||||
@ -2331,7 +2357,7 @@ async function linkCurrentEmailToExistingSag() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sag_id: Number(selectedSagId),
|
||||
relation_type: document.getElementById('existingSagRelationType')?.value || 'kommentar'
|
||||
relation_type: 'mail'
|
||||
})
|
||||
});
|
||||
|
||||
@ -3196,6 +3222,15 @@ function getStatusBadge(email) {
|
||||
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) {
|
||||
if (contentType?.includes('pdf')) return 'pdf';
|
||||
if (contentType?.includes('image')) return 'image';
|
||||
|
||||
@ -7,11 +7,9 @@ Runs daily at 04:00
|
||||
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
import json
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, get_db_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -19,11 +17,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
async def process_subscriptions():
|
||||
"""
|
||||
Main job: Process subscriptions due for invoicing
|
||||
- Find active subscriptions where next_invoice_date <= TODAY
|
||||
- Create ordre draft with line items from subscription
|
||||
- Advance period_start and next_invoice_date based on billing_interval
|
||||
- Log all actions for audit trail
|
||||
Main job: Process subscriptions due for invoicing.
|
||||
- Find active subscriptions where next_invoice_date <= today
|
||||
- Skip subscriptions blocked for invoicing (missing asset/serial)
|
||||
- Aggregate eligible subscriptions into one ordre_draft per customer + merge key + due date + billing direction
|
||||
- Advance period_start and next_invoice_date for processed subscriptions
|
||||
"""
|
||||
|
||||
try:
|
||||
@ -39,9 +37,14 @@ async def process_subscriptions():
|
||||
c.name AS customer_name,
|
||||
s.product_name,
|
||||
s.billing_interval,
|
||||
s.billing_direction,
|
||||
s.advance_months,
|
||||
s.price,
|
||||
s.next_invoice_date,
|
||||
s.period_start,
|
||||
s.invoice_merge_key,
|
||||
s.billing_blocked,
|
||||
s.billing_block_reason,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_agg(
|
||||
@ -51,7 +54,12 @@ async def process_subscriptions():
|
||||
'quantity', si.quantity,
|
||||
'unit_price', si.unit_price,
|
||||
'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
|
||||
)
|
||||
FROM sag_subscription_items si
|
||||
@ -75,110 +83,186 @@ async def process_subscriptions():
|
||||
|
||||
logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process")
|
||||
|
||||
blocked_count = 0
|
||||
processed_count = 0
|
||||
error_count = 0
|
||||
|
||||
|
||||
grouped_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:
|
||||
await _process_single_subscription(sub)
|
||||
processed_count += 1
|
||||
count = await _process_subscription_group(group)
|
||||
processed_count += count
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to process subscription {sub['id']}: {e}", exc_info=True)
|
||||
logger.error("❌ Failed processing subscription group: %s", e, exc_info=True)
|
||||
error_count += 1
|
||||
|
||||
logger.info(f"✅ Subscription processing complete: {processed_count} processed, {error_count} errors")
|
||||
|
||||
logger.info(
|
||||
"✅ Subscription processing complete: %s processed, %s blocked, %s errors",
|
||||
processed_count,
|
||||
blocked_count,
|
||||
error_count,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def _process_single_subscription(sub: dict):
|
||||
"""Process a single subscription: create ordre draft and advance period"""
|
||||
|
||||
subscription_id = sub['id']
|
||||
logger.info(f"Processing subscription #{subscription_id}: {sub['product_name']} for {sub['customer_name']}")
|
||||
|
||||
async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
||||
"""Create one aggregated ordre draft for a group of subscriptions and advance all periods."""
|
||||
|
||||
if not subscriptions:
|
||||
return 0
|
||||
|
||||
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()
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
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 = []
|
||||
for item in line_items:
|
||||
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
|
||||
})
|
||||
|
||||
# Create ordre draft title with period information
|
||||
period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
||||
next_period_start = _calculate_next_period_start(period_start, sub['billing_interval'])
|
||||
|
||||
title = f"Abonnement: {sub['product_name']}"
|
||||
notes = f"Periode: {period_start} til {next_period_start}\nAbonnement ID: {subscription_id}"
|
||||
|
||||
if sub.get('sag_id'):
|
||||
notes += f"\nSag: {sub['sag_name']}"
|
||||
|
||||
# Insert ordre draft
|
||||
source_subscription_ids = []
|
||||
coverage_start = None
|
||||
coverage_end = None
|
||||
|
||||
for sub in subscriptions:
|
||||
subscription_id = int(sub['id'])
|
||||
source_subscription_ids.append(subscription_id)
|
||||
|
||||
line_items = sub.get('line_items', [])
|
||||
if isinstance(line_items, str):
|
||||
line_items = json.loads(line_items)
|
||||
|
||||
period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
||||
period_end = _calculate_next_period_start(period_start, sub['billing_interval'])
|
||||
if coverage_start is None or period_start < coverage_start:
|
||||
coverage_start = period_start
|
||||
if coverage_end is None or period_end > coverage_end:
|
||||
coverage_end = period_end
|
||||
|
||||
for item in line_items:
|
||||
if item.get('billing_blocked'):
|
||||
logger.warning(
|
||||
"⚠️ Skipping blocked subscription item %s on subscription %s",
|
||||
item.get('id'),
|
||||
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 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::jsonb, CURRENT_TIMESTAMP)
|
||||
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
|
||||
cursor.execute(insert_query, (
|
||||
title,
|
||||
sub['customer_id'],
|
||||
customer_id,
|
||||
json.dumps(ordre_lines, ensure_ascii=False),
|
||||
notes,
|
||||
coverage_start,
|
||||
coverage_end,
|
||||
billing_direction,
|
||||
source_subscription_ids,
|
||||
invoice_aggregate_key,
|
||||
1, # Default layout
|
||||
None, # System-created
|
||||
json.dumps({"source": "subscription", "subscription_id": subscription_id}, ensure_ascii=False)
|
||||
'pending',
|
||||
json.dumps({"source": "subscription", "subscription_ids": source_subscription_ids}, ensure_ascii=False)
|
||||
))
|
||||
|
||||
|
||||
ordre_id = cursor.fetchone()[0]
|
||||
logger.info(f"✅ Created ordre draft #{ordre_id} for subscription #{subscription_id}")
|
||||
|
||||
# Calculate new period dates
|
||||
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'])
|
||||
|
||||
# Update subscription with new period dates
|
||||
update_query = """
|
||||
UPDATE sag_subscriptions
|
||||
SET period_start = %s,
|
||||
next_invoice_date = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
cursor.execute(update_query, (new_period_start, new_next_invoice_date, subscription_id))
|
||||
|
||||
logger.info(
|
||||
"✅ Created aggregated ordre draft #%s for %s subscription(s)",
|
||||
ordre_id,
|
||||
len(source_subscription_ids),
|
||||
)
|
||||
|
||||
for sub in subscriptions:
|
||||
subscription_id = int(sub['id'])
|
||||
current_period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
||||
new_period_start = _calculate_next_period_start(current_period_start, sub['billing_interval'])
|
||||
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE sag_subscriptions
|
||||
SET period_start = %s,
|
||||
next_invoice_date = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(new_period_start, new_next_invoice_date, subscription_id)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"✅ Advanced subscription #{subscription_id}: next invoice {new_next_invoice_date}")
|
||||
|
||||
return len(source_subscription_ids)
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise e
|
||||
|
||||
119
app/jobs/reconcile_ordre_drafts.py
Normal file
119
app/jobs/reconcile_ordre_drafts.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
@ -11,6 +12,7 @@ from app.modules.orders.backend.service import aggregate_order_lines
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
|
||||
|
||||
|
||||
class OrdreLineInput(BaseModel):
|
||||
@ -32,6 +34,7 @@ class OrdreExportRequest(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
layout_number: Optional[int] = None
|
||||
draft_id: Optional[int] = None
|
||||
force_export: bool = False
|
||||
|
||||
|
||||
class OrdreDraftUpsertRequest(BaseModel):
|
||||
@ -65,6 +68,42 @@ def _get_user_id_from_request(http_request: Request) -> Optional[int]:
|
||||
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")
|
||||
async def get_ordre_aggregate(
|
||||
customer_id: Optional[int] = Query(None),
|
||||
@ -95,6 +134,39 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
"""Export selected ordre lines to e-conomic draft order."""
|
||||
try:
|
||||
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]
|
||||
export_result = await ordre_economic_export_service.export_order(
|
||||
@ -123,15 +195,53 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
"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(
|
||||
"""
|
||||
UPDATE ordre_drafts
|
||||
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,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
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
|
||||
@ -150,9 +260,26 @@ async def list_ordre_drafts(
|
||||
"""List all ordre drafts (no user filtering)."""
|
||||
try:
|
||||
query = """
|
||||
SELECT id, title, customer_id, notes, layout_number, created_by_user_id,
|
||||
created_at, updated_at, last_exported_at
|
||||
SELECT id, title, customer_id, notes, layout_number, created_by_user_id,
|
||||
coverage_start, coverage_end, billing_direction, source_subscription_ids,
|
||||
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
|
||||
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
|
||||
LIMIT %s
|
||||
"""
|
||||
@ -202,9 +329,10 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
|
||||
notes,
|
||||
layout_number,
|
||||
created_by_user_id,
|
||||
sync_status,
|
||||
export_status_json,
|
||||
updated_at
|
||||
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
|
||||
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
"""
|
||||
params = (
|
||||
@ -223,6 +351,172 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
|
||||
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}")
|
||||
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
|
||||
"""Update existing ordre draft."""
|
||||
|
||||
@ -49,6 +49,30 @@
|
||||
color: white;
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -72,6 +96,13 @@
|
||||
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
|
||||
</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="row g-3">
|
||||
<div class="col-md-3">
|
||||
@ -114,6 +145,120 @@
|
||||
<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 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-body">
|
||||
<div class="table-responsive">
|
||||
@ -138,6 +283,15 @@
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
@ -146,6 +300,27 @@
|
||||
const draftId = {{ draft_id }};
|
||||
let orderData = null;
|
||||
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) {
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
|
||||
@ -164,6 +339,35 @@
|
||||
return '<span class="badge bg-success">Salg</span>';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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() {
|
||||
const tbody = document.getElementById('linesTableBody');
|
||||
if (!orderLines.length) {
|
||||
@ -311,10 +515,142 @@
|
||||
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
|
||||
|
||||
renderLines();
|
||||
refreshSyncPanelFromOrder();
|
||||
await loadConfig();
|
||||
await loadSyncEvents(syncEventsOffset);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(`Fejl: ${error.message}`);
|
||||
showToast(`Fejl: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -352,22 +688,22 @@
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Kunne ikke gemme ordre');
|
||||
|
||||
alert('Ordre gemt');
|
||||
showToast('Ordre gemt', 'success');
|
||||
await loadOrder();
|
||||
} catch (err) {
|
||||
alert(`Kunne ikke gemme ordre: ${err.message}`);
|
||||
showToast(`Kunne ikke gemme ordre: ${err.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportOrder() {
|
||||
const customerId = Number(document.getElementById('customerId').value || 0);
|
||||
if (!customerId) {
|
||||
alert('Angiv kunde ID før eksport');
|
||||
showToast('Angiv kunde ID før eksport', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!orderLines.length) {
|
||||
alert('Ingen linjer at eksportere');
|
||||
showToast('Ingen linjer at eksportere', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -388,6 +724,7 @@
|
||||
notes: document.getElementById('orderNotes').value || null,
|
||||
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
|
||||
draft_id: draftId,
|
||||
force_export: document.getElementById('forceExportToggle').checked,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -401,11 +738,11 @@
|
||||
throw new Error(data.detail || 'Eksport fejlede');
|
||||
}
|
||||
|
||||
alert(data.message || 'Eksport udført');
|
||||
showToast(data.message || 'Eksport udført', data.dry_run ? 'warning' : 'success');
|
||||
await loadOrder();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(`Eksport fejlede: ${err.message}`);
|
||||
showToast(`Eksport fejlede: ${err.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -36,6 +36,31 @@
|
||||
.order-row:hover {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -48,6 +73,16 @@
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -65,6 +100,7 @@
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 42px;"><input id="selectAllOrders" type="checkbox" onchange="toggleSelectAll(this.checked)"></th>
|
||||
<th>Ordre #</th>
|
||||
<th>Titel</th>
|
||||
<th>Kunde</th>
|
||||
@ -72,23 +108,53 @@
|
||||
<th>Oprettet</th>
|
||||
<th>Sidst opdateret</th>
|
||||
<th>Sidst eksporteret</th>
|
||||
<th>Seneste event</th>
|
||||
<th>Status</th>
|
||||
<th>Sync</th>
|
||||
<th>Handlinger</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ordersTableBody">
|
||||
<tr><td colspan="9" class="text-muted text-center py-4">Indlæser...</td></tr>
|
||||
<tr><td colspan="12" class="text-muted text-center py-4">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
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) {
|
||||
if (!dateStr) return '-';
|
||||
@ -96,23 +162,48 @@
|
||||
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() {
|
||||
const visibleOrders = getFilteredOrders();
|
||||
const tbody = document.getElementById('ordersTableBody');
|
||||
if (!orders.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Ingen ordre fundet</td></tr>';
|
||||
updateSummary();
|
||||
if (!orders.length || !visibleOrders.length) {
|
||||
const message = orders.length
|
||||
? 'Ingen ordre matcher det valgte filter'
|
||||
: 'Ingen ordre fundet';
|
||||
tbody.innerHTML = `<tr><td colspan="12" class="text-muted text-center py-4">${message}</td></tr>`;
|
||||
updateSummary(visibleOrders);
|
||||
syncSelectAllCheckbox(visibleOrders);
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = orders.map(order => {
|
||||
tbody.innerHTML = visibleOrders.map(order => {
|
||||
const lines = Array.isArray(order.lines_json) ? order.lines_json : [];
|
||||
const hasExported = order.last_exported_at ? true : false;
|
||||
const statusBadge = hasExported
|
||||
? '<span class="badge bg-success">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 `
|
||||
<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>${order.title || '-'}</td>
|
||||
<td>${order.customer_id ? `Kunde ${order.customer_id}` : '-'}</td>
|
||||
@ -120,7 +211,31 @@
|
||||
<td>${formatDate(order.created_at)}</td>
|
||||
<td>${formatDate(order.updated_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>
|
||||
<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>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); window.location.href='/ordre/${order.id}'">
|
||||
<i class="bi bi-eye"></i>
|
||||
@ -133,18 +248,61 @@
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
updateSummary();
|
||||
syncSelectAllCheckbox(visibleOrders);
|
||||
updateSelectedCounter();
|
||||
|
||||
updateSummary(visibleOrders);
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
function toggleOrderSelection(orderId, checked) {
|
||||
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 oneMonthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
|
||||
const recentOrders = orders.filter(order => new Date(order.created_at) >= oneMonthAgo);
|
||||
const exportedOrders = orders.filter(order => order.last_exported_at);
|
||||
const notExportedOrders = orders.filter(order => !order.last_exported_at);
|
||||
const recentOrders = rows.filter(order => new Date(order.created_at) >= oneMonthAgo);
|
||||
const exportedOrders = rows.filter(order => order.last_exported_at);
|
||||
const notExportedOrders = rows.filter(order => !order.last_exported_at);
|
||||
|
||||
document.getElementById('sumOrders').textContent = orders.length;
|
||||
document.getElementById('sumOrders').textContent = rows.length;
|
||||
document.getElementById('sumRecent').textContent = recentOrders.length;
|
||||
document.getElementById('sumExported').textContent = exportedOrders.length;
|
||||
document.getElementById('sumNotExported').textContent = notExportedOrders.length;
|
||||
@ -152,7 +310,7 @@
|
||||
|
||||
async function loadOrders() {
|
||||
const tbody = document.getElementById('ordersTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Indlæser...</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="12" class="text-muted text-center py-4">Indlæser...</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/ordre/drafts?limit=100');
|
||||
@ -166,10 +324,13 @@
|
||||
console.log('Fetched orders:', 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) {
|
||||
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();
|
||||
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>';
|
||||
updateSummary([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -194,9 +355,86 @@
|
||||
renderOrders();
|
||||
} catch (error) {
|
||||
console.error('Load orders error:', error);
|
||||
tbody.innerHTML = `<tr><td colspan="9" class="text-danger text-center py-4">${error.message || 'Kunne ikke hente ordre'}</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="12" class="text-danger text-center py-4">${error.message || 'Kunne ikke hente ordre'}</td></tr>`;
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,9 +449,9 @@
|
||||
}
|
||||
|
||||
await loadOrders();
|
||||
alert('Ordre slettet');
|
||||
showToast('Ordre slettet', 'success');
|
||||
} catch (error) {
|
||||
alert(`Fejl: ${error.message}`);
|
||||
showToast(`Fejl: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import re
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
@ -188,6 +190,131 @@ def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
|
||||
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("&", "&").replace("<", "<").replace(">", ">").replace("\n", "<br>")
|
||||
return f"{body_html}<br><br>--<br>{signature_html}"
|
||||
|
||||
|
||||
@router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis)
|
||||
async def analyze_quick_create(request: QuickCreateRequest):
|
||||
"""
|
||||
@ -276,8 +403,8 @@ async def list_sager(
|
||||
cases = [c for c in cases if c["id"] in tagged_ids]
|
||||
|
||||
return cases
|
||||
except Exception as e:
|
||||
logger.error("❌ Error listing cases: %s", e)
|
||||
except Exception:
|
||||
logger.exception("❌ Error listing cases")
|
||||
raise HTTPException(status_code=500, detail="Failed to list cases")
|
||||
|
||||
@router.get("/sag/sale-items/all")
|
||||
@ -1901,7 +2028,7 @@ async def get_kommentarer(sag_id: int):
|
||||
if not check:
|
||||
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 ASC"
|
||||
query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
|
||||
result = execute_query(query, (sag_id,))
|
||||
return result
|
||||
except HTTPException:
|
||||
@ -1911,7 +2038,7 @@ async def get_kommentarer(sag_id: int):
|
||||
raise HTTPException(status_code=500, detail="Failed to get comments")
|
||||
|
||||
@router.post("/sag/{sag_id}/kommentarer")
|
||||
async def add_kommentar(sag_id: int, data: dict):
|
||||
async def add_kommentar(sag_id: int, data: dict, request: Request):
|
||||
"""Add a comment to a case."""
|
||||
try:
|
||||
if not data.get("indhold"):
|
||||
@ -1921,10 +2048,34 @@ async def add_kommentar(sag_id: int, data: dict):
|
||||
if not check:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
# Default author to current user or provided in body (if system)
|
||||
# simplistic auth for now
|
||||
forfatter = data.get("forfatter", "Bruger")
|
||||
er_system_besked = data.get("er_system_besked", False)
|
||||
er_system_besked = bool(data.get("er_system_besked", False))
|
||||
|
||||
if er_system_besked:
|
||||
forfatter = str(data.get("forfatter") or "System").strip() or "System"
|
||||
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 = """
|
||||
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||
@ -2118,19 +2269,33 @@ async def get_sag_emails(sag_id: int):
|
||||
SELECT
|
||||
e.*,
|
||||
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((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'), ''),
|
||||
CONCAT('email-', e.id::text)
|
||||
) AS thread_key
|
||||
) AS resolved_thread_key
|
||||
FROM email_messages e
|
||||
JOIN sag_emails se ON e.id = se.email_id
|
||||
WHERE se.sag_id = %s
|
||||
)
|
||||
SELECT
|
||||
linked_emails.*,
|
||||
COUNT(*) OVER (PARTITION BY linked_emails.thread_key) AS thread_message_count,
|
||||
MAX(linked_emails.received_date) OVER (PARTITION BY linked_emails.thread_key) AS thread_last_received_date
|
||||
(
|
||||
LOWER(COALESCE(linked_emails.folder, '')) LIKE 'sent%%'
|
||||
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
|
||||
ORDER BY thread_last_received_date DESC NULLS LAST, received_date DESC
|
||||
"""
|
||||
@ -2238,7 +2403,7 @@ async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/emails/send")
|
||||
async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Request):
|
||||
"""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,))
|
||||
if not case_exists:
|
||||
@ -2258,6 +2423,11 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
if not body_text:
|
||||
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_ids = list(dict.fromkeys(payload.attachment_file_ids or []))
|
||||
if attachment_ids:
|
||||
@ -2322,12 +2492,22 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
else:
|
||||
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()
|
||||
success, send_message, generated_message_id = await email_service.send_email_with_attachments(
|
||||
success, send_message, generated_message_id, provider_thread_key = await email_service.send_email_with_attachments(
|
||||
to_addresses=to_addresses,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=payload.body_html,
|
||||
body_html=body_html,
|
||||
cc=cc_addresses,
|
||||
bcc=bcc_addresses,
|
||||
in_reply_to=in_reply_to_header,
|
||||
@ -2338,10 +2518,23 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
|
||||
if not success:
|
||||
logger.error("❌ Failed to send case email for case %s: %s", sag_id, send_message)
|
||||
raise HTTPException(status_code=500, detail="Failed to send email")
|
||||
failure_detail = str(send_message or "Email send failed without provider detail").strip()
|
||||
# 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_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
|
||||
try:
|
||||
@ -2349,11 +2542,30 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
INSERT INTO email_messages (
|
||||
message_id, subject, sender_email, sender_name,
|
||||
recipient_email, cc, body_text, body_html,
|
||||
in_reply_to, email_references,
|
||||
in_reply_to, email_references, thread_key,
|
||||
received_date, folder, has_attachments, attachment_count,
|
||||
status, import_method, linked_case_id
|
||||
)
|
||||
VALUES (%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, %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
|
||||
"""
|
||||
insert_result = execute_query(
|
||||
@ -2366,15 +2578,16 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
", ".join(to_addresses),
|
||||
", ".join(cc_addresses),
|
||||
body_text,
|
||||
payload.body_html,
|
||||
body_html,
|
||||
in_reply_to_header,
|
||||
references_header,
|
||||
thread_key,
|
||||
datetime.now(),
|
||||
"Sent",
|
||||
bool(smtp_attachments),
|
||||
len(smtp_attachments),
|
||||
"sent",
|
||||
"sag_outbound",
|
||||
"manual_upload",
|
||||
sag_id,
|
||||
),
|
||||
)
|
||||
@ -2387,6 +2600,22 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
status, import_method, linked_case_id
|
||||
)
|
||||
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
|
||||
"""
|
||||
insert_result = execute_query(
|
||||
@ -2399,13 +2628,13 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
", ".join(to_addresses),
|
||||
", ".join(cc_addresses),
|
||||
body_text,
|
||||
payload.body_html,
|
||||
body_html,
|
||||
datetime.now(),
|
||||
"Sent",
|
||||
bool(smtp_attachments),
|
||||
len(smtp_attachments),
|
||||
"sent",
|
||||
"sag_outbound",
|
||||
"manual_upload",
|
||||
sag_id,
|
||||
),
|
||||
)
|
||||
@ -2446,6 +2675,32 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
(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(
|
||||
"✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)",
|
||||
sag_id,
|
||||
@ -2458,6 +2713,13 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
"status": "sent",
|
||||
"email_id": email_id,
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@ -252,9 +252,24 @@ async def sager_liste(
|
||||
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||
"current_assigned_group_id": assigned_group_id_int,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("❌ Error displaying case list: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to load case list")
|
||||
except Exception:
|
||||
logger.exception("❌ Error displaying case list")
|
||||
return templates.TemplateResponse("modules/sag/templates/index.html", {
|
||||
"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)
|
||||
async def opret_sag_side(request: Request):
|
||||
@ -454,7 +469,7 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
customers = execute_query(customers_query, (sag_id,))
|
||||
|
||||
# Fetch comments
|
||||
comments_query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at ASC"
|
||||
comments_query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
|
||||
comments = execute_query(comments_query, (sag_id,))
|
||||
|
||||
# Fetch Solution
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -135,7 +135,7 @@ async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/prepaid-cards/{card_id}", response_model=Dict[str, Any])
|
||||
@router.get("/prepaid-cards/{card_id:int}", response_model=Dict[str, Any])
|
||||
async def get_prepaid_card(card_id: int):
|
||||
"""
|
||||
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))
|
||||
|
||||
|
||||
@router.put("/prepaid-cards/{card_id}/status")
|
||||
@router.put("/prepaid-cards/{card_id:int}/status")
|
||||
async def update_card_status(card_id: int, status: str):
|
||||
"""
|
||||
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))
|
||||
|
||||
|
||||
@router.put("/prepaid-cards/{card_id}/rounding", response_model=Dict[str, Any])
|
||||
@router.put("/prepaid-cards/{card_id:int}/rounding", response_model=Dict[str, Any])
|
||||
async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate):
|
||||
"""
|
||||
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))
|
||||
|
||||
|
||||
@router.delete("/prepaid-cards/{card_id}")
|
||||
@router.delete("/prepaid-cards/{card_id:int}")
|
||||
async def delete_prepaid_card(card_id: int):
|
||||
"""
|
||||
Delete a prepaid card (only if no transactions)
|
||||
|
||||
@ -6,7 +6,7 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=["app/prepaid/frontend", "app/shared/frontend"])
|
||||
templates = Jinja2Templates(directory=["app/prepaid/frontend", "app/shared/frontend", "app"])
|
||||
|
||||
|
||||
@router.get("/prepaid-cards", response_class=HTMLResponse)
|
||||
|
||||
@ -113,7 +113,9 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
||||
"""
|
||||
match_params = (product_id, supplier_name, supplier_sku)
|
||||
|
||||
existing = execute_query_single(match_query, match_params) if match_query else None
|
||||
existing = None
|
||||
if match_query and match_params is not None:
|
||||
existing = execute_query_single(match_query, match_params)
|
||||
|
||||
if existing:
|
||||
update_query = """
|
||||
@ -473,6 +475,9 @@ async def list_products(
|
||||
minimum_term_months,
|
||||
is_bundle,
|
||||
billable,
|
||||
serial_number_required,
|
||||
asset_required,
|
||||
rental_asset_enabled,
|
||||
image_url
|
||||
FROM products
|
||||
{where_clause}
|
||||
@ -526,6 +531,9 @@ async def create_product(payload: Dict[str, Any]):
|
||||
parent_product_id,
|
||||
bundle_pricing_model,
|
||||
billable,
|
||||
serial_number_required,
|
||||
asset_required,
|
||||
rental_asset_enabled,
|
||||
default_case_tag,
|
||||
default_time_rate_id,
|
||||
category_id,
|
||||
@ -548,7 +556,8 @@ 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
|
||||
)
|
||||
RETURNING *
|
||||
"""
|
||||
@ -585,6 +594,9 @@ async def create_product(payload: Dict[str, Any]):
|
||||
payload.get("parent_product_id"),
|
||||
payload.get("bundle_pricing_model"),
|
||||
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_time_rate_id"),
|
||||
payload.get("category_id"),
|
||||
@ -634,7 +646,7 @@ async def update_product(
|
||||
payload: Dict[str, Any],
|
||||
current_user: dict = Depends(require_permission("products.update"))
|
||||
):
|
||||
"""Update product fields like name."""
|
||||
"""Update product fields for core metadata and billing validation flags."""
|
||||
try:
|
||||
name = payload.get("name")
|
||||
if name is not None:
|
||||
@ -642,21 +654,45 @@ async def update_product(
|
||||
if not name:
|
||||
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(
|
||||
"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,)
|
||||
)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
query = """
|
||||
updates = ["updated_at = CURRENT_TIMESTAMP"]
|
||||
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
|
||||
SET name = COALESCE(%s, name),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
RETURNING *
|
||||
"""
|
||||
result = execute_query(query, (name, product_id))
|
||||
result = execute_query(query, tuple(values))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
if name is not None and name != existing.get("name"):
|
||||
@ -666,6 +702,15 @@ async def update_product(
|
||||
current_user.get("id") if current_user else None,
|
||||
{"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]
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@ -784,7 +784,7 @@ class EconomicService:
|
||||
invoice_date: str,
|
||||
total_amount: float,
|
||||
vat_breakdown: Dict[str, float],
|
||||
line_items: List[Dict] = None,
|
||||
line_items: Optional[List[Dict]] = None,
|
||||
due_date: Optional[str] = None,
|
||||
text: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
@ -983,10 +983,12 @@ class EconomicService:
|
||||
data = await response.json() if response_text else {}
|
||||
|
||||
# e-conomic returns array of created vouchers
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict):
|
||||
voucher_data = data[0]
|
||||
else:
|
||||
elif isinstance(data, dict):
|
||||
voucher_data = data
|
||||
else:
|
||||
voucher_data = {}
|
||||
|
||||
voucher_number = voucher_data.get('voucherNumber')
|
||||
logger.info(f"✅ Supplier invoice posted to kassekladde: voucher #{voucher_number}")
|
||||
@ -1045,8 +1047,8 @@ class EconomicService:
|
||||
url = f"{self.api_url}/journals/{journal_number}/vouchers/{accounting_year}-{voucher_number}/attachment/file"
|
||||
|
||||
headers = {
|
||||
'X-AppSecretToken': self.app_secret_token,
|
||||
'X-AgreementGrantToken': self.agreement_grant_token
|
||||
'X-AppSecretToken': str(self.app_secret_token or ''),
|
||||
'X-AgreementGrantToken': str(self.agreement_grant_token or '')
|
||||
}
|
||||
|
||||
# Use multipart/form-data as required by e-conomic API
|
||||
@ -1070,6 +1072,55 @@ class EconomicService:
|
||||
logger.error(f"❌ upload_voucher_attachment error: {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
|
||||
_economic_service_instance = None
|
||||
|
||||
@ -133,13 +133,20 @@ class EmailProcessorService:
|
||||
classification = (email_data.get('classification') or '').strip().lower()
|
||||
confidence = float(email_data.get('confidence_score') or 0.0)
|
||||
require_manual_approval = getattr(settings, 'EMAIL_REQUIRE_MANUAL_APPROVAL', True)
|
||||
has_helpdesk_hint = email_workflow_service.has_helpdesk_routing_hint(email_data)
|
||||
|
||||
if require_manual_approval:
|
||||
if has_helpdesk_hint:
|
||||
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')
|
||||
stats['awaiting_user_action'] = True
|
||||
return stats
|
||||
|
||||
if not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD:
|
||||
if (not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD) and not has_helpdesk_hint:
|
||||
await self._set_awaiting_user_action(email_id, reason='low_confidence')
|
||||
stats['awaiting_user_action'] = True
|
||||
return stats
|
||||
|
||||
@ -13,11 +13,12 @@ from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from typing import List, Dict, Optional, Tuple, Any
|
||||
from datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
import base64
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
# Try to import aiosmtplib, but don't fail if not available
|
||||
@ -57,6 +58,186 @@ class EmailService:
|
||||
'client_secret': settings.GRAPH_CLIENT_SECRET,
|
||||
'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]:
|
||||
"""
|
||||
@ -172,7 +353,7 @@ class EmailService:
|
||||
params = {
|
||||
'$top': limit,
|
||||
'$orderby': 'receivedDateTime desc',
|
||||
'$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId'
|
||||
'$select': 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,internetMessageId,conversationId,internetMessageHeaders'
|
||||
}
|
||||
|
||||
headers = {
|
||||
@ -398,10 +579,26 @@ class EmailService:
|
||||
received_date_str = msg.get('receivedDateTime', '')
|
||||
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 {
|
||||
'message_id': msg.get('internetMessageId', msg.get('id', '')),
|
||||
'in_reply_to': None,
|
||||
'email_references': None,
|
||||
'in_reply_to': in_reply_to,
|
||||
'email_references': references,
|
||||
'thread_key': conversation_id,
|
||||
'subject': msg.get('subject', ''),
|
||||
'sender_name': sender_name,
|
||||
'sender_email': sender_email,
|
||||
@ -509,6 +706,46 @@ class EmailService:
|
||||
else:
|
||||
# Just email address
|
||||
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:
|
||||
"""Parse email date header into datetime object"""
|
||||
@ -532,32 +769,62 @@ class EmailService:
|
||||
async def save_email(self, email_data: Dict) -> Optional[int]:
|
||||
"""Save email to database"""
|
||||
try:
|
||||
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')
|
||||
))
|
||||
thread_key = self._derive_thread_key(email_data)
|
||||
|
||||
try:
|
||||
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, thread_key,
|
||||
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_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'),
|
||||
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]}...")
|
||||
|
||||
@ -879,36 +1146,70 @@ class EmailService:
|
||||
return None
|
||||
|
||||
# Insert email
|
||||
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"
|
||||
))
|
||||
thread_key = self._derive_thread_key(email_data)
|
||||
try:
|
||||
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, thread_key,
|
||||
status, import_method, created_at
|
||||
)
|
||||
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"],
|
||||
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"),
|
||||
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:
|
||||
logger.error("❌ Failed to insert email - no ID returned")
|
||||
@ -958,14 +1259,37 @@ class EmailService:
|
||||
logger.warning(f"🔒 DRY RUN MODE: Would send email to {to_addresses} with subject '{subject}'")
|
||||
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
|
||||
if not HAS_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"
|
||||
|
||||
# Validate SMTP configuration
|
||||
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
|
||||
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"
|
||||
|
||||
try:
|
||||
@ -1013,8 +1337,10 @@ class EmailService:
|
||||
return True, f"Email sent to {len(to_addresses)} recipient(s)"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Failed to send email: {str(e)}"
|
||||
error_msg = f"❌ SMTP send error: {str(e)}"
|
||||
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
|
||||
|
||||
async def send_email_with_attachments(
|
||||
@ -1030,10 +1356,11 @@ class EmailService:
|
||||
references: Optional[str] = None,
|
||||
attachments: Optional[List[Dict]] = None,
|
||||
respect_dry_run: bool = True,
|
||||
) -> Tuple[bool, str, str]:
|
||||
"""Send email via SMTP with optional attachments and return generated Message-ID."""
|
||||
) -> Tuple[bool, str, str, Optional[str]]:
|
||||
"""Send email and return status, message, message-id, and optional provider thread key."""
|
||||
|
||||
generated_message_id = f"<{uuid4().hex}@bmchub.local>"
|
||||
provider_thread_key: Optional[str] = None
|
||||
|
||||
if respect_dry_run and settings.REMINDERS_DRY_RUN:
|
||||
logger.warning(
|
||||
@ -1041,15 +1368,53 @@ class EmailService:
|
||||
to_addresses,
|
||||
subject,
|
||||
)
|
||||
return True, "Dry run mode - email not actually sent", generated_message_id
|
||||
return True, "Dry run mode - email not actually sent", generated_message_id, provider_thread_key
|
||||
|
||||
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:
|
||||
logger.error("❌ aiosmtplib not installed - cannot send email. Install with: pip install aiosmtplib")
|
||||
return False, "aiosmtplib not installed", generated_message_id
|
||||
if graph_failure_message:
|
||||
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]):
|
||||
logger.error("❌ SMTP not configured - cannot send email")
|
||||
return False, "SMTP not configured", generated_message_id
|
||||
if graph_failure_message:
|
||||
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:
|
||||
msg = MIMEMultipart('mixed')
|
||||
@ -1114,9 +1479,11 @@ class EmailService:
|
||||
len(to_addresses),
|
||||
subject,
|
||||
)
|
||||
return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id
|
||||
return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Failed to send email with attachments: {str(e)}"
|
||||
error_msg = f"❌ SMTP send error (attachments): {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg, generated_message_id
|
||||
if graph_failure_message:
|
||||
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
|
||||
|
||||
@ -53,12 +53,21 @@ class EmailWorkflowService:
|
||||
return {'status': 'disabled', 'workflows_executed': 0}
|
||||
|
||||
email_id = email_data.get('id')
|
||||
classification = email_data.get('classification')
|
||||
classification = (email_data.get('classification') or '').strip().lower()
|
||||
confidence = email_data.get('confidence_score', 0.0)
|
||||
has_hint = self.has_helpdesk_routing_hint(email_data)
|
||||
|
||||
if not email_id or not classification:
|
||||
logger.warning(f"⚠️ Cannot execute workflows: missing email_id or classification")
|
||||
if not email_id:
|
||||
logger.warning("⚠️ Cannot execute workflows: missing email_id")
|
||||
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})")
|
||||
|
||||
@ -82,9 +91,14 @@ class EmailWorkflowService:
|
||||
logger.info("✅ Bankruptcy system workflow executed successfully")
|
||||
|
||||
# Special System Workflow: Helpdesk SAG routing
|
||||
# - If SAG-<id> is present in subject/header => update existing case
|
||||
# - If no SAG id and sender domain matches customer => create new case
|
||||
if classification not in self.HELPDESK_SKIP_CLASSIFICATIONS:
|
||||
# - If SAG/tråd-hint findes => forsøg altid routing til eksisterende sag
|
||||
# - Uden hints: brug klassifikationsgating som før
|
||||
should_try_helpdesk = (
|
||||
classification not in self.HELPDESK_SKIP_CLASSIFICATIONS
|
||||
or has_hint
|
||||
)
|
||||
|
||||
if should_try_helpdesk:
|
||||
helpdesk_result = await self._handle_helpdesk_sag_routing(email_data)
|
||||
if helpdesk_result:
|
||||
results['details'].append(helpdesk_result)
|
||||
@ -208,17 +222,48 @@ class EmailWorkflowService:
|
||||
domain = domain[4:]
|
||||
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]:
|
||||
candidates = [
|
||||
email_data.get('subject') 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:
|
||||
match = re.search(r'\bSAG-(\d+)\b', value, re.IGNORECASE)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
for pattern in sag_patterns:
|
||||
match = re.search(pattern, value, re.IGNORECASE)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
def _normalize_message_id(self, value: Optional[str]) -> Optional[str]:
|
||||
@ -244,6 +289,53 @@ class EmailWorkflowService:
|
||||
# De-duplicate while preserving order
|
||||
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]:
|
||||
thread_message_ids = self._extract_thread_message_ids(email_data)
|
||||
if not thread_message_ids:
|
||||
@ -297,15 +389,58 @@ class EmailWorkflowService:
|
||||
(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:
|
||||
email_id = email_data.get('id')
|
||||
sender = email_data.get('sender_email') or 'ukendt'
|
||||
subject = email_data.get('subject') or '(ingen emne)'
|
||||
received = email_data.get('received_date')
|
||||
received_str = received.isoformat() if hasattr(received, 'isoformat') else str(received or '')
|
||||
body_text = (email_data.get('body_text') or '').strip()
|
||||
body_text = self._strip_quoted_email_text((email_data.get('body_text') or '').strip())
|
||||
|
||||
email_meta_line = f"Email-ID: {email_id}\n" if email_id else ""
|
||||
|
||||
comment = (
|
||||
f"📧 Indgående email\n"
|
||||
f"{email_meta_line}"
|
||||
f"Fra: {sender}\n"
|
||||
f"Emne: {subject}\n"
|
||||
f"Modtaget: {received_str}\n\n"
|
||||
@ -351,12 +486,45 @@ class EmailWorkflowService:
|
||||
if not email_id:
|
||||
return None
|
||||
|
||||
sag_id = self._extract_sag_id(email_data)
|
||||
if not sag_id:
|
||||
sag_id = self._find_sag_id_from_thread_headers(email_data)
|
||||
if sag_id:
|
||||
derived_thread_key = self._derive_thread_key(email_data)
|
||||
sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key)
|
||||
sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data)
|
||||
sag_id_from_tag = self._extract_sag_id(email_data)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
if sag_id:
|
||||
case_rows = execute_query(
|
||||
@ -390,7 +558,8 @@ class EmailWorkflowService:
|
||||
'status': 'completed',
|
||||
'action': 'updated_existing_sag',
|
||||
'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
|
||||
@ -425,7 +594,8 @@ class EmailWorkflowService:
|
||||
'action': 'created_new_sag',
|
||||
'sag_id': case['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]:
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
Settings and User Management API Router
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import execute_query
|
||||
@ -15,6 +15,17 @@ import json
|
||||
logger = logging.getLogger(__name__)
|
||||
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
|
||||
class Setting(BaseModel):
|
||||
@ -58,6 +69,30 @@ class UserUpdate(BaseModel):
|
||||
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
|
||||
async def get_settings(category: Optional[str] = None):
|
||||
"""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"
|
||||
params = []
|
||||
|
||||
@ -130,6 +165,24 @@ async def get_setting(key: str):
|
||||
|
||||
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,))
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
|
||||
@ -146,6 +199,27 @@ async def update_setting(key: str, setting: SettingUpdate):
|
||||
RETURNING *
|
||||
"""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
@ -542,6 +616,10 @@ class PromptUpdate(BaseModel):
|
||||
class PromptTestRequest(BaseModel):
|
||||
test_input: 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"])
|
||||
@ -579,7 +657,7 @@ async def reset_ai_prompt(key: str):
|
||||
|
||||
|
||||
@router.post("/ai-prompts/{key}/test", tags=["Settings"])
|
||||
async def test_ai_prompt(key: str, payload: PromptTestRequest):
|
||||
async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Request):
|
||||
"""Run a quick AI test for a specific system prompt"""
|
||||
prompts = _get_prompts_with_overrides()
|
||||
if key not in prompts:
|
||||
@ -597,12 +675,37 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
|
||||
raise HTTPException(status_code=400, detail="Test input is empty")
|
||||
|
||||
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:
|
||||
model_normalized = (model or "").strip().lower()
|
||||
# qwen models are more reliable with /api/chat than /api/generate.
|
||||
use_chat_api = model_normalized.startswith("qwen")
|
||||
|
||||
timeout = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0)
|
||||
logger.info(
|
||||
"🧪 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:
|
||||
if use_chat_api:
|
||||
response = await client.post(
|
||||
@ -659,6 +762,7 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
|
||||
"endpoint": endpoint,
|
||||
"test_input": test_input,
|
||||
"ai_response": ai_response,
|
||||
"timeout_seconds": read_timeout_seconds,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
|
||||
@ -348,6 +348,23 @@
|
||||
|
||||
<!-- 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>
|
||||
<h5 class="fw-bold mb-1">Email Skabeloner</h5>
|
||||
@ -1699,7 +1716,7 @@ async function loadSettings() {
|
||||
|
||||
function displaySettingsByCategory() {
|
||||
const categories = {
|
||||
company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_address'],
|
||||
company: ['company_name', 'company_cvr', 'company_email', 'company_phone', 'company_website', 'company_address'],
|
||||
integrations: ['vtiger_enabled', 'vtiger_url', 'vtiger_username', 'economic_enabled', 'economic_app_secret', 'economic_agreement_token'],
|
||||
notifications: ['email_notifications'],
|
||||
system: ['system_timezone']
|
||||
@ -2942,6 +2959,28 @@ async function loadAIPrompts() {
|
||||
const container = document.getElementById('aiPromptsContent');
|
||||
|
||||
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">
|
||||
${Object.entries(prompts).map(([key, prompt], index) => `
|
||||
<div class="accordion-item">
|
||||
@ -3031,6 +3070,7 @@ async function loadAIPrompts() {
|
||||
`;
|
||||
|
||||
container.innerHTML = accordionHtml;
|
||||
renderAiPromptLog();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading AI prompts:', error);
|
||||
@ -3116,6 +3156,9 @@ async function testPrompt(key) {
|
||||
const editElement = document.getElementById(`edit_prompt_${key}`);
|
||||
|
||||
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;
|
||||
|
||||
btn.disabled = true;
|
||||
@ -3124,12 +3167,13 @@ async function testPrompt(key) {
|
||||
resultElement.className = 'alert alert-secondary m-3 py-2 px-3';
|
||||
resultElement.classList.remove('d-none');
|
||||
resultElement.textContent = 'Tester AI...';
|
||||
addAiPromptLogEntry('info', key, `Test startet (timeout=${timeoutSeconds}s)`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ai-prompts/${key}/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt_text: promptText })
|
||||
body: JSON.stringify({ prompt_text: promptText, timeout_seconds: timeoutSeconds })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -3146,16 +3190,54 @@ async function testPrompt(key) {
|
||||
`✅ AI svar modtaget (${result.latency_ms} ms)\n` +
|
||||
`Model: ${result.model}\n\n` +
|
||||
`${preview || '[Tomt svar]'}`;
|
||||
addAiPromptLogEntry('success', key, `OK (${result.latency_ms} ms, timeout=${result.timeout_seconds || timeoutSeconds}s)`);
|
||||
} catch (error) {
|
||||
console.error('Error testing AI prompt:', error);
|
||||
resultElement.className = 'alert alert-danger m-3 py-2 px-3';
|
||||
resultElement.textContent = `❌ ${error.message || 'Kunne ikke teste AI prompt'}`;
|
||||
addAiPromptLogEntry('error', key, error.message || 'Kunne ikke teste AI prompt');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
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) {
|
||||
@ -4432,6 +4514,45 @@ 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() {
|
||||
// Reset form
|
||||
document.getElementById('emailTemplateForm').reset();
|
||||
@ -4577,6 +4698,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Other loaders are called at bottom of file in existing script
|
||||
loadEmailTemplates();
|
||||
loadEmailTemplateCustomers();
|
||||
loadDefaultEmailSignatureTemplate();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -1086,7 +1086,7 @@
|
||||
</script>
|
||||
|
||||
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
||||
{% include "shared/frontend/quick_create_modal.html" %}
|
||||
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
|
||||
|
||||
<!-- Profile Modal -->
|
||||
<div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true">
|
||||
|
||||
@ -21,6 +21,8 @@ router = APIRouter()
|
||||
|
||||
ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
|
||||
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:
|
||||
@ -165,6 +167,15 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
billing_interval = payload.get("billing_interval")
|
||||
billing_day = payload.get("billing_day")
|
||||
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")
|
||||
line_items = payload.get("line_items") or []
|
||||
|
||||
@ -178,6 +189,12 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
raise HTTPException(status_code=400, detail="start_date is required")
|
||||
if not line_items:
|
||||
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(
|
||||
"SELECT id, customer_id FROM sag_sager WHERE id = %s",
|
||||
@ -202,18 +219,27 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
product_map = {}
|
||||
if product_ids:
|
||||
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_map = {row["id"]: row for row in (rows or [])}
|
||||
|
||||
cleaned_items = []
|
||||
total_price = 0
|
||||
blocked_reasons = []
|
||||
for idx, item in enumerate(line_items, start=1):
|
||||
product_id = item.get("product_id")
|
||||
description = (item.get("description") or "").strip()
|
||||
quantity = item.get("quantity")
|
||||
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)
|
||||
if not description and product:
|
||||
@ -228,21 +254,58 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
if unit_price is None or float(unit_price) < 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)
|
||||
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({
|
||||
"line_no": idx,
|
||||
"product_id": product_id,
|
||||
"asset_id": asset_id,
|
||||
"description": description,
|
||||
"quantity": quantity,
|
||||
"unit_price": unit_price,
|
||||
"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"]
|
||||
if 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
|
||||
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
@ -272,14 +335,30 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
customer_id,
|
||||
product_name,
|
||||
billing_interval,
|
||||
billing_direction,
|
||||
advance_months,
|
||||
first_full_period_start,
|
||||
billing_day,
|
||||
price,
|
||||
start_date,
|
||||
period_start,
|
||||
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,
|
||||
notes
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, 'draft', %s
|
||||
)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
@ -287,11 +366,23 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
sag["customer_id"],
|
||||
product_name,
|
||||
billing_interval,
|
||||
billing_direction,
|
||||
advance_months,
|
||||
first_full_period_start,
|
||||
billing_day,
|
||||
total_price,
|
||||
start_date,
|
||||
period_start,
|
||||
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,
|
||||
)
|
||||
)
|
||||
@ -304,20 +395,34 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
subscription_id,
|
||||
line_no,
|
||||
product_id,
|
||||
asset_id,
|
||||
description,
|
||||
quantity,
|
||||
unit_price,
|
||||
line_total
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
line_total,
|
||||
period_from,
|
||||
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"],
|
||||
item["line_no"],
|
||||
item["product_id"],
|
||||
item["asset_id"],
|
||||
item["description"],
|
||||
item["quantity"],
|
||||
item["unit_price"],
|
||||
item["line_total"],
|
||||
item["period_from"],
|
||||
item["period_to"],
|
||||
item["requires_serial_number"],
|
||||
item["serial_number"],
|
||||
item["billing_blocked"],
|
||||
item["billing_block_reason"],
|
||||
)
|
||||
)
|
||||
|
||||
@ -348,13 +453,25 @@ async def get_subscription(subscription_id: int):
|
||||
c.name AS customer_name,
|
||||
s.product_name,
|
||||
s.billing_interval,
|
||||
s.billing_direction,
|
||||
s.advance_months,
|
||||
s.first_full_period_start,
|
||||
s.billing_day,
|
||||
s.price,
|
||||
s.start_date,
|
||||
s.end_date,
|
||||
s.next_invoice_date,
|
||||
s.period_start,
|
||||
s.binding_months,
|
||||
s.binding_start_date,
|
||||
s.binding_end_date,
|
||||
s.binding_group_key,
|
||||
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.notes,
|
||||
s.cancelled_at,
|
||||
@ -377,11 +494,18 @@ async def get_subscription(subscription_id: int):
|
||||
i.id,
|
||||
i.line_no,
|
||||
i.product_id,
|
||||
i.asset_id,
|
||||
p.name AS product_name,
|
||||
i.description,
|
||||
i.quantity,
|
||||
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
|
||||
LEFT JOIN products p ON p.id = i.product_id
|
||||
WHERE i.subscription_id = %s
|
||||
@ -416,7 +540,11 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
||||
allowed_fields = {
|
||||
"product_name", "billing_interval", "billing_day", "price",
|
||||
"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 = []
|
||||
@ -471,13 +599,23 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
||||
"""
|
||||
INSERT INTO sag_subscription_items (
|
||||
subscription_id, line_no, description,
|
||||
quantity, unit_price, line_total, product_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
quantity, unit_price, line_total, product_id,
|
||||
asset_id, period_from, 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, idx, description,
|
||||
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"),
|
||||
)
|
||||
)
|
||||
|
||||
@ -537,10 +675,13 @@ async def list_subscriptions(status: str = Query("all")):
|
||||
c.name AS customer_name,
|
||||
s.product_name,
|
||||
s.billing_interval,
|
||||
s.billing_direction,
|
||||
s.billing_day,
|
||||
s.price,
|
||||
s.start_date,
|
||||
s.end_date,
|
||||
s.billing_blocked,
|
||||
s.invoice_merge_key,
|
||||
s.status,
|
||||
(SELECT COUNT(*) FROM sag_subscription_items WHERE subscription_id = s.id) as item_count
|
||||
FROM sag_subscriptions s
|
||||
@ -602,6 +743,475 @@ async def trigger_subscription_processing():
|
||||
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])
|
||||
async def import_simply_subscriptions_to_staging():
|
||||
"""Import recurring Simply CRM SalesOrders into staging (parking area)."""
|
||||
|
||||
195
design_forslag_kompakt.html
Normal file
195
design_forslag_kompakt.html
Normal file
@ -0,0 +1,195 @@
|
||||
<!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>
|
||||
338
design_forslag_sagsdetaljer.html
Normal file
338
design_forslag_sagsdetaljer.html
Normal file
@ -0,0 +1,338 @@
|
||||
<!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> • 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>
|
||||
290
design_forslag_top3_ny_side.html
Normal file
290
design_forslag_top3_ny_side.html
Normal file
@ -0,0 +1,290 @@
|
||||
<!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>
|
||||
197
forslag_kommentar.html
Normal file
197
forslag_kommentar.html
Normal file
@ -0,0 +1,197 @@
|
||||
<!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">⚙</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>
|
||||
26
main.py
26
main.py
@ -170,6 +170,20 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
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:
|
||||
from app.jobs.eset_sync import run_eset_sync
|
||||
|
||||
@ -281,13 +295,21 @@ async def auth_middleware(request: Request, call_next):
|
||||
request.state.user_id = None
|
||||
|
||||
if path.startswith("/api") and not payload.get("shadow_admin"):
|
||||
if not payload.get("sub"):
|
||||
sub_value = payload.get("sub")
|
||||
if not sub_value:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Invalid token"}
|
||||
)
|
||||
try:
|
||||
user_id = int(sub_value)
|
||||
except (TypeError, ValueError):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Invalid token"}
|
||||
)
|
||||
user_id = int(payload.get("sub"))
|
||||
|
||||
if _users_column_exists("is_2fa_enabled"):
|
||||
user = execute_query_single(
|
||||
|
||||
116
migrations/1002_asset_first_subscription_billing.sql
Normal file
116
migrations/1002_asset_first_subscription_billing.sql
Normal file
@ -0,0 +1,116 @@
|
||||
-- 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.';
|
||||
25
migrations/1003_ordre_sync_audit_and_idempotency.sql
Normal file
25
migrations/1003_ordre_sync_audit_and_idempotency.sql
Normal file
@ -0,0 +1,25 @@
|
||||
-- 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);
|
||||
40
migrations/142_email_thread_key.sql
Normal file
40
migrations/142_email_thread_key.sql
Normal file
@ -0,0 +1,40 @@
|
||||
-- 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';
|
||||
324
mine_3_anbefalinger.html
Normal file
324
mine_3_anbefalinger.html
Normal file
@ -0,0 +1,324 @@
|
||||
<!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 & 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;"><div class="ln-thread">
|
||||
{% for comment in comments %}
|
||||
<div class="ln-msg {% if comment.er_system_besked %}system-msg{% endif %}">
|
||||
<div class="ln-head">
|
||||
<div class="ln-avatar {% if comment.er_system_besked %}sys{% endif %}">
|
||||
{% if comment.er_system_besked %}<i class="bi bi-gear"></i>
|
||||
{% else %}{{ comment.forfatter[:2]|upper }}{% endif %}
|
||||
</div>
|
||||
<span class="ln-name">{{ comment.forfatter }}</span>
|
||||
<span class="ln-time">{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span>
|
||||
</div>
|
||||
<div class="ln-body">{{ comment.indhold|replace('\n','<br>')|safe }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="ln-input">...form...</div>
|
||||
</div></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 & 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;"><div class="jira-log">
|
||||
{% for comment in comments %}
|
||||
<div class="jira-row">
|
||||
<div class="jira-stripe {% if comment.er_system_besked %}sys
|
||||
{%- elif comment.er_intern %}tech{% endif %}"></div>
|
||||
<div class="jira-inner">
|
||||
<div class="jira-meta">
|
||||
<strong>{{ comment.forfatter }}</strong>
|
||||
{% if comment.er_intern %}<span class="badge bg-primary">BMC</span>{% endif %}
|
||||
· {{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}
|
||||
</div>
|
||||
<div class="jira-text">{{ comment.indhold|replace('\n','<br>')|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div></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 & 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;"><div class="inbox-feed">
|
||||
{% for comment in comments %}
|
||||
{% if comment.er_system_besked %}
|
||||
<div class="ib-msg sys" style="align-self:center;max-width:90%">
|
||||
{% elif comment.er_intern %}
|
||||
<div class="ib-msg right">
|
||||
{% else %}
|
||||
<div class="ib-msg left">
|
||||
{% endif %}
|
||||
<div class="ib-bubble">{{ comment.indhold|replace('\n','<br>')|safe }}</div>
|
||||
<div class="ib-meta">{{ comment.forfatter }} · {{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div></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>
|
||||
640
opgavebeskrivelse_mockup_3_forsog.html
Normal file
640
opgavebeskrivelse_mockup_3_forsog.html
Normal file
@ -0,0 +1,640 @@
|
||||
<!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 — 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">⚠</span>
|
||||
<span class="alert-msg">Alle printere paa lokationen er nede — 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>🕐</span>
|
||||
Sidst aendret af <strong>ct</strong> — 19/03-2026 08:02
|
||||
</p>
|
||||
</div>
|
||||
<p class="hint">Styrke: fø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> —
|
||||
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øjet 19/03-2026 08:15.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Styrke: ingen struktur-tvang — 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>
|
||||
500
samlet_forslag_oversigt.html
Normal file
500
samlet_forslag_oversigt.html
Normal file
@ -0,0 +1,500 @@
|
||||
<!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>
|
||||
1749
ssag_muck_3_forslag.html
Normal file
1749
ssag_muck_3_forslag.html
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user