feat: Add Technician Dashboard V1, V2, and V3 with enhanced UI and functionality

- Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview.
- Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities.
- Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management.
- Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions.
- Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths.
- Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management.
- Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql).
- Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql).
- Added a script to move time tracking section in detail view (move_time_section.py).
- Created a test script for subscription processing (test_subscription_processing.py).
This commit is contained in:
Christian 2026-02-17 08:29:05 +01:00
parent 891180f3f0
commit 3cddb71cec
58 changed files with 8170 additions and 326 deletions

View File

@ -137,6 +137,12 @@ class Settings(BaseSettings):
TIMETRACKING_ECONOMIC_LAYOUT: int = 19 # e-conomic invoice layout number (default: 19 = Danish standard) TIMETRACKING_ECONOMIC_LAYOUT: int = 19 # e-conomic invoice layout number (default: 19 = Danish standard)
TIMETRACKING_ECONOMIC_PRODUCT: str = "1000" # e-conomic product number for time entries (default: 1000) TIMETRACKING_ECONOMIC_PRODUCT: str = "1000" # e-conomic product number for time entries (default: 1000)
# Global Ordre Module Safety Flags
ORDRE_ECONOMIC_READ_ONLY: bool = True
ORDRE_ECONOMIC_DRY_RUN: bool = True
ORDRE_ECONOMIC_LAYOUT: int = 19
ORDRE_ECONOMIC_PRODUCT: str = "1000"
# Simply-CRM (Old vTiger On-Premise) # Simply-CRM (Old vTiger On-Premise)
OLD_VTIGER_URL: str = "" OLD_VTIGER_URL: str = ""
OLD_VTIGER_USERNAME: str = "" OLD_VTIGER_USERNAME: str = ""

View File

@ -136,7 +136,7 @@ async function loadStages() {
} }
async function loadCustomers() { async function loadCustomers() {
const response = await fetch('/api/v1/customers?limit=10000'); const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json(); const data = await response.json();
customers = Array.isArray(data) ? data : (data.customers || []); customers = Array.isArray(data) ? data : (data.customers || []);
@ -158,20 +158,20 @@ function renderBoard() {
return; return;
} }
board.innerHTML = stages.map(stage => { const renderCards = (items, stage) => {
const items = opportunities.filter(o => o.stage_id === stage.id); return items.map(o => `
const cards = items.map(o => `
<div class="pipeline-card"> <div class="pipeline-card">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<h6>${escapeHtml(o.title)}</h6> <h6>${escapeHtml(o.titel || '')}</h6>
<span class="badge" style="background:${stage.color}; color: white;">${o.probability || 0}%</span> <span class="badge" style="background:${(stage && stage.color) || '#6c757d'}; color: white;">${o.pipeline_probability || 0}%</span>
</div> </div>
<div class="pipeline-meta">${escapeHtml(o.customer_name || '-')} <div class="pipeline-meta">${escapeHtml(o.customer_name || '-')}
· ${formatCurrency(o.amount, o.currency)} · ${formatCurrency(o.pipeline_amount, 'DKK')}
</div> </div>
<div class="d-flex justify-content-between align-items-center mt-2"> <div class="d-flex justify-content-between align-items-center mt-2">
<select class="form-select form-select-sm" onchange="changeStage(${o.id}, this.value)"> <select class="form-select form-select-sm" onchange="changeStage(${o.id}, this.value)">
${stages.map(s => `<option value="${s.id}" ${s.id === o.stage_id ? 'selected' : ''}>${s.name}</option>`).join('')} <option value="">Ikke sat</option>
${stages.map(s => `<option value="${s.id}" ${Number(s.id) === Number(o.pipeline_stage_id) ? 'selected' : ''}>${s.name}</option>`).join('')}
</select> </select>
<button class="btn btn-sm btn-outline-primary ms-2" onclick="goToDetail(${o.id})"> <button class="btn btn-sm btn-outline-primary ms-2" onclick="goToDetail(${o.id})">
<i class="bi bi-arrow-right"></i> <i class="bi bi-arrow-right"></i>
@ -179,24 +179,52 @@ function renderBoard() {
</div> </div>
</div> </div>
`).join(''); `).join('');
};
return ` const unassignedItems = opportunities.filter(o => !o.pipeline_stage_id);
const columns = [];
if (unassignedItems.length > 0) {
columns.push(`
<div class="pipeline-column">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Ikke sat</strong>
<span class="small text-muted">${unassignedItems.length}</span>
</div>
${renderCards(unassignedItems, null)}
</div>
`);
}
stages.forEach(stage => {
const items = opportunities.filter(o => Number(o.pipeline_stage_id) === Number(stage.id));
if (!items.length) return;
columns.push(`
<div class="pipeline-column"> <div class="pipeline-column">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<strong>${stage.name}</strong> <strong>${stage.name}</strong>
<span class="small text-muted">${items.length}</span> <span class="small text-muted">${items.length}</span>
</div> </div>
${cards || '<div class="text-muted small">Ingen muligheder</div>'} ${renderCards(items, stage)}
</div> </div>
`; `);
}).join(''); });
if (!columns.length) {
board.innerHTML = '<div class="pipeline-column"><div class="text-muted small">Ingen muligheder i pipeline endnu</div></div>';
return;
}
board.innerHTML = columns.join('');
} }
async function changeStage(opportunityId, stageId) { async function changeStage(opportunityId, stageId) {
const response = await fetch(`/api/v1/opportunities/${opportunityId}/stage`, { const response = await fetch(`/api/v1/sag/${opportunityId}/pipeline`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_id: parseInt(stageId) }) body: JSON.stringify({ stage_id: stageId ? parseInt(stageId, 10) : null })
}); });
if (!response.ok) { if (!response.ok) {
@ -231,6 +259,7 @@ async function createOpportunity() {
const response = await fetch('/api/v1/opportunities', { const response = await fetch('/api/v1/opportunities', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
@ -240,12 +269,27 @@ async function createOpportunity() {
return; return;
} }
const createdCase = await response.json();
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
await loadOpportunities(); await loadOpportunities();
if (createdCase?.id && (payload.stage_id || payload.amount)) {
await fetch(`/api/v1/sag/${createdCase.id}/pipeline`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stage_id: payload.stage_id || null,
amount: payload.amount || null
})
});
await loadOpportunities();
}
} }
function goToDetail(id) { function goToDetail(id) {
window.location.href = `/opportunities/${id}`; window.location.href = `/sag/${id}`;
} }
function formatCurrency(value, currency) { function formatCurrency(value, currency) {

View File

@ -1,16 +1,106 @@
from fastapi import APIRouter, Request import logging
from fastapi import APIRouter, Request, Form
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from app.core.database import execute_query_single from app.core.database import execute_query, execute_query_single
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
logger = logging.getLogger(__name__)
_DISALLOWED_DASHBOARD_PATHS = {
"/dashboard/default",
"/dashboard/default/clear",
}
def _sanitize_dashboard_path(value: str) -> str:
if not value:
return ""
candidate = value.strip()
if not candidate.startswith("/"):
return ""
if candidate.startswith("/api"):
return ""
if candidate.startswith("//"):
return ""
if candidate in _DISALLOWED_DASHBOARD_PATHS:
return ""
return candidate
def _get_user_default_dashboard(user_id: int) -> str:
try:
row = execute_query_single(
"""
SELECT default_dashboard_path
FROM user_dashboard_preferences
WHERE user_id = %s
""",
(user_id,)
)
return _sanitize_dashboard_path((row or {}).get("default_dashboard_path", ""))
except Exception as exc:
if "user_dashboard_preferences" in str(exc):
logger.warning("⚠️ user_dashboard_preferences table not found; using fallback default dashboard")
return ""
raise
def _get_user_group_names(user_id: int):
rows = execute_query(
"""
SELECT LOWER(g.name) AS name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
""",
(user_id,)
)
return [r["name"] for r in (rows or []) if r.get("name")]
def _is_technician_group(group_names) -> bool:
return any(
"technician" in group or "teknik" in group
for group in (group_names or [])
)
def _is_sales_group(group_names) -> bool:
return any(
"sales" in group or "salg" in group
for group in (group_names or [])
)
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request): async def dashboard(request: Request):
""" """
Render the dashboard page Render the dashboard page
""" """
user_id = getattr(request.state, "user_id", None)
preferred_dashboard = ""
if user_id:
preferred_dashboard = _get_user_default_dashboard(int(user_id))
if not preferred_dashboard:
preferred_dashboard = _sanitize_dashboard_path(request.cookies.get("bmc_default_dashboard", ""))
if preferred_dashboard and preferred_dashboard != "/":
return RedirectResponse(url=preferred_dashboard, status_code=302)
if user_id:
group_names = _get_user_group_names(int(user_id))
if _is_technician_group(group_names):
return RedirectResponse(
url=f"/ticket/dashboard/technician/v1?technician_user_id={int(user_id)}",
status_code=302
)
if _is_sales_group(group_names):
return RedirectResponse(url="/dashboard/sales", status_code=302)
# Fetch count of unknown billing worklogs # Fetch count of unknown billing worklogs
unknown_query = """ unknown_query = """
SELECT COUNT(*) as count SELECT COUNT(*) as count
@ -60,3 +150,197 @@ async def dashboard(request: Request):
"bankruptcy_alerts": bankruptcy_alerts "bankruptcy_alerts": bankruptcy_alerts
}) })
@router.get("/dashboard/sales", response_class=HTMLResponse)
async def sales_dashboard(request: Request):
pipeline_stats_query = """
SELECT
COUNT(*) FILTER (WHERE s.status = 'åben') AS open_count,
COUNT(*) FILTER (WHERE s.status = 'lukket') AS closed_count,
COALESCE(SUM(COALESCE(s.pipeline_amount, 0)) FILTER (WHERE s.status = 'åben'), 0) AS open_value,
COALESCE(AVG(COALESCE(s.pipeline_probability, 0)) FILTER (WHERE s.status = 'åben'), 0) AS avg_probability
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND (
s.template_key = 'pipeline'
OR EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'case'
AND et.entity_id = s.id
AND LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
"""
recent_opportunities_query = """
SELECT
s.id,
s.titel,
s.status,
s.pipeline_amount,
s.pipeline_probability,
ps.name AS pipeline_stage,
s.deadline,
s.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
COALESCE(u.full_name, u.username, 'Ingen') AS owner_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
WHERE s.deleted_at IS NULL
AND (
s.template_key = 'pipeline'
OR EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'case'
AND et.entity_id = s.id
AND LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
ORDER BY s.created_at DESC
LIMIT 12
"""
due_soon_query = """
SELECT
s.id,
s.titel,
s.deadline,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
COALESCE(u.full_name, u.username, 'Ingen') AS owner_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
AND s.deadline IS NOT NULL
AND s.deadline BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '14 days')
AND (
s.template_key = 'pipeline'
OR EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'case'
AND et.entity_id = s.id
AND LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
ORDER BY s.deadline ASC
LIMIT 8
"""
pipeline_stats = execute_query_single(pipeline_stats_query) or {}
recent_opportunities = execute_query(recent_opportunities_query) or []
due_soon = execute_query(due_soon_query) or []
return templates.TemplateResponse(
"dashboard/frontend/sales.html",
{
"request": request,
"pipeline_stats": pipeline_stats,
"recent_opportunities": recent_opportunities,
"due_soon": due_soon,
"default_dashboard": _get_user_default_dashboard(getattr(request.state, "user_id", 0) or 0)
or request.cookies.get("bmc_default_dashboard", "")
}
)
@router.post("/dashboard/default")
async def set_default_dashboard(
request: Request,
dashboard_path: str = Form(...),
redirect_to: str = Form(default="/")
):
safe_path = _sanitize_dashboard_path(dashboard_path)
safe_redirect = _sanitize_dashboard_path(redirect_to) or "/"
user_id = getattr(request.state, "user_id", None)
response = RedirectResponse(url=safe_redirect, status_code=303)
if safe_path:
if user_id:
try:
execute_query(
"""
INSERT INTO user_dashboard_preferences (user_id, default_dashboard_path, updated_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (user_id)
DO UPDATE SET
default_dashboard_path = EXCLUDED.default_dashboard_path,
updated_at = CURRENT_TIMESTAMP
""",
(int(user_id), safe_path)
)
except Exception as exc:
if "user_dashboard_preferences" in str(exc):
logger.warning("⚠️ Could not persist dashboard preference in DB (table missing); cookie fallback still active")
else:
raise
response.set_cookie(
key="bmc_default_dashboard",
value=safe_path,
httponly=True,
samesite="Lax"
)
return response
@router.get("/dashboard/default")
async def set_default_dashboard_get_fallback():
return RedirectResponse(url="/settings#system", status_code=303)
@router.post("/dashboard/default/clear")
async def clear_default_dashboard(
request: Request,
redirect_to: str = Form(default="/")
):
safe_redirect = _sanitize_dashboard_path(redirect_to) or "/"
user_id = getattr(request.state, "user_id", None)
if user_id:
try:
execute_query(
"DELETE FROM user_dashboard_preferences WHERE user_id = %s",
(int(user_id),)
)
except Exception as exc:
if "user_dashboard_preferences" in str(exc):
logger.warning("⚠️ Could not clear DB dashboard preference (table missing); cookie fallback still active")
else:
raise
response = RedirectResponse(url=safe_redirect, status_code=303)
response.delete_cookie("bmc_default_dashboard")
return response
@router.get("/dashboard/default/clear")
async def clear_default_dashboard_get_fallback():
return RedirectResponse(url="/settings#system", status_code=303)

View File

@ -0,0 +1,115 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Salg Dashboard - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">💼 Salg Dashboard</h1>
<p class="text-muted mb-0">Pipeline-overblik og opfølgning for salgsteamet</p>
</div>
<div class="d-flex gap-2">
<a href="/opportunities" class="btn btn-outline-primary btn-sm">Åbn Opportunities</a>
<a href="/" class="btn btn-outline-secondary btn-sm">Til hoveddashboard</a>
</div>
</div>
<div class="alert alert-info border-0 shadow-sm mb-4" role="alert">
Vælg standard-dashboard under <strong>Indstillinger → System</strong>. Dashboard åbnes altid fra roden <code>/</code>.
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Åbne opportunities</div>
<div class="h3 mb-0">{{ pipeline_stats.open_count or 0 }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Lukkede opportunities</div>
<div class="h3 mb-0">{{ pipeline_stats.closed_count or 0 }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Åben pipeline værdi</div>
<div class="h4 mb-0">{{ "{:,.0f}".format((pipeline_stats.open_value or 0)|float).replace(',', '.') }} kr.</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Gns. sandsynlighed</div>
<div class="h3 mb-0">{{ "%.0f"|format((pipeline_stats.avg_probability or 0)|float) }}%</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0"><h5 class="mb-0">Seneste opportunities</h5></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Stage</th>
<th>Beløb</th>
<th>Sandsynlighed</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in recent_opportunities %}
<tr>
<td>#{{ item.id }}</td>
<td>{{ item.titel }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.pipeline_stage or '-' }}</td>
<td>{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr.</td>
<td>{{ "%.0f"|format((item.pipeline_probability or 0)|float) }}%</td>
<td><a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary">Åbn</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">Ingen opportunities fundet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0"><h5 class="mb-0">Deadline næste 14 dage</h5></div>
<div class="card-body">
{% for item in due_soon %}
<div class="border rounded p-2 mb-2">
<div class="fw-semibold">{{ item.titel }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.owner_name }}</div>
<div class="small text-muted">Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</div>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen deadlines de næste 14 dage.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,216 @@
"""
Subscription Invoice Processing Job
Processes active subscriptions when next_invoice_date is reached
Creates ordre drafts and advances subscription periods
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__)
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
"""
try:
logger.info("💰 Processing subscription invoices...")
# Find subscriptions due for invoicing
query = """
SELECT
s.id,
s.sag_id,
sg.titel AS sag_name,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.price,
s.next_invoice_date,
s.period_start,
COALESCE(
(
SELECT json_agg(
json_build_object(
'id', si.id,
'description', si.description,
'quantity', si.quantity,
'unit_price', si.unit_price,
'line_total', si.line_total,
'product_id', si.product_id
) ORDER BY si.id
)
FROM sag_subscription_items si
WHERE si.subscription_id = s.id
),
'[]'::json
) as line_items
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.status = 'active'
AND s.next_invoice_date <= CURRENT_DATE
ORDER BY s.next_invoice_date, s.id
"""
subscriptions = execute_query(query)
if not subscriptions:
logger.info("✅ No subscriptions due for invoicing")
return
logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process")
processed_count = 0
error_count = 0
for sub in subscriptions:
try:
await _process_single_subscription(sub)
processed_count += 1
except Exception as e:
logger.error(f"❌ Failed to process subscription {sub['id']}: {e}", exc_info=True)
error_count += 1
logger.info(f"✅ Subscription processing complete: {processed_count} processed, {error_count} errors")
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']}")
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
insert_query = """
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
export_status_json,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
RETURNING id
"""
cursor.execute(insert_query, (
title,
sub['customer_id'],
json.dumps(ordre_lines, ensure_ascii=False),
notes,
1, # Default layout
None, # System-created
json.dumps({"source": "subscription", "subscription_id": subscription_id}, 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))
conn.commit()
logger.info(f"✅ Advanced subscription #{subscription_id}: next invoice {new_next_invoice_date}")
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
conn.close()
def _calculate_next_period_start(current_date, billing_interval: str) -> date:
"""Calculate next period start date based on billing interval"""
# Parse current_date if it's a string
if isinstance(current_date, str):
current_date = datetime.strptime(current_date, '%Y-%m-%d').date()
elif isinstance(current_date, datetime):
current_date = current_date.date()
# Calculate delta based on interval
if billing_interval == 'daily':
delta = relativedelta(days=1)
elif billing_interval == 'biweekly':
delta = relativedelta(weeks=2)
elif billing_interval == 'monthly':
delta = relativedelta(months=1)
elif billing_interval == 'quarterly':
delta = relativedelta(months=3)
elif billing_interval == 'yearly':
delta = relativedelta(years=1)
else:
# Default to monthly if unknown
logger.warning(f"Unknown billing interval '{billing_interval}', defaulting to monthly")
delta = relativedelta(months=1)
next_date = current_date + delta
return next_date

View File

@ -759,17 +759,21 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">Vælg kontaktperson</label> <label class="form-label fw-bold">Vælg kontaktperson</label>
<select id="ownerContactSelect" name="owner_contact_id" class="form-select" required> <input
type="text"
id="ownerContactSearch"
class="form-control mb-2"
placeholder="🔍 Søg kontaktperson..."
autocomplete="off"
>
<select
id="ownerContactSelect"
name="owner_contact_id"
class="form-select"
data-current-owner-contact-id="{{ owner_contact_ns.contact.contact_id if owner_contact_ns.contact else '' }}"
required
>
<option value="">-- Vælg kontaktperson --</option> <option value="">-- Vælg kontaktperson --</option>
{% for contact in owner_contacts %}
<option
value="{{ contact.id }}"
data-customer-id="{{ contact.customer_id }}"
{% if owner_contact_ns.contact and owner_contact_ns.contact.contact_id == contact.id %}selected{% endif %}
>
{{ contact.first_name }} {{ contact.last_name }}{% if contact.email %} ({{ contact.email }}){% endif %}
</option>
{% endfor %}
</select> </select>
<div id="ownerContactHelp" class="form-text">Viser kun kontakter for valgt virksomhed.</div> <div id="ownerContactHelp" class="form-text">Viser kun kontakter for valgt virksomhed.</div>
</div> </div>
@ -1038,9 +1042,86 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch'); const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect'); const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
const ownerContactSearch = document.getElementById('ownerContactSearch');
const ownerContactSelect = document.getElementById('ownerContactSelect'); const ownerContactSelect = document.getElementById('ownerContactSelect');
const ownerCustomerHelp = document.getElementById('ownerCustomerHelp'); const ownerCustomerHelp = document.getElementById('ownerCustomerHelp');
const ownerContactHelp = document.getElementById('ownerContactHelp'); const ownerContactHelp = document.getElementById('ownerContactHelp');
let ownerContactsCache = [];
const initialOwnerCustomerOptions = ownerCustomerSelect
? Array.from(ownerCustomerSelect.options).map(option => ({
value: option.value,
label: option.textContent,
selected: option.selected
}))
: [];
let ownerCustomerSearchTimeout;
function renderOwnerCustomerOptions(items, keepValue = null) {
if (!ownerCustomerSelect) {
return;
}
ownerCustomerSelect.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = '-- Vælg kunde --';
ownerCustomerSelect.appendChild(placeholder);
(items || []).forEach(item => {
const option = document.createElement('option');
option.value = String(item.id);
option.textContent = item.name || item.navn || '';
ownerCustomerSelect.appendChild(option);
});
if (keepValue) {
ownerCustomerSelect.value = String(keepValue);
}
}
function restoreInitialOwnerCustomers() {
if (!ownerCustomerSelect) {
return;
}
ownerCustomerSelect.innerHTML = '';
initialOwnerCustomerOptions.forEach(item => {
const option = document.createElement('option');
option.value = item.value;
option.textContent = item.label;
if (item.selected) {
option.selected = true;
}
ownerCustomerSelect.appendChild(option);
});
}
async function searchOwnerCustomersRemote(query) {
if (!ownerCustomerSelect) {
return;
}
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search request failed');
}
const rows = await response.json();
const currentValue = ownerCustomerSelect.value;
renderOwnerCustomerOptions(rows || [], currentValue);
if (ownerCustomerHelp) {
ownerCustomerHelp.textContent = rows && rows.length
? `Viser ${rows.length} virksomhed(er).`
: 'Ingen virksomheder matcher søgningen.';
}
} catch (error) {
if (ownerCustomerHelp) {
ownerCustomerHelp.textContent = 'Søgning fejlede. Prøv igen.';
}
}
}
function filterOwnerCustomers() { function filterOwnerCustomers() {
if (!ownerCustomerSearch || !ownerCustomerSelect) { if (!ownerCustomerSearch || !ownerCustomerSelect) {
@ -1048,6 +1129,16 @@
} }
const filter = ownerCustomerSearch.value.toLowerCase().trim(); const filter = ownerCustomerSearch.value.toLowerCase().trim();
if (filter.length >= 2) {
clearTimeout(ownerCustomerSearchTimeout);
ownerCustomerSearchTimeout = setTimeout(() => {
searchOwnerCustomersRemote(filter);
}, 250);
return;
}
restoreInitialOwnerCustomers();
const options = Array.from(ownerCustomerSelect.options); const options = Array.from(ownerCustomerSelect.options);
let visibleCount = 0; let visibleCount = 0;
@ -1079,56 +1170,104 @@
} }
} }
function filterOwnerContacts() { async function loadOwnerContactsForCustomer(customerId) {
if (!ownerCustomerSelect || !ownerContactSelect) { if (!ownerContactSelect) {
return; return;
} }
const selectedCustomerId = ownerCustomerSelect.value; ownerContactsCache = [];
const options = Array.from(ownerContactSelect.options); ownerContactSelect.innerHTML = '<option value="">-- Vælg kontaktperson --</option>';
let visibleCount = 0;
options.forEach((option, index) => { if (!customerId) {
if (index === 0) { ownerContactSelect.disabled = true;
option.hidden = false; if (ownerContactHelp) {
return; ownerContactHelp.textContent = 'Vælg først virksomhed.';
} }
return;
const optionCustomerId = option.getAttribute('data-customer-id');
const isVisible = selectedCustomerId && optionCustomerId === selectedCustomerId;
option.hidden = !isVisible;
if (isVisible) {
visibleCount += 1;
}
});
const selectedOption = ownerContactSelect.selectedOptions[0];
if (!selectedOption || selectedOption.hidden) {
ownerContactSelect.value = '';
} }
ownerContactSelect.disabled = !selectedCustomerId || visibleCount === 0; try {
const response = await fetch(`/api/v1/customers/${encodeURIComponent(customerId)}/contacts`);
if (!response.ok) {
throw new Error('Failed to load contacts');
}
const rows = await response.json();
ownerContactsCache = rows || [];
ownerContactSelect.disabled = !ownerContactsCache.length;
filterOwnerContactsSearch();
if (ownerContactHelp) {
ownerContactHelp.textContent = ownerContactsCache.length
? `Viser ${ownerContactsCache.length} kontaktperson(er) for valgt virksomhed.`
: 'Ingen kontaktpersoner fundet for valgt virksomhed.';
}
} catch (error) {
ownerContactSelect.disabled = true;
ownerContactsCache = [];
if (ownerContactHelp) {
ownerContactHelp.textContent = 'Kunne ikke hente kontaktpersoner. Prøv igen.';
}
}
}
function filterOwnerContactsSearch() {
if (!ownerContactSelect || !ownerContactSearch) {
return;
}
const filter = ownerContactSearch.value.toLowerCase().trim();
const preferredContactId = ownerContactSelect.getAttribute('data-current-owner-contact-id');
const currentValue = ownerContactSelect.value;
const filteredContacts = ownerContactsCache.filter(contact => {
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim().toLowerCase();
const email = (contact.email || '').toLowerCase();
const phone = (contact.phone || '').toLowerCase();
return !filter || fullName.includes(filter) || email.includes(filter) || phone.includes(filter);
});
ownerContactSelect.innerHTML = '<option value="">-- Vælg kontaktperson --</option>';
filteredContacts.forEach(contact => {
const option = document.createElement('option');
option.value = String(contact.id);
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
option.textContent = contact.email ? `${fullName} (${contact.email})` : fullName;
if (
(currentValue && String(contact.id) === String(currentValue)) ||
(!currentValue && preferredContactId && String(contact.id) === String(preferredContactId))
) {
option.selected = true;
}
ownerContactSelect.appendChild(option);
});
if (ownerContactHelp) { if (ownerContactHelp) {
if (!selectedCustomerId) { if (ownerContactSelect.disabled) {
ownerContactHelp.textContent = 'Vælg først virksomhed.'; ownerContactHelp.textContent = 'Vælg først virksomhed.';
} else if (visibleCount === 0) { } else if (filteredContacts.length === 0) {
ownerContactHelp.textContent = 'Ingen kontaktpersoner fundet for valgt virksomhed.'; ownerContactHelp.textContent = 'Ingen kontaktpersoner matcher søgningen.';
} else { } else {
ownerContactHelp.textContent = 'Viser kun kontakter for valgt virksomhed.'; ownerContactHelp.textContent = `Viser ${filteredContacts.length} kontaktperson(er) for valgt virksomhed.`;
} }
} }
} }
if (ownerCustomerSelect && ownerContactSelect) { if (ownerCustomerSelect && ownerContactSelect) {
ownerCustomerSelect.addEventListener('change', filterOwnerContacts); ownerCustomerSelect.addEventListener('change', function() {
ownerContactSelect.setAttribute('data-current-owner-contact-id', '');
loadOwnerContactsForCustomer(ownerCustomerSelect.value);
});
if (ownerCustomerSearch) { if (ownerCustomerSearch) {
ownerCustomerSearch.addEventListener('input', function() { ownerCustomerSearch.addEventListener('input', function() {
filterOwnerCustomers(); filterOwnerCustomers();
filterOwnerContacts();
}); });
} }
if (ownerContactSearch) {
ownerContactSearch.addEventListener('input', filterOwnerContactsSearch);
}
filterOwnerCustomers(); filterOwnerCustomers();
filterOwnerContacts(); loadOwnerContactsForCustomer(ownerCustomerSelect.value);
} }
if (window.renderEntityTags) { if (window.renderEntityTags) {

View File

@ -147,7 +147,7 @@ def list_locations_view(
is_active_bool = False is_active_bool = False
# Query locations directly from database # Query locations directly from database
where_clauses = [] where_clauses = ["deleted_at IS NULL"]
query_params = [] query_params = []
if location_type: if location_type:
@ -272,7 +272,7 @@ def create_location_view():
parent_locations = execute_query(""" parent_locations = execute_query("""
SELECT id, name, location_type SELECT id, name, location_type
FROM locations_locations FROM locations_locations
WHERE is_active = true WHERE deleted_at IS NULL AND is_active = true
ORDER BY name ORDER BY name
LIMIT 1000 LIMIT 1000
""") """)
@ -322,12 +322,12 @@ def location_wizard_view():
logger.info("🧭 Rendering location wizard") logger.info("🧭 Rendering location wizard")
parent_locations = execute_query(""" parent_locations = execute_query("""
SELECT id, name, location_type SELECT id, name, location_type
FROM locations_locations FROM locations_locations
WHERE is_active = true WHERE deleted_at IS NULL AND is_active = true
ORDER BY name ORDER BY name
LIMIT 1000 LIMIT 1000
""") """)
customers = execute_query(""" customers = execute_query("""
SELECT id, name, email, phone SELECT id, name, email, phone

View File

@ -508,12 +508,18 @@ document.addEventListener('DOMContentLoaded', function() {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
.then(response => { .then(async response => {
if (response.ok) { if (response.ok) {
deleteModal.hide(); deleteModal.hide();
setTimeout(() => location.reload(), 300); setTimeout(() => location.reload(), 300);
} else { } else {
alert('Fejl ved sletning af lokation'); const err = await response.json().catch(() => ({}));
if (response.status === 404) {
alert(err.detail || 'Lokationen er allerede slettet. Siden opdateres.');
setTimeout(() => location.reload(), 300);
return;
}
alert(err.detail || 'Fejl ved sletning af lokation');
} }
}) })
.catch(error => { .catch(error => {

View File

@ -0,0 +1,197 @@
import json
import logging
from datetime import date
from typing import Any, Dict, List, Optional
import aiohttp
from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
class OrdreEconomicExportService:
"""e-conomic export service for global ordre page."""
def __init__(self):
self.api_url = settings.ECONOMIC_API_URL
self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN
self.agreement_grant_token = settings.ECONOMIC_AGREEMENT_GRANT_TOKEN
self.read_only = settings.ORDRE_ECONOMIC_READ_ONLY
self.dry_run = settings.ORDRE_ECONOMIC_DRY_RUN
self.default_layout = settings.ORDRE_ECONOMIC_LAYOUT
self.default_product = settings.ORDRE_ECONOMIC_PRODUCT
if self.read_only:
logger.warning("🔒 ORDRE e-conomic READ-ONLY mode: Enabled")
if self.dry_run:
logger.warning("🏃 ORDRE e-conomic DRY-RUN mode: Enabled")
if not self.read_only:
logger.error("⚠️ WARNING: ORDRE e-conomic READ-ONLY disabled!")
def _headers(self) -> Dict[str, str]:
return {
"X-AppSecretToken": self.app_secret_token,
"X-AgreementGrantToken": self.agreement_grant_token,
"Content-Type": "application/json",
}
def _check_write_permission(self, operation: str) -> bool:
if self.read_only:
logger.error("🚫 BLOCKED: %s - READ_ONLY mode enabled", operation)
return False
if self.dry_run:
logger.warning("🏃 DRY-RUN: %s - Would execute but not sending", operation)
return False
logger.warning("⚠️ EXECUTING WRITE: %s", operation)
return True
async def export_order(
self,
customer_id: int,
lines: List[Dict[str, Any]],
notes: Optional[str] = None,
layout_number: Optional[int] = None,
user_id: Optional[int] = None,
) -> Dict[str, Any]:
customer = execute_query_single(
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
if not customer.get("economic_customer_number"):
raise HTTPException(
status_code=400,
detail="Kunden mangler e-conomic kundenummer i Customers modulet",
)
selected_lines = [line for line in lines if bool(line.get("selected", True))]
if not selected_lines:
raise HTTPException(status_code=400, detail="Ingen linjer valgt til eksport")
product_ids = [int(line["product_id"]) for line in selected_lines if line.get("product_id")]
product_map: Dict[int, str] = {}
if product_ids:
product_rows = execute_query(
"SELECT id, sku_internal FROM products WHERE id = ANY(%s)",
(product_ids,),
) or []
product_map = {
int(row["id"]): str(row["sku_internal"])
for row in product_rows
if row.get("sku_internal")
}
economic_lines: List[Dict[str, Any]] = []
for line in selected_lines:
try:
quantity = float(line.get("quantity") or 0)
unit_price = float(line.get("unit_price") or 0)
discount = float(line.get("discount_percentage") or 0)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Ugyldige tal i linjer")
if quantity <= 0:
raise HTTPException(status_code=400, detail="Linje quantity skal være > 0")
if unit_price < 0:
raise HTTPException(status_code=400, detail="Linje unit_price skal være >= 0")
line_payload: Dict[str, Any] = {
"description": line.get("description") or "Ordrelinje",
"quantity": quantity,
"unitNetPrice": unit_price,
}
product_id = line.get("product_id")
product_number = None
if product_id is not None:
try:
product_number = product_map.get(int(product_id))
except (TypeError, ValueError):
product_number = None
if not product_number:
product_number = self.default_product
if product_number:
line_payload["product"] = {"productNumber": str(product_number)}
if discount > 0:
line_payload["discountPercentage"] = discount
economic_lines.append(line_payload)
payload: Dict[str, Any] = {
"date": date.today().isoformat(),
"currency": "DKK",
"customer": {
"customerNumber": int(customer["economic_customer_number"]),
},
"layout": {
"layoutNumber": int(layout_number or self.default_layout),
},
"lines": economic_lines,
}
if notes:
payload["notes"] = {"textLine1": str(notes)[:250]}
operation = f"Export ordre for customer {customer_id} to e-conomic"
if not self._check_write_permission(operation):
return {
"success": True,
"dry_run": True,
"message": "DRY-RUN: Export blocked by safety flags",
"details": {
"customer_id": customer_id,
"customer_name": customer.get("name"),
"selected_line_count": len(selected_lines),
"read_only": self.read_only,
"dry_run": self.dry_run,
"user_id": user_id,
"payload": payload,
},
}
logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str))
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.api_url}/orders/drafts",
headers=self._headers(),
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as response:
response_text = await response.text()
if response.status not in [200, 201]:
logger.error("❌ e-conomic export failed (%s): %s", response.status, response_text)
raise HTTPException(
status_code=502,
detail=f"e-conomic export fejlede ({response.status})",
)
export_result = await response.json(content_type=None)
draft_number = export_result.get("draftOrderNumber") or export_result.get("orderNumber")
logger.info("✅ Ordre exported to e-conomic draft %s", draft_number)
return {
"success": True,
"dry_run": False,
"message": f"Ordre eksporteret til e-conomic draft {draft_number}",
"economic_draft_id": draft_number,
"details": {
"customer_id": customer_id,
"customer_name": customer.get("name"),
"selected_line_count": len(selected_lines),
"user_id": user_id,
"economic_response": export_result,
},
}
ordre_economic_export_service = OrdreEconomicExportService()

View File

@ -0,0 +1,280 @@
import logging
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.modules.orders.backend.economic_export import ordre_economic_export_service
from app.modules.orders.backend.service import aggregate_order_lines
logger = logging.getLogger(__name__)
router = APIRouter()
class OrdreLineInput(BaseModel):
line_key: str
source_type: str
source_id: int
description: str
quantity: float = Field(gt=0)
unit_price: float = Field(ge=0)
discount_percentage: float = Field(default=0, ge=0, le=100)
unit: Optional[str] = None
product_id: Optional[int] = None
selected: bool = True
class OrdreExportRequest(BaseModel):
customer_id: int
lines: List[OrdreLineInput]
notes: Optional[str] = None
layout_number: Optional[int] = None
draft_id: Optional[int] = None
class OrdreDraftUpsertRequest(BaseModel):
title: str = Field(min_length=1, max_length=120)
customer_id: Optional[int] = None
lines: List[Dict[str, Any]] = Field(default_factory=list)
notes: Optional[str] = None
layout_number: Optional[int] = None
def _safe_json_field(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return value
def _get_user_id_from_request(http_request: Request) -> Optional[int]:
state_user_id = getattr(http_request.state, "user_id", None)
if state_user_id is None:
return None
try:
return int(state_user_id)
except (TypeError, ValueError):
return None
@router.get("/ordre/aggregate")
async def get_ordre_aggregate(
customer_id: Optional[int] = Query(None),
sag_id: Optional[int] = Query(None),
q: Optional[str] = Query(None),
):
"""Aggregate global ordre lines from subscriptions, hardware and sales."""
try:
return aggregate_order_lines(customer_id=customer_id, sag_id=sag_id, q=q)
except Exception as e:
logger.error("❌ Error aggregating ordre lines: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to aggregate ordre lines")
@router.get("/ordre/config")
async def get_ordre_config():
"""Return ordre module safety config for frontend banner."""
return {
"economic_read_only": ordre_economic_export_service.read_only,
"economic_dry_run": ordre_economic_export_service.dry_run,
"default_layout": ordre_economic_export_service.default_layout,
"default_product": ordre_economic_export_service.default_product,
}
@router.post("/ordre/export")
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)
line_payload = [line.model_dump() for line in request.lines]
export_result = await ordre_economic_export_service.export_order(
customer_id=request.customer_id,
lines=line_payload,
notes=request.notes,
layout_number=request.layout_number,
user_id=user_id,
)
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
export_result["exported_line_keys"] = exported_line_keys
if request.draft_id:
from app.core.database import execute_query_single, execute_query
existing = execute_query_single("SELECT export_status_json FROM ordre_drafts WHERE id = %s", (request.draft_id,))
existing_status = _safe_json_field((existing or {}).get("export_status_json")) or {}
if not isinstance(existing_status, dict):
existing_status = {}
line_status = "dry-run" if export_result.get("dry_run") else "exported"
for line_key in exported_line_keys:
existing_status[line_key] = {
"status": line_status,
"timestamp": datetime.utcnow().isoformat(),
}
execute_query(
"""
UPDATE ordre_drafts
SET export_status_json = %s::jsonb,
last_exported_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(json.dumps(existing_status, ensure_ascii=False), request.draft_id),
)
return export_result
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to export ordre")
@router.get("/ordre/drafts")
async def list_ordre_drafts(
http_request: Request,
limit: int = Query(25, ge=1, le=100)
):
"""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
FROM ordre_drafts
ORDER BY updated_at DESC, id DESC
LIMIT %s
"""
params = (limit,)
from app.core.database import execute_query
return execute_query(query, params) or []
except Exception as e:
logger.error("❌ Error listing ordre drafts: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to list ordre drafts")
@router.get("/ordre/drafts/{draft_id}")
async def get_ordre_draft(draft_id: int, http_request: Request):
"""Get single ordre draft with lines payload (no user filtering)."""
try:
query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
params = (draft_id,)
from app.core.database import execute_query_single
draft = execute_query_single(query, params)
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
draft["lines_json"] = _safe_json_field(draft.get("lines_json")) or []
draft["export_status_json"] = _safe_json_field(draft.get("export_status_json")) or {}
return draft
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error fetching ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to fetch ordre draft")
@router.post("/ordre/drafts")
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
"""Create a new ordre draft."""
try:
user_id = _get_user_id_from_request(http_request)
from app.core.database import execute_query
query = """
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
export_status_json,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
RETURNING *
"""
params = (
request.title,
request.customer_id,
json.dumps(request.lines, ensure_ascii=False),
request.notes,
request.layout_number,
user_id,
json.dumps({}, ensure_ascii=False),
)
result = execute_query(query, params)
return result[0]
except Exception as e:
logger.error("❌ Error creating ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create ordre draft")
@router.patch("/ordre/drafts/{draft_id}")
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
"""Update existing ordre draft."""
try:
from app.core.database import execute_query
query = """
UPDATE ordre_drafts
SET title = %s,
customer_id = %s,
lines_json = %s::jsonb,
notes = %s,
layout_number = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
params = (
request.title,
request.customer_id,
json.dumps(request.lines, ensure_ascii=False),
request.notes,
request.layout_number,
draft_id,
)
result = execute_query(query, params)
if not result:
raise HTTPException(status_code=404, detail="Draft not found")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to update ordre draft")
@router.delete("/ordre/drafts/{draft_id}")
async def delete_ordre_draft(draft_id: int, http_request: Request):
"""Delete ordre draft."""
try:
from app.core.database import execute_query
query = "DELETE FROM ordre_drafts WHERE id = %s RETURNING id"
params = (draft_id,)
result = execute_query(query, params)
if not result:
raise HTTPException(status_code=404, detail="Draft not found")
return {"status": "deleted", "id": draft_id}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to delete ordre draft")

View File

@ -0,0 +1,286 @@
import logging
from typing import Any, Dict, List, Optional
from app.core.database import execute_query
logger = logging.getLogger(__name__)
def _to_float(value: Any, default: float = 0.0) -> float:
if value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _apply_common_filters(
base_query: str,
params: List[Any],
customer_id: Optional[int],
sag_id: Optional[int],
q: Optional[str],
customer_alias: str,
sag_alias: str,
description_alias: str,
) -> tuple[str, List[Any]]:
query = base_query
if customer_id:
query += f" AND {customer_alias}.id = %s"
params.append(customer_id)
if sag_id:
query += f" AND {sag_alias}.id = %s"
params.append(sag_id)
if q:
like = f"%{q.lower()}%"
query += (
f" AND (LOWER({description_alias}) LIKE %s"
f" OR LOWER(COALESCE({sag_alias}.titel, '')) LIKE %s"
f" OR LOWER(COALESCE({customer_alias}.name, '')) LIKE %s)"
)
params.extend([like, like, like])
return query, params
def _fetch_sales_lines(customer_id: Optional[int], sag_id: Optional[int], q: Optional[str]) -> List[Dict[str, Any]]:
query = """
SELECT
si.id,
si.sag_id,
s.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
si.description,
si.quantity,
si.unit,
si.unit_price,
si.amount,
si.currency,
si.status,
si.line_date,
si.product_id
FROM sag_salgsvarer si
JOIN sag_sager s ON s.id = si.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND LOWER(si.type) = 'sale'
AND LOWER(si.status) != 'cancelled'
"""
params: List[Any] = []
query, params = _apply_common_filters(query, params, customer_id, sag_id, q, "c", "s", "si.description")
query += " ORDER BY si.line_date DESC NULLS LAST, si.id DESC"
rows = execute_query(query, tuple(params)) or []
lines: List[Dict[str, Any]] = []
for row in rows:
qty = _to_float(row.get("quantity"), 0.0)
unit_price = _to_float(row.get("unit_price"), 0.0)
amount = _to_float(row.get("amount"), qty * unit_price)
lines.append(
{
"line_key": f"sale:{row['id']}",
"source_type": "sale",
"source_id": row["id"],
"reference_id": row["id"],
"sag_id": row.get("sag_id"),
"sag_title": row.get("sag_title"),
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"description": row.get("description") or "Salgslinje",
"quantity": qty if qty > 0 else 1.0,
"unit": row.get("unit") or "stk",
"unit_price": unit_price,
"discount_percentage": 0.0,
"amount": amount,
"currency": row.get("currency") or "DKK",
"status": row.get("status") or "draft",
"line_date": str(row.get("line_date")) if row.get("line_date") else None,
"product_id": row.get("product_id"),
"selected": True,
}
)
return lines
def _fetch_subscription_lines(customer_id: Optional[int], sag_id: Optional[int], q: Optional[str]) -> List[Dict[str, Any]]:
query = """
SELECT
i.id,
i.subscription_id,
i.line_no,
i.product_id,
i.description,
i.quantity,
i.unit_price,
i.line_total,
s.id AS sub_id,
s.subscription_number,
s.status AS subscription_status,
s.billing_interval,
s.sag_id,
sg.titel AS sag_title,
s.customer_id,
c.name AS customer_name
FROM sag_subscription_items i
JOIN sag_subscriptions s ON s.id = i.subscription_id
JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE sg.deleted_at IS NULL
AND LOWER(s.status) IN ('draft', 'active', 'paused')
"""
params: List[Any] = []
query, params = _apply_common_filters(query, params, customer_id, sag_id, q, "c", "sg", "i.description")
query += " ORDER BY s.id DESC, i.line_no ASC, i.id ASC"
rows = execute_query(query, tuple(params)) or []
lines: List[Dict[str, Any]] = []
for row in rows:
qty = _to_float(row.get("quantity"), 1.0)
unit_price = _to_float(row.get("unit_price"), 0.0)
amount = _to_float(row.get("line_total"), qty * unit_price)
lines.append(
{
"line_key": f"subscription:{row['id']}",
"source_type": "subscription",
"source_id": row["id"],
"reference_id": row.get("subscription_id"),
"subscription_number": row.get("subscription_number"),
"sag_id": row.get("sag_id"),
"sag_title": row.get("sag_title"),
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"description": row.get("description") or "Abonnementslinje",
"quantity": qty if qty > 0 else 1.0,
"unit": "stk",
"unit_price": unit_price,
"discount_percentage": 0.0,
"amount": amount,
"currency": "DKK",
"status": row.get("subscription_status") or "draft",
"line_date": None,
"product_id": row.get("product_id"),
"selected": True,
"meta": {
"billing_interval": row.get("billing_interval"),
"line_no": row.get("line_no"),
},
}
)
return lines
def _fetch_hardware_lines(customer_id: Optional[int], sag_id: Optional[int], q: Optional[str]) -> List[Dict[str, Any]]:
query = """
SELECT
sh.id AS relation_id,
sh.sag_id,
sh.note,
s.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
h.id AS hardware_id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.status AS hardware_status
FROM sag_hardware sh
JOIN sag_sager s ON s.id = sh.sag_id
JOIN hardware_assets h ON h.id = sh.hardware_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE sh.deleted_at IS NULL
AND s.deleted_at IS NULL
AND h.deleted_at IS NULL
"""
params: List[Any] = []
query, params = _apply_common_filters(
query,
params,
customer_id,
sag_id,
q,
"c",
"s",
"CONCAT(COALESCE(h.brand, ''), ' ', COALESCE(h.model, ''), ' ', COALESCE(h.serial_number, ''))",
)
query += " ORDER BY sh.id DESC"
rows = execute_query(query, tuple(params)) or []
lines: List[Dict[str, Any]] = []
for row in rows:
serial = row.get("serial_number")
serial_part = f" (S/N: {serial})" if serial else ""
brand_model = " ".join([part for part in [row.get("brand"), row.get("model")] if part]).strip()
label = brand_model or row.get("asset_type") or "Hardware"
desc = f"Hardware: {label}{serial_part}"
if row.get("note"):
desc = f"{desc} - {row['note']}"
lines.append(
{
"line_key": f"hardware:{row['relation_id']}",
"source_type": "hardware",
"source_id": row["relation_id"],
"reference_id": row.get("hardware_id"),
"sag_id": row.get("sag_id"),
"sag_title": row.get("sag_title"),
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"description": desc,
"quantity": 1.0,
"unit": "stk",
"unit_price": 0.0,
"discount_percentage": 0.0,
"amount": 0.0,
"currency": "DKK",
"status": row.get("hardware_status") or "active",
"line_date": None,
"product_id": None,
"selected": True,
}
)
return lines
def aggregate_order_lines(
customer_id: Optional[int] = None,
sag_id: Optional[int] = None,
q: Optional[str] = None,
) -> Dict[str, Any]:
"""Aggregate order-ready lines across sale, subscription and hardware sources."""
sales_lines = _fetch_sales_lines(customer_id=customer_id, sag_id=sag_id, q=q)
subscription_lines = _fetch_subscription_lines(customer_id=customer_id, sag_id=sag_id, q=q)
hardware_lines = _fetch_hardware_lines(customer_id=customer_id, sag_id=sag_id, q=q)
all_lines = sales_lines + subscription_lines + hardware_lines
total_amount = sum(_to_float(line.get("amount")) for line in all_lines)
selected_amount = sum(_to_float(line.get("amount")) for line in all_lines if line.get("selected"))
customer_ids = sorted(
{
int(line["customer_id"])
for line in all_lines
if line.get("customer_id") is not None
}
)
return {
"lines": all_lines,
"summary": {
"line_count": len(all_lines),
"line_count_sales": len(sales_lines),
"line_count_subscriptions": len(subscription_lines),
"line_count_hardware": len(hardware_lines),
"customer_count": len(customer_ids),
"total_amount": round(total_amount, 2),
"selected_amount": round(selected_amount, 2),
"currency": "DKK",
},
}

View File

@ -0,0 +1,30 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/ordre/create/new", response_class=HTMLResponse)
async def ordre_create(request: Request):
"""Opret ny ordre (gammel funktionalitet)."""
return templates.TemplateResponse("modules/orders/templates/create.html", {"request": request})
@router.get("/ordre/{draft_id}", response_class=HTMLResponse)
async def ordre_detail(request: Request, draft_id: int):
"""Detaljeret visning af en specifik ordre."""
return templates.TemplateResponse("modules/orders/templates/detail.html", {
"request": request,
"draft_id": draft_id
})
@router.get("/ordre", response_class=HTMLResponse)
async def ordre_index(request: Request):
"""Liste over alle ordre drafts."""
return templates.TemplateResponse("modules/orders/templates/list.html", {"request": request})

View File

@ -0,0 +1,726 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Ordre - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.summary-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0,0,0,0.06);
}
.summary-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.35rem;
letter-spacing: 0.6px;
}
.summary-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
}
.table thead th {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
}
.line-source {
font-size: 0.75rem;
}
.customer-search-wrap {
position: relative;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
max-height: 220px;
overflow-y: auto;
z-index: 1100;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.search-item {
padding: 0.6rem 0.8rem;
border-bottom: 1px solid rgba(0,0,0,0.06);
cursor: pointer;
}
.search-item:hover {
background: var(--accent-light);
}
.search-item:last-child {
border-bottom: none;
}
.table-secondary {
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.08) !important;
}
.table-secondary td {
padding: 0.75rem !important;
}
.order-header-row {
cursor: pointer;
transition: background-color 0.2s;
}
.order-header-row:hover {
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.15) !important;
}
.order-lines-container {
display: none;
}
.order-lines-container.show {
display: table-row-group;
}
.expand-icon {
transition: transform 0.3s;
}
.expand-icon.expanded {
transform: rotate(90deg);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-receipt me-2"></i>Opret ny ordre</h2>
<div class="text-muted">Avanceret samlet ordrevisning (abonnement, hardware, salg)</div>
</div>
<div class="d-flex gap-2">
<a href="/ordre" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Tilbage til liste</a>
<button class="btn btn-success" onclick="addManualLine()"><i class="bi bi-plus-circle me-1"></i>Tilføj linje</button>
<button class="btn btn-outline-secondary" onclick="expandAllOrders()"><i class="bi bi-arrows-expand me-1"></i>Fold alle ud</button>
<button class="btn btn-outline-secondary" onclick="collapseAllOrders()"><i class="bi bi-arrows-collapse me-1"></i>Fold alle sammen</button>
<button class="btn btn-outline-secondary" onclick="loadDrafts()"><i class="bi bi-folder2-open me-1"></i>Hent kladder</button>
<button class="btn btn-outline-secondary" onclick="saveDraft()"><i class="bi bi-save me-1"></i>Gem kladde</button>
<button class="btn btn-outline-primary" onclick="loadOrdreLines()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div>
</div>
<div id="safetyBanner" class="alert alert-warning d-none">
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div>
<div class="card mb-3">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4 customer-search-wrap">
<label class="form-label">Kunde (kræves ved eksport)</label>
<input id="customerSearch" type="text" class="form-control" placeholder="Søg kunde (min. 2 tegn)">
<input id="customerId" type="hidden">
<div id="customerSearchResults" class="search-results d-none"></div>
<div id="selectedCustomerMeta" class="small text-muted mt-1"></div>
</div>
<div class="col-md-2">
<label class="form-label">Sag ID</label>
<input id="sagId" type="number" class="form-control" placeholder="fx 456">
</div>
<div class="col-md-3">
<label class="form-label">Søg</label>
<input id="searchText" type="text" class="form-control" placeholder="Beskrivelse, kunde eller sag">
</div>
<div class="col-md-3">
<label class="form-label">Layout nr.</label>
<input id="layoutNumber" type="number" class="form-control" placeholder="e-conomic layout">
</div>
<div class="col-md-4">
<label class="form-label">Kladde</label>
<select id="draftSelect" class="form-select">
<option value="">Vælg kladde...</option>
</select>
</div>
<div class="col-md-2 d-grid">
<button class="btn btn-outline-primary" onclick="loadSelectedDraft()"><i class="bi bi-box-arrow-in-down me-1"></i>Indlæs</button>
</div>
<div class="col-md-2 d-grid">
<button class="btn btn-outline-danger" onclick="deleteSelectedDraft()"><i class="bi bi-trash me-1"></i>Slet</button>
</div>
<div class="col-12">
<label class="form-label">Noter (til e-conomic)</label>
<textarea id="exportNotes" class="form-control" rows="2" placeholder="Valgfri note til ordren"></textarea>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Linjer total</div><div id="sumLines" class="summary-value">0</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Valgte linjer</div><div id="sumSelectedLines" class="summary-value">0</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Beløb total</div><div id="sumAmount" class="summary-value">0 kr.</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Valgt beløb</div><div id="sumSelectedAmount" class="summary-value">0 kr.</div></div></div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th style="width: 30px;"></th>
<th style="width: 50px;">Valg</th>
<th>Kilde</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Pris</th>
<th>Rabat %</th>
<th>Beløb</th>
<th>Eksport</th>
<th>Handling</th>
</tr>
</thead>
<tbody id="ordreLinesBody">
<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="d-flex justify-content-end mt-3">
<button class="btn btn-success" onclick="exportOrdre()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let ordreLines = [];
let customerSearchTimeout = null;
let customerSearchResultsCache = [];
function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
}
function sourceBadge(type) {
if (type === 'subscription') return '<span class="badge bg-primary line-source">Abonnement</span>';
if (type === 'hardware') return '<span class="badge bg-secondary line-source">Hardware</span>';
if (type === 'manual') return '<span class="badge bg-info line-source">Manuel</span>';
return '<span class="badge bg-success line-source">Salg</span>';
}
function addManualLine() {
const customerId = Number(document.getElementById('customerId').value || 0) || null;
const customerName = document.getElementById('customerSearch').value || 'Manuel ordre';
const newLine = {
line_key: `manual-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
source_type: 'manual',
source_id: null,
customer_name: customerName,
customer_id: customerId,
sag_title: null,
sag_id: null,
description: 'Ny linje',
quantity: 1,
unit_price: 0,
discount_percentage: 0,
amount: 0,
unit: 'stk',
product_id: null,
selected: true,
export_status: null,
};
ordreLines.push(newLine);
renderLines();
}
function deleteLine(index) {
if (!confirm('Slet denne linje?')) return;
ordreLines.splice(index, 1);
renderLines();
}
function toggleGroupSelection(indices, selected) {
indices.forEach(index => {
ordreLines[index].selected = selected;
});
renderLines();
}
function recalcSummary() {
const totalAmount = ordreLines.reduce((sum, line) => sum + Number(line.amount || 0), 0);
const selected = ordreLines.filter(line => line.selected);
const selectedAmount = selected.reduce((sum, line) => sum + Number(line.amount || 0), 0);
document.getElementById('sumLines').textContent = ordreLines.length;
document.getElementById('sumSelectedLines').textContent = selected.length;
document.getElementById('sumAmount').textContent = formatCurrency(totalAmount);
document.getElementById('sumSelectedAmount').textContent = formatCurrency(selectedAmount);
}
function updateLineAmount(index) {
const line = ordreLines[index];
const qty = Number(line.quantity || 0);
const price = Number(line.unit_price || 0);
const discount = Number(line.discount_percentage || 0);
const gross = qty * price;
const net = gross * (1 - (discount / 100));
line.amount = Number(net.toFixed(2));
const amountEl = document.getElementById(`lineAmount-${index}`);
if (amountEl) amountEl.textContent = formatCurrency(line.amount);
// Re-render to update group totals
renderLines();
}
function renderLines() {
const body = document.getElementById('ordreLinesBody');
if (!ordreLines.length) {
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Ingen linjer fundet</td></tr>';
recalcSummary();
return;
}
// Group lines by customer_id (or use 'manual' for manual entries without customer)
const grouped = {};
ordreLines.forEach((line, index) => {
const groupKey = line.customer_id || line.customer_name || 'manual';
if (!grouped[groupKey]) {
grouped[groupKey] = {
customer_name: line.customer_name || 'Manuel ordre',
customer_id: line.customer_id || null,
lines: []
};
}
grouped[groupKey].lines.push({ ...line, originalIndex: index });
});
// Render grouped lines with collapsible rows
let html = '';
Object.keys(grouped).forEach((groupKey, groupIndex) => {
const group = grouped[groupKey];
const groupTotal = group.lines.reduce((sum, line) => sum + Number(line.amount || 0), 0);
const groupSelected = group.lines.filter(line => line.selected).length;
const allSelected = groupSelected === group.lines.length;
const lineIndices = group.lines.map(line => line.originalIndex);
// Group header row (clickable to expand/collapse)
html += `
<tr class="table-secondary order-header-row" onclick="toggleOrderLines('order-${groupIndex}')">
<td>
<i class="bi bi-chevron-right expand-icon" id="icon-order-${groupIndex}"></i>
</td>
<td>
<input type="checkbox" ${allSelected ? 'checked' : ''}
onclick="event.stopPropagation();"
onchange="toggleGroupSelection([${lineIndices.join(',')}], this.checked);"
title="Vælg/fravælg alle">
</td>
<td colspan="8">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-folder2 me-2"></i><strong>${group.customer_name}</strong>
${group.customer_id ? ` <span class="badge bg-light text-dark border">Kunde ${group.customer_id}</span>` : ''}
</div>
<div class="text-end">
<span class="badge bg-primary me-2">${group.lines.length} ${group.lines.length === 1 ? 'linje' : 'linjer'}</span>
<span class="badge bg-success me-2">${groupSelected} valgt</span>
<span class="fw-bold">${formatCurrency(groupTotal)}</span>
</div>
</div>
</td>
</tr>
`;
// Render lines in this group (hidden by default)
group.lines.forEach((line) => {
const index = line.originalIndex;
const isManual = line.source_type === 'manual';
const descriptionField = isManual
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
onchange="ordreLines[${index}].description = this.value;">`
: (line.description || '-');
html += `
<tr class="order-lines-container" data-order="order-${groupIndex}">
<td></td>
<td>
<input type="checkbox" ${line.selected ? 'checked' : ''} onchange="ordreLines[${index}].selected = this.checked; recalcSummary();">
</td>
<td>${sourceBadge(line.source_type)}</td>
<td>${descriptionField}</td>
<td style="min-width:100px;">
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
onchange="ordreLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:120px;">
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
onchange="ordreLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:110px;">
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
onchange="ordreLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
<td>${renderExportStatusBadge(line)}</td>
<td>
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
</td>
</tr>
`;
});
});
body.innerHTML = html;
recalcSummary();
}
function toggleOrderLines(orderId) {
const lines = document.querySelectorAll(`tr[data-order="${orderId}"]`);
const icon = document.getElementById(`icon-${orderId}`);
lines.forEach(line => {
line.classList.toggle('show');
});
if (icon) {
icon.classList.toggle('expanded');
}
}
function expandAllOrders() {
document.querySelectorAll('.order-lines-container').forEach(line => {
line.classList.add('show');
});
document.querySelectorAll('.expand-icon').forEach(icon => {
icon.classList.add('expanded');
});
}
function collapseAllOrders() {
document.querySelectorAll('.order-lines-container').forEach(line => {
line.classList.remove('show');
});
document.querySelectorAll('.expand-icon').forEach(icon => {
icon.classList.remove('expanded');
});
}
function renderExportStatusBadge(line) {
const status = line.export_status || '';
if (status === 'exported') {
return '<span class="badge bg-success">Eksporteret</span>';
}
if (status === 'dry-run') {
return '<span class="badge bg-warning text-dark">Dry-run</span>';
}
return '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
}
function selectCustomer(customer) {
document.getElementById('customerId').value = customer.id;
document.getElementById('customerSearch').value = customer.name || '';
document.getElementById('selectedCustomerMeta').textContent = `ID ${customer.id}${customer.cvr_nummer ? ' · CVR ' + customer.cvr_nummer : ''}`;
document.getElementById('customerSearchResults').classList.add('d-none');
}
function clearCustomerSelection() {
document.getElementById('customerId').value = '';
document.getElementById('selectedCustomerMeta').textContent = '';
}
async function searchCustomers(query) {
const resultsEl = document.getElementById('customerSearchResults');
if (!query || query.length < 2) {
resultsEl.classList.add('d-none');
resultsEl.innerHTML = '';
return;
}
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Kundesøgning fejlede');
}
const customers = await response.json();
if (!Array.isArray(customers) || customers.length === 0) {
resultsEl.innerHTML = '<div class="search-item text-muted">Ingen kunder fundet</div>';
resultsEl.classList.remove('d-none');
return;
}
customerSearchResultsCache = customers;
resultsEl.innerHTML = customers.map((customer, index) => `
<div class="search-item" onclick="selectCustomerByIndex(${index})">
<div class="fw-semibold">${customer.name || '-'}</div>
<div class="small text-muted">ID ${customer.id}${customer.cvr_nummer ? ' · CVR ' + customer.cvr_nummer : ''}</div>
</div>
`).join('');
resultsEl.classList.remove('d-none');
} catch (err) {
resultsEl.innerHTML = '<div class="search-item text-danger">Fejl ved kundesøgning</div>';
resultsEl.classList.remove('d-none');
}
}
function selectCustomerByIndex(index) {
const customer = customerSearchResultsCache[index];
if (!customer) return;
selectCustomer(customer);
}
async function loadConfig() {
try {
const res = await fetch('/api/v1/ordre/config');
if (!res.ok) return;
const cfg = await res.json();
if (cfg.economic_read_only || cfg.economic_dry_run) {
document.getElementById('safetyBanner').classList.remove('d-none');
}
if (cfg.default_layout) {
document.getElementById('layoutNumber').value = cfg.default_layout;
}
} catch (err) {
console.error('Config load failed', err);
}
}
async function loadOrdreLines() {
const customerId = document.getElementById('customerId').value;
const sagId = document.getElementById('sagId').value;
const q = document.getElementById('searchText').value.trim();
const params = new URLSearchParams();
if (customerId) params.append('customer_id', customerId);
if (sagId) params.append('sag_id', sagId);
if (q) params.append('q', q);
const body = document.getElementById('ordreLinesBody');
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>';
try {
const res = await fetch(`/api/v1/ordre/aggregate?${params.toString()}`);
if (!res.ok) throw new Error('Failed to load aggregate');
const data = await res.json();
ordreLines = data.lines || [];
renderLines();
} catch (err) {
console.error(err);
body.innerHTML = '<tr><td colspan="10" class="text-danger text-center py-4">Kunne ikke hente ordrelinjer</td></tr>';
ordreLines = [];
recalcSummary();
}
}
async function loadDrafts() {
const select = document.getElementById('draftSelect');
select.innerHTML = '<option value="">Indlæser kladder...</option>';
try {
const res = await fetch('/api/v1/ordre/drafts');
if (!res.ok) throw new Error('Kunne ikke hente kladder');
const drafts = await res.json();
select.innerHTML = '<option value="">Vælg kladde...</option>' + (drafts || []).map(d =>
`<option value="${d.id}">${d.title} (#${d.id})</option>`
).join('');
} catch (err) {
select.innerHTML = '<option value="">Fejl ved indlæsning</option>';
}
}
async function saveDraft() {
const title = prompt('Navn på kladde:', 'Ordrekladde');
if (!title) return;
const selectedDraftId = Number(document.getElementById('draftSelect').value || 0);
const payload = {
title,
customer_id: Number(document.getElementById('customerId').value || 0) || null,
lines: ordreLines,
notes: document.getElementById('exportNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
};
try {
const isUpdate = selectedDraftId > 0;
const endpoint = isUpdate ? `/api/v1/ordre/drafts/${selectedDraftId}` : '/api/v1/ordre/drafts';
const method = isUpdate ? 'PATCH' : 'POST';
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke gemme kladde');
if (data.id && !isUpdate) {
// Redirect to detail page after creating new order
window.location.href = `/ordre/${data.id}`;
return;
}
await loadDrafts();
if (data.id) {
document.getElementById('draftSelect').value = String(data.id);
}
alert('Kladde gemt');
} catch (err) {
alert(`Kunne ikke gemme kladde: ${err.message}`);
}
}
async function loadSelectedDraft() {
const draftId = Number(document.getElementById('draftSelect').value || 0);
if (!draftId) {
alert('Vælg en kladde først');
return;
}
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`);
const draft = await res.json();
if (!res.ok) throw new Error(draft.detail || 'Kunne ikke hente kladde');
ordreLines = Array.isArray(draft.lines_json) ? draft.lines_json : [];
document.getElementById('exportNotes').value = draft.notes || '';
document.getElementById('layoutNumber').value = draft.layout_number || '';
const exportStatus = (draft.export_status_json && typeof draft.export_status_json === 'object')
? draft.export_status_json
: {};
ordreLines = ordreLines.map((line) => {
const key = line.line_key;
const statusMeta = key ? exportStatus[key] : null;
if (statusMeta && statusMeta.status) {
return {
...line,
export_status: statusMeta.status,
exported_at: statusMeta.timestamp || null,
};
}
return line;
});
if (draft.customer_id) {
document.getElementById('customerId').value = draft.customer_id;
document.getElementById('customerSearch').value = `Kunde #${draft.customer_id}`;
document.getElementById('selectedCustomerMeta').textContent = `ID ${draft.customer_id}`;
} else {
document.getElementById('customerSearch').value = '';
clearCustomerSelection();
}
renderLines();
alert('Kladde indlæst');
} catch (err) {
alert(`Kunne ikke indlæse kladde: ${err.message}`);
}
}
async function deleteSelectedDraft() {
const draftId = Number(document.getElementById('draftSelect').value || 0);
if (!draftId) {
alert('Vælg en kladde først');
return;
}
if (!confirm('Slet denne kladde?')) return;
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke slette kladde');
await loadDrafts();
alert('Kladde slettet');
} catch (err) {
alert(`Kunne ikke slette kladde: ${err.message}`);
}
}
async function exportOrdre() {
const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) {
alert('Vælg kunde før eksport');
return;
}
const selectedLines = ordreLines.filter(line => line.selected);
if (!selectedLines.length) {
alert('Vælg mindst én linje');
return;
}
const payload = {
customer_id: customerId,
lines: selectedLines.map(line => ({
line_key: line.line_key,
source_type: line.source_type,
source_id: line.source_id,
description: line.description,
quantity: Number(line.quantity || 0),
unit_price: Number(line.unit_price || 0),
discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk',
product_id: line.product_id || null,
selected: true,
})),
notes: document.getElementById('exportNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: Number(document.getElementById('draftSelect').value || 0) || null,
};
try {
const res = await fetch('/api/v1/ordre/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Eksport fejlede');
}
const exportedLineKeys = data.exported_line_keys || [];
const status = data.dry_run ? 'dry-run' : 'exported';
ordreLines.forEach((line) => {
if (exportedLineKeys.includes(line.line_key)) {
line.export_status = status;
line.exported_at = new Date().toISOString();
}
});
renderLines();
alert(data.message || 'Eksport udført');
} catch (err) {
console.error(err);
alert(`Eksport fejlede: ${err.message}`);
}
}
document.addEventListener('DOMContentLoaded', async () => {
const customerSearchInput = document.getElementById('customerSearch');
if (customerSearchInput) {
customerSearchInput.addEventListener('input', () => {
const query = customerSearchInput.value.trim();
clearTimeout(customerSearchTimeout);
customerSearchTimeout = setTimeout(() => {
if (!query) {
clearCustomerSelection();
}
searchCustomers(query);
}, 200);
});
}
document.addEventListener('click', (event) => {
const resultsEl = document.getElementById('customerSearchResults');
const searchInput = document.getElementById('customerSearch');
if (!resultsEl || !searchInput) return;
if (resultsEl.contains(event.target) || searchInput.contains(event.target)) return;
resultsEl.classList.add('d-none');
});
await loadConfig();
await loadDrafts();
await loadOrdreLines();
});
</script>
{% endblock %}

View File

@ -0,0 +1,416 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Ordre #{{ draft_id }} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.ordre-header {
background: var(--bg-card);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(0,0,0,0.06);
}
.info-item {
margin-bottom: 0.75rem;
}
.info-label {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.5px;
}
.info-value {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.summary-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0,0,0,0.06);
}
.summary-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.35rem;
letter-spacing: 0.6px;
}
.summary-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
}
.table thead th {
font-size: 0.8rem;
text-transform: uppercase;
color: white;
background: var(--accent);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-receipt me-2"></i>Ordre #{{ draft_id }}</h2>
<div class="text-muted">Detaljeret visning</div>
</div>
<div class="d-flex gap-2">
<a href="/ordre" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Tilbage til liste</a>
<button class="btn btn-success" onclick="addManualLine()"><i class="bi bi-plus-circle me-1"></i>Tilføj linje</button>
<button class="btn btn-primary" onclick="saveOrder()"><i class="bi bi-save me-1"></i>Gem</button>
<button class="btn btn-warning" onclick="exportOrder()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button>
</div>
</div>
<div id="safetyBanner" class="alert alert-warning d-none">
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div>
<div class="ordre-header">
<div class="row g-3">
<div class="col-md-3">
<div class="info-item">
<div class="info-label">Titel</div>
<input type="text" id="orderTitle" class="form-control" placeholder="Ordre titel">
</div>
</div>
<div class="col-md-3">
<div class="info-item">
<div class="info-label">Kunde ID</div>
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
</div>
</div>
<div class="col-md-3">
<div class="info-item">
<div class="info-label">Layout nr.</div>
<input type="number" id="layoutNumber" class="form-control" placeholder="e-conomic layout">
</div>
</div>
<div class="col-md-3">
<div class="info-item">
<div class="info-label">Status</div>
<div id="orderStatus" class="info-value">-</div>
</div>
</div>
<div class="col-12">
<div class="info-item">
<div class="info-label">Noter</div>
<textarea id="orderNotes" class="form-control" rows="2" placeholder="Valgfri noter til ordren"></textarea>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Antal linjer</div><div id="sumLines" class="summary-value">0</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Total beløb</div><div id="sumAmount" class="summary-value">0 kr.</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Oprettet</div><div id="createdAt" class="summary-value">-</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Kilde</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Enhedspris</th>
<th>Rabat %</th>
<th>Beløb</th>
<th>Enhed</th>
<th>Status</th>
<th>Handling</th>
</tr>
</thead>
<tbody id="linesTableBody">
<tr><td colspan="9" class="text-muted text-center py-4">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const draftId = {{ draft_id }};
let orderData = null;
let orderLines = [];
function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function sourceBadge(type) {
if (type === 'subscription') return '<span class="badge bg-primary">Abonnement</span>';
if (type === 'hardware') return '<span class="badge bg-secondary">Hardware</span>';
if (type === 'manual') return '<span class="badge bg-info">Manuel</span>';
return '<span class="badge bg-success">Salg</span>';
}
function renderLines() {
const tbody = document.getElementById('linesTableBody');
if (!orderLines.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Ingen linjer</td></tr>';
updateSummary();
return;
}
tbody.innerHTML = orderLines.map((line, index) => {
const isManual = line.source_type === 'manual';
const descriptionField = isManual
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
onchange="orderLines[${index}].description = this.value;">`
: (line.description || '-');
const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported'
? '<span class="badge bg-success">Eksporteret</span>'
: exportStatus === 'dry-run'
? '<span class="badge bg-warning text-dark">Dry-run</span>'
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
return `
<tr>
<td>${sourceBadge(line.source_type)}</td>
<td>${descriptionField}</td>
<td style="min-width:100px;">
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:120px;">
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:110px;">
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
<td>${line.unit || 'stk'}</td>
<td>${statusBadge}</td>
<td>
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
</td>
</tr>
`;
}).join('');
updateSummary();
}
function updateLineAmount(index) {
const line = orderLines[index];
const qty = Number(line.quantity || 0);
const price = Number(line.unit_price || 0);
const discount = Number(line.discount_percentage || 0);
const gross = qty * price;
const net = gross * (1 - (discount / 100));
line.amount = Number(net.toFixed(2));
renderLines();
}
function updateSummary() {
const totalAmount = orderLines.reduce((sum, line) => sum + Number(line.amount || 0), 0);
document.getElementById('sumLines').textContent = orderLines.length;
document.getElementById('sumAmount').textContent = formatCurrency(totalAmount);
}
function addManualLine() {
const newLine = {
line_key: `manual-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
source_type: 'manual',
source_id: null,
customer_name: '-',
customer_id: null,
description: 'Ny linje',
quantity: 1,
unit_price: 0,
discount_percentage: 0,
amount: 0,
unit: 'stk',
selected: true,
};
orderLines.push(newLine);
renderLines();
}
function deleteLine(index) {
if (!confirm('Slet denne linje?')) return;
orderLines.splice(index, 1);
renderLines();
}
function normalizeOrderLine(line) {
// Handle e-conomic format (product.description, unitNetPrice, etc.)
if (line.product && line.product.description && !line.description) {
return {
line_key: line.line_key || `imported-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
source_type: line.source_type || 'manual',
source_id: line.source_id || null,
customer_name: line.customer_name || '-',
customer_id: line.customer_id || null,
description: line.product.description || '',
quantity: Number(line.quantity || 1),
unit_price: Number(line.unitNetPrice || 0),
discount_percentage: Number(line.discountPercentage || 0),
amount: Number(line.totalNetAmount || 0),
unit: line.unit || 'stk',
product_id: line.product.productNumber || null,
selected: line.selected !== false,
export_status: line.export_status || null,
};
}
// Already in our internal format
return {
...line,
quantity: Number(line.quantity || 1),
unit_price: Number(line.unit_price || 0),
discount_percentage: Number(line.discount_percentage || 0),
amount: Number(line.amount || 0),
};
}
async function loadOrder() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`);
if (!res.ok) throw new Error('Kunne ikke hente ordre');
orderData = await res.json();
orderLines = Array.isArray(orderData.lines_json)
? orderData.lines_json.map(normalizeOrderLine)
: [];
document.getElementById('orderTitle').value = orderData.title || '';
document.getElementById('customerId').value = orderData.customer_id || '';
document.getElementById('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || '';
const hasExported = orderData.last_exported_at ? true : false;
document.getElementById('orderStatus').innerHTML = hasExported
? '<span class="badge bg-success">Eksporteret</span>'
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
renderLines();
await loadConfig();
} catch (error) {
console.error(error);
alert(`Fejl: ${error.message}`);
}
}
async function loadConfig() {
try {
const res = await fetch('/api/v1/ordre/config');
if (!res.ok) return;
const cfg = await res.json();
if (cfg.economic_read_only || cfg.economic_dry_run) {
document.getElementById('safetyBanner').classList.remove('d-none');
}
if (!document.getElementById('layoutNumber').value && cfg.default_layout) {
document.getElementById('layoutNumber').value = cfg.default_layout;
}
} catch (err) {
console.error('Config load failed', err);
}
}
async function saveOrder() {
const payload = {
title: document.getElementById('orderTitle').value || 'Ordre',
customer_id: Number(document.getElementById('customerId').value || 0) || null,
lines: orderLines,
notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
};
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`, {
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 gemme ordre');
alert('Ordre gemt');
await loadOrder();
} catch (err) {
alert(`Kunne ikke gemme ordre: ${err.message}`);
}
}
async function exportOrder() {
const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) {
alert('Angiv kunde ID før eksport');
return;
}
if (!orderLines.length) {
alert('Ingen linjer at eksportere');
return;
}
const payload = {
customer_id: customerId,
lines: orderLines.map(line => ({
line_key: line.line_key,
source_type: line.source_type,
source_id: line.source_id,
description: line.description,
quantity: Number(line.quantity || 0),
unit_price: Number(line.unit_price || 0),
discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk',
product_id: line.product_id || null,
selected: true,
})),
notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId,
};
try {
const res = await fetch('/api/v1/ordre/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Eksport fejlede');
}
alert(data.message || 'Eksport udført');
await loadOrder();
} catch (err) {
console.error(err);
alert(`Eksport fejlede: ${err.message}`);
}
}
document.addEventListener('DOMContentLoaded', () => {
loadOrder();
});
</script>
{% endblock %}

View File

@ -0,0 +1,224 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Ordre - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.ordre-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0,0,0,0.06);
}
.ordre-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.35rem;
letter-spacing: 0.6px;
}
.ordre-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
}
.table thead th {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
background: var(--accent);
color: white;
}
.order-row {
cursor: pointer;
transition: background-color 0.2s;
}
.order-row:hover {
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.05);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-receipt me-2"></i>Ordre</h2>
<div class="text-muted">Oversigt over alle ordre</div>
</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>
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Total ordre</div><div id="sumOrders" class="ordre-value">0</div></div></div>
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Seneste måned</div><div id="sumRecent" class="ordre-value">0</div></div></div>
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Eksporteret</div><div id="sumExported" class="ordre-value">0</div></div></div>
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Ikke eksporteret</div><div id="sumNotExported" class="ordre-value">0</div></div></div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Ordre #</th>
<th>Titel</th>
<th>Kunde</th>
<th>Linjer</th>
<th>Oprettet</th>
<th>Sidst opdateret</th>
<th>Sidst eksporteret</th>
<th>Status</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="ordersTableBody">
<tr><td colspan="9" class="text-muted text-center py-4">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let orders = [];
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function renderOrders() {
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();
return;
}
tbody.innerHTML = orders.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>';
return `
<tr class="order-row" onclick="window.location.href='/ordre/${order.id}'">
<td><strong>#${order.id}</strong></td>
<td>${order.title || '-'}</td>
<td>${order.customer_id ? `Kunde ${order.customer_id}` : '-'}</td>
<td><span class="badge bg-primary">${lines.length} linjer</span></td>
<td>${formatDate(order.created_at)}</td>
<td>${formatDate(order.updated_at)}</td>
<td>${formatDate(order.last_exported_at)}</td>
<td>${statusBadge}</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>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="event.stopPropagation(); deleteOrder(${order.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
updateSummary();
}
function updateSummary() {
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);
document.getElementById('sumOrders').textContent = orders.length;
document.getElementById('sumRecent').textContent = recentOrders.length;
document.getElementById('sumExported').textContent = exportedOrders.length;
document.getElementById('sumNotExported').textContent = notExportedOrders.length;
}
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>';
try {
const res = await fetch('/api/v1/ordre/drafts?limit=100');
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
console.error('API Error:', res.status, errorData);
throw new Error(errorData.detail || `HTTP ${res.status}: Kunne ikke hente ordre`);
}
const data = await res.json();
console.log('Fetched orders:', data);
orders = Array.isArray(data) ? data : [];
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();
return;
}
// Fetch lines_json for each order to get line count
const detailPromises = orders.map(async (order) => {
try {
const detailRes = await fetch(`/api/v1/ordre/drafts/${order.id}`);
if (detailRes.ok) {
const detail = await detailRes.json();
order.lines_json = detail.lines_json || [];
} else {
order.lines_json = [];
}
} catch (e) {
console.error(`Failed to fetch details for order ${order.id}:`, e);
order.lines_json = [];
}
});
await Promise.all(detailPromises);
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>`;
orders = [];
updateSummary();
}
}
async function deleteOrder(orderId) {
if (!confirm('Er du sikker på, at du vil slette denne ordre?')) return;
try {
const res = await fetch(`/api/v1/ordre/drafts/${orderId}`, { method: 'DELETE' });
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Kunne ikke slette ordre');
}
await loadOrders();
alert('Ordre slettet');
} catch (error) {
alert(`Fejl: ${error.message}`);
}
}
document.addEventListener('DOMContentLoaded', () => {
loadOrders();
});
</script>
{% endblock %}

View File

@ -42,6 +42,63 @@ def _get_user_id_from_request(request: Request) -> int:
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter") raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
def _normalize_case_status(status_value: Optional[str]) -> str:
if not status_value:
return "åben"
normalized = str(status_value).strip().lower()
if normalized == "afventer":
return "åben"
if normalized in {"åben", "lukket"}:
return normalized
return "åben"
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
text = str(value).strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.replace(tzinfo=None)
return parsed.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}")
def _coerce_optional_int(value: Optional[object], field_name: str) -> Optional[int]:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
def _validate_user_id(user_id: Optional[int], field_name: str = "ansvarlig_bruger_id") -> None:
if user_id is None:
return
exists = execute_query("SELECT 1 FROM users WHERE user_id = %s", (user_id,))
if not exists:
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
def _validate_group_id(group_id: Optional[int], field_name: str = "assigned_group_id") -> None:
if group_id is None:
return
exists = execute_query("SELECT 1 FROM groups WHERE id = %s", (group_id,))
if not exists:
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
# ============================================================================ # ============================================================================
# SAGER - CRUD Operations # SAGER - CRUD Operations
# ============================================================================ # ============================================================================
@ -52,6 +109,7 @@ async def list_sager(
tag: Optional[str] = Query(None), tag: Optional[str] = Query(None),
customer_id: Optional[int] = Query(None), customer_id: Optional[int] = Query(None),
ansvarlig_bruger_id: Optional[int] = Query(None), ansvarlig_bruger_id: Optional[int] = Query(None),
assigned_group_id: Optional[int] = Query(None),
include_deferred: bool = Query(False), include_deferred: bool = Query(False),
q: Optional[str] = Query(None), q: Optional[str] = Query(None),
limit: Optional[int] = Query(None, ge=1, le=200), limit: Optional[int] = Query(None, ge=1, le=200),
@ -59,28 +117,39 @@ async def list_sager(
): ):
"""List all cases with optional filtering.""" """List all cases with optional filtering."""
try: try:
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" query = """
SELECT s.*,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
WHERE s.deleted_at IS NULL
"""
params = [] params = []
if not include_deferred: if not include_deferred:
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())" query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
if status: if status:
query += " AND status = %s" query += " AND s.status = %s"
params.append(status) params.append(status)
if customer_id: if customer_id:
query += " AND customer_id = %s" query += " AND s.customer_id = %s"
params.append(customer_id) params.append(customer_id)
if ansvarlig_bruger_id: if ansvarlig_bruger_id:
query += " AND ansvarlig_bruger_id = %s" query += " AND s.ansvarlig_bruger_id = %s"
params.append(ansvarlig_bruger_id) params.append(ansvarlig_bruger_id)
if assigned_group_id:
query += " AND s.assigned_group_id = %s"
params.append(assigned_group_id)
if q: if q:
query += " AND (LOWER(titel) LIKE %s OR CAST(id AS TEXT) LIKE %s)" query += " AND (LOWER(s.titel) LIKE %s OR CAST(s.id AS TEXT) LIKE %s)"
q_like = f"%{q.lower()}%" q_like = f"%{q.lower()}%"
params.extend([q_like, q_like]) params.extend([q_like, q_like])
query += " ORDER BY created_at DESC" query += " ORDER BY s.created_at DESC"
if limit is not None: if limit is not None:
query += " LIMIT %s OFFSET %s" query += " LIMIT %s OFFSET %s"
@ -162,14 +231,19 @@ async def create_sag(data: dict):
if not data.get("customer_id"): if not data.get("customer_id"):
raise HTTPException(status_code=400, detail="customer_id is required") raise HTTPException(status_code=400, detail="customer_id is required")
status = data.get("status", "åben") status = _normalize_case_status(data.get("status"))
if status not in {"åben", "lukket"}: deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
status = "åben" deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
ansvarlig_bruger_id = _coerce_optional_int(data.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
_validate_user_id(ansvarlig_bruger_id)
_validate_group_id(assigned_group_id)
query = """ query = """
INSERT INTO sag_sager INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status) (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
params = ( params = (
@ -178,10 +252,11 @@ async def create_sag(data: dict):
data.get("template_key") or data.get("type", "ticket"), data.get("template_key") or data.get("type", "ticket"),
status, status,
data.get("customer_id"), data.get("customer_id"),
data.get("ansvarlig_bruger_id"), ansvarlig_bruger_id,
assigned_group_id,
data.get("created_by_user_id", 1), data.get("created_by_user_id", 1),
data.get("deadline"), deadline,
data.get("deferred_until"), deferred_until,
data.get("deferred_until_case_id"), data.get("deferred_until_case_id"),
data.get("deferred_until_status"), data.get("deferred_until_status"),
) )
@ -199,7 +274,15 @@ async def create_sag(data: dict):
async def get_sag(sag_id: int): async def get_sag(sag_id: int):
"""Get a specific case.""" """Get a specific case."""
try: try:
query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" query = """
SELECT s.*,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
WHERE s.id = %s AND s.deleted_at IS NULL
"""
result = execute_query(query, (sag_id,)) result = execute_query(query, (sag_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Case not found") raise HTTPException(status_code=404, detail="Case not found")
@ -402,8 +485,32 @@ async def update_sag(sag_id: int, updates: dict):
if "type" in updates and "template_key" not in updates: if "type" in updates and "template_key" not in updates:
updates["template_key"] = updates.get("type") updates["template_key"] = updates.get("type")
if "status" in updates:
updates["status"] = _normalize_case_status(updates.get("status"))
if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "deferred_until" in updates:
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
if "ansvarlig_bruger_id" in updates:
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
_validate_user_id(updates["ansvarlig_bruger_id"])
if "assigned_group_id" in updates:
updates["assigned_group_id"] = _coerce_optional_int(updates.get("assigned_group_id"), "assigned_group_id")
_validate_group_id(updates["assigned_group_id"])
# Build dynamic update query # Build dynamic update query
allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status"] allowed_fields = [
"titel",
"beskrivelse",
"template_key",
"status",
"ansvarlig_bruger_id",
"assigned_group_id",
"deadline",
"deferred_until",
"deferred_until_case_id",
"deferred_until_status",
]
set_clauses = [] set_clauses = []
params = [] params = []
@ -1036,8 +1143,15 @@ async def add_case_location(sag_id: int, data: dict):
async def remove_case_location(sag_id: int, location_id: int): async def remove_case_location(sag_id: int, location_id: int):
"""Remove location from case.""" """Remove location from case."""
try: try:
query = "UPDATE sag_lokationer SET deleted_at = NOW() WHERE sag_id = %s AND location_id = %s RETURNING id" query = """
result = execute_query(query, (sag_id, location_id)) UPDATE sag_lokationer
SET deleted_at = NOW()
WHERE sag_id = %s
AND deleted_at IS NULL
AND (location_id = %s OR id = %s)
RETURNING id
"""
result = execute_query(query, (sag_id, location_id, location_id))
if result: if result:
logger.info("✅ Location %s removed from case %s", location_id, sag_id) logger.info("✅ Location %s removed from case %s", location_id, sag_id)

View File

@ -1,4 +1,6 @@
import logging import logging
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@ -8,25 +10,78 @@ from app.core.database import execute_query
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _is_deadline_overdue(deadline_value) -> bool:
if not deadline_value:
return False
if isinstance(deadline_value, datetime):
return deadline_value.date() < date.today()
if isinstance(deadline_value, date):
return deadline_value < date.today()
return False
# Setup template directory # Setup template directory
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
def _fetch_assignment_users():
return execute_query(
"""
SELECT user_id, COALESCE(full_name, username) AS display_name
FROM users
ORDER BY display_name
""",
()
) or []
def _fetch_assignment_groups():
return execute_query(
"""
SELECT id, name
FROM groups
ORDER BY name
""",
()
) or []
def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
"""Convert empty strings and None to None, otherwise parse as int."""
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@router.get("/sag", response_class=HTMLResponse) @router.get("/sag", response_class=HTMLResponse)
async def sager_liste( async def sager_liste(
request: Request, request: Request,
status: str = Query(None), status: str = Query(None),
tag: str = Query(None), tag: str = Query(None),
customer_id: int = Query(None), customer_id: str = Query(None),
ansvarlig_bruger_id: str = Query(None),
assigned_group_id: str = Query(None),
include_deferred: bool = Query(False), include_deferred: bool = Query(False),
): ):
"""Display list of all cases.""" """Display list of all cases."""
try: try:
# Coerce string params to optional ints
customer_id_int = _coerce_optional_int(customer_id)
ansvarlig_bruger_id_int = _coerce_optional_int(ansvarlig_bruger_id)
assigned_group_id_int = _coerce_optional_int(assigned_group_id)
query = """ query = """
SELECT s.*, SELECT s.*,
c.name as customer_name, c.name as customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT cc.contact_id SELECT cc.contact_id
FROM contact_companies cc FROM contact_companies cc
@ -50,9 +105,15 @@ async def sager_liste(
if status: if status:
query += " AND s.status = %s" query += " AND s.status = %s"
params.append(status) params.append(status)
if customer_id: if customer_id_int:
query += " AND s.customer_id = %s" query += " AND s.customer_id = %s"
params.append(customer_id) params.append(customer_id_int)
if ansvarlig_bruger_id_int:
query += " AND s.ansvarlig_bruger_id = %s"
params.append(ansvarlig_bruger_id_int)
if assigned_group_id_int:
query += " AND s.assigned_group_id = %s"
params.append(assigned_group_id_int)
query += " ORDER BY s.created_at DESC" query += " ORDER BY s.created_at DESC"
sager = execute_query(query, tuple(params)) sager = execute_query(query, tuple(params))
@ -119,6 +180,10 @@ async def sager_liste(
"current_tag": tag, "current_tag": tag,
"include_deferred": include_deferred, "include_deferred": include_deferred,
"toggle_include_deferred_url": toggle_include_deferred_url, "toggle_include_deferred_url": toggle_include_deferred_url,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
}) })
except Exception as e: except Exception as e:
logger.error("❌ Error displaying case list: %s", e) logger.error("❌ Error displaying case list: %s", e)
@ -127,7 +192,11 @@ async def sager_liste(
@router.get("/sag/new", response_class=HTMLResponse) @router.get("/sag/new", response_class=HTMLResponse)
async def opret_sag_side(request: Request): async def opret_sag_side(request: Request):
"""Show create case form.""" """Show create case form."""
return templates.TemplateResponse("modules/sag/templates/create.html", {"request": request}) return templates.TemplateResponse("modules/sag/templates/create.html", {
"request": request,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
})
@router.get("/sag/varekob-salg", response_class=HTMLResponse) @router.get("/sag/varekob-salg", response_class=HTMLResponse)
async def sag_varekob_salg(request: Request): async def sag_varekob_salg(request: Request):
@ -141,7 +210,15 @@ async def sag_detaljer(request: Request, sag_id: int):
"""Display case details.""" """Display case details."""
try: try:
# Fetch main case # Fetch main case
sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" sag_query = """
SELECT s.*,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
WHERE s.id = %s AND s.deleted_at IS NULL
"""
sag_result = execute_query(sag_query, (sag_id,)) sag_result = execute_query(sag_query, (sag_id,))
if not sag_result: if not sag_result:
@ -375,6 +452,7 @@ async def sag_detaljer(request: Request, sag_id: int):
pipeline_stages = [] pipeline_stages = []
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail.html", { return templates.TemplateResponse("modules/sag/templates/detail.html", {
"request": request, "request": request,
@ -398,6 +476,9 @@ async def sag_detaljer(request: Request, sag_id: int):
"related_case_options": related_case_options, "related_case_options": related_case_options,
"pipeline_stages": pipeline_stages, "pipeline_stages": pipeline_stages,
"status_options": [s["status"] for s in statuses], "status_options": [s["status"] for s in statuses],
"is_deadline_overdue": is_deadline_overdue,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
}) })
except HTTPException: except HTTPException:
raise raise
@ -419,6 +500,8 @@ async def sag_rediger(request: Request, sag_id: int):
return templates.TemplateResponse("modules/sag/templates/edit.html", { return templates.TemplateResponse("modules/sag/templates/edit.html", {
"request": request, "request": request,
"case": sag_result[0], "case": sag_result[0],
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
}) })
except HTTPException: except HTTPException:
raise raise

View File

@ -238,7 +238,7 @@
<!-- Section: Metadata --> <!-- Section: Metadata -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-md-4"> <div class="col-md-3">
<label for="type" class="form-label">Type <span class="text-danger">*</span></label> <label for="type" class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="type" required> <select class="form-select" id="type" required>
<option value="ticket" selected>🎫 Ticket</option> <option value="ticket" selected>🎫 Ticket</option>
@ -248,7 +248,7 @@
<option value="service">🛠️ Service</option> <option value="service">🛠️ Service</option>
</select> </select>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label> <label for="status" class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" id="status" required> <select class="form-select" id="status" required>
<option value="åben" selected>🟢 Åben</option> <option value="åben" selected>🟢 Åben</option>
@ -256,15 +256,28 @@
<option value="lukket">🔴 Lukket</option> <option value="lukket">🔴 Lukket</option>
</select> </select>
</div> </div>
<div class="col-md-3">
<div class="col-md-4"> <label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig (ID)</label> <select class="form-select" id="ansvarlig_bruger_id">
<div class="input-group"> <option value="">Ingen</option>
<span class="input-group-text bg-light border-end-0"><i class="bi bi-person-badge"></i></span> {% for user in assignment_users or [] %}
<input type="number" class="form-control border-start-0 ps-0" id="ansvarlig_bruger_id" placeholder="Bruger ID"> <option value="{{ user.user_id }}">{{ user.display_name }}</option>
</div> {% endfor %}
</select>
</div> </div>
<div class="col-md-3">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id">
<option value="">Ingen</option>
{% for group in assignment_groups or [] %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-md-4"> <div class="col-md-4">
<label for="deadline" class="form-label">Deadline</label> <label for="deadline" class="form-label">Deadline</label>
<div class="input-group"> <div class="input-group">
@ -839,6 +852,7 @@
status: status, status: status,
customer_id: selectedCustomer ? selectedCustomer.id : null, customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null, ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
created_by_user_id: 1, // HARDCODED for now, should come from auth created_by_user_id: 1, // HARDCODED for now, should come from auth
deadline: document.getElementById('deadline').value || null deadline: document.getElementById('deadline').value || null
}; };

View File

@ -716,11 +716,20 @@
<span class="text-muted" style="margin-right: 0.3rem;">Opr:</span> {{ case.created_at.strftime('%d/%m-%y') if case.created_at else '-' }} <span class="text-muted" style="margin-right: 0.3rem;">Opr:</span> {{ case.created_at.strftime('%d/%m-%y') if case.created_at else '-' }}
<span class="text-muted mx-2">|</span> <span class="text-muted mx-2">|</span>
<span class="text-muted" style="margin-right: 0.3rem;">Opd:</span> {{ case.updated_at.strftime('%d/%m-%y') if case.updated_at else '-' }} <span class="text-muted" style="margin-right: 0.3rem;">Opd:</span> {{ case.updated_at.strftime('%d/%m-%y') if case.updated_at else '-' }}
<span class="text-muted mx-2">|</span> </div>
<span class="text-muted" style="margin-right: 0.3rem;">Deadline:</span>
<strong class="{{ 'text-danger' if case.deadline and case.deadline < now else '' }}"> <div class="d-flex align-items-center ps-3 border-start">
{{ case.deadline.strftime('%d/%m-%y') if case.deadline else 'Ingen' }} <strong style="color: var(--accent); margin-right: 0.4rem;">Deadline:</strong>
</strong> {% if case.deadline %}
<span class="badge bg-light text-dark border me-1 {{ 'text-danger border-danger' if is_deadline_overdue else '' }}">
<i class="bi bi-clock me-1"></i>{{ case.deadline.strftime('%d/%m-%y') }}
</span>
{% else %}
<span class="text-muted fst-italic me-1">Ingen</span>
{% endif %}
<button class="btn btn-link btn-sm p-0 text-muted" onclick="openDeadlineModal()" title="Rediger deadline">
<i class="bi bi-pencil-square"></i>
</button>
</div> </div>
<!-- Deferred Logic integrated --> <!-- Deferred Logic integrated -->
@ -741,6 +750,36 @@
</div> </div>
</div> </div>
<!-- Assignment Card -->
<div class="card mb-3" style="background: var(--bg-card); box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
<div class="card-body py-2 px-3">
<div class="row g-3 align-items-end">
<div class="col-md-5">
<label class="form-label small text-muted">Ansvarlig medarbejder</label>
<select id="assignmentUserSelect" class="form-select form-select-sm">
<option value="">Ingen</option>
{% for user in assignment_users or [] %}
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-5">
<label class="form-label small text-muted">Ansvarlig gruppe</label>
<select id="assignmentGroupSelect" class="form-select form-select-sm">
<option value="">Ingen</option>
{% for group in assignment_groups or [] %}
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2 d-flex justify-content-end">
<button class="btn btn-sm btn-primary" onclick="saveAssignment()">Gem tildeling</button>
</div>
</div>
<div id="assignmentStatus" class="small text-muted mt-2"></div>
</div>
</div>
<!-- Tabs Navigation --> <!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist"> <ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@ -925,6 +964,13 @@
</div> </div>
</div> </div>
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;"> <div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
<div class="alert alert-light border small py-2 px-3 mb-3">
<div class="fw-semibold mb-1"><i class="bi bi-info-circle me-1"></i>Hvad betyder relationstyper?</div>
<div><strong>Relateret til</strong>: Faglig kobling uden direkte afhængighed.</div>
<div><strong>Afledt af</strong>: Denne sag er opstået på baggrund af en anden sag.</div>
<div><strong>Årsag til</strong>: Denne sag er årsagen til en anden sag.</div>
<div><strong>Blokkerer</strong>: Arbejde i en sag stopper fremdrift i den anden.</div>
</div>
{% macro render_tree(nodes) %} {% macro render_tree(nodes) %}
<ul class="relation-tree"> <ul class="relation-tree">
{% for node in nodes %} {% for node in nodes %}
@ -936,16 +982,23 @@
{% if node.relation_type %} {% if node.relation_type %}
{% set rel_icon = 'bi-link-45deg' %} {% set rel_icon = 'bi-link-45deg' %}
{% set rel_color = 'text-muted' %} {% set rel_color = 'text-muted' %}
{% set rel_help = 'Faglig kobling uden direkte afhængighed' %}
{% if node.relation_type == 'Afledt af' %} {% if node.relation_type == 'Afledt af' %}
{% set rel_icon = 'bi-arrow-return-right' %} {% set rel_icon = 'bi-arrow-return-right' %}
{% set rel_color = 'text-info' %} {% set rel_color = 'text-info' %}
{% set rel_help = 'Denne sag er opstået på baggrund af en anden sag' %}
{% elif node.relation_type == 'Årsag til' %}
{% set rel_icon = 'bi-arrow-right-circle' %}
{% set rel_color = 'text-primary' %}
{% set rel_help = 'Denne sag er årsag til en anden sag' %}
{% elif node.relation_type == 'Blokkerer' %} {% elif node.relation_type == 'Blokkerer' %}
{% set rel_icon = 'bi-slash-circle' %} {% set rel_icon = 'bi-slash-circle' %}
{% set rel_color = 'text-danger' %} {% set rel_color = 'text-danger' %}
{% set rel_help = 'Arbejdet i denne sag blokerer den anden' %}
{% endif %} {% endif %}
<span class="relation-type-badge {{ rel_color }}" title="{{ node.relation_type }}"> <span class="relation-type-badge {{ rel_color }}" title="{{ node.relation_type }}: {{ rel_help }}">
<i class="bi {{ rel_icon }}"></i> <i class="bi {{ rel_icon }}"></i>
<span class="d-none d-md-inline ms-1" style="font-size: 0.7rem;">{{ node.relation_type }}</span> <span class="d-none d-md-inline ms-1" style="font-size: 0.7rem;">{{ node.relation_type }}</span>
</span> </span>
@ -1244,17 +1297,25 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">2. Vælg relationstype</label> <label class="form-label fw-bold">2. Vælg relationstype</label>
<select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton()"> <select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();">
<option value="">Vælg hvordan sagerne er relateret...</option> <option value="">Vælg hvordan sagerne er relateret...</option>
<option value="relateret">🔗 Relateret - Generel relation</option> <option value="Relateret til">🔗 Relateret til - Faglig kobling uden direkte afhængighed</option>
<option value="afhænger af">⏳ Afhænger af - Denne sag venter på den anden</option> <option value="Afledt af">↪ Afledt af - Denne sag er opstået på baggrund af den anden</option>
<option value="blokkerer">🚫 Blokkerer - Denne sag blokerer den anden</option> <option value="Årsag til">➡ Årsag til - Denne sag er årsagen til den anden</option>
<option value="duplikat">📋 Duplikat - Sagerne er den samme</option> <option value="Blokkerer">⛔ Blokkerer - Denne sag stopper fremdrift i den anden</option>
<option value="forårsaget af">🔄 Forårsaget af - Denne sag er konsekvens af den anden</option>
<option value="følger op på">📌 Følger op på - Fortsættelse af tidligere sag</option>
</select> </select>
</div> </div>
<div id="relationTypeHint" class="alert alert-info small mb-3" style="display:none;"></div>
<div class="alert alert-light border small mb-3">
<div class="fw-semibold mb-1">Betydning i praksis</div>
<div><strong>Relateret til</strong>: Bruges når sager hænger sammen, men ingen af dem afhænger direkte af den anden.</div>
<div><strong>Afledt af</strong>: Bruges når denne sag er afledt af et tidligere problem/arbejde.</div>
<div><strong>Årsag til</strong>: Bruges når denne sag skaber behovet for den anden.</div>
<div><strong>Blokkerer</strong>: Bruges når løsning i én sag er nødvendig før den anden kan videre.</div>
</div>
<div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;"> <div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;">
<i class="bi bi-info-circle me-2"></i> <i class="bi bi-info-circle me-2"></i>
<div> <div>
@ -1350,6 +1411,37 @@
</div> </div>
</div> </div>
<!-- Deadline Modal -->
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Deadline</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label">Dato</label>
<input
type="date"
class="form-control form-control-sm"
id="deadlineInput"
value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}"
/>
<div class="defer-controls mt-2">
<button class="btn btn-outline-primary" onclick="shiftDeadlineDays(1)">+1 dag</button>
<button class="btn btn-outline-primary" onclick="shiftDeadlineDays(7)">+1 uge</button>
<button class="btn btn-outline-primary" onclick="shiftDeadlineMonths(1)">+1 mnd</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-outline-danger" onclick="clearDeadlineAll()">Ryd</button>
<button type="button" class="btn btn-primary" onclick="saveDeadlineAll()">Gem</button>
</div>
</div>
</div>
</div>
<!-- Deferred Modal --> <!-- Deferred Modal -->
<div class="modal fade" id="deferredModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deferredModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@ -1465,6 +1557,8 @@
setupContactSearch(); setupContactSearch();
setupCustomerSearch(); setupCustomerSearch();
setupRelationSearch(); setupRelationSearch();
updateRelationTypeHint();
updateNewCaseRelationTypeHint();
// Render Global Tags // Render Global Tags
if (window.renderEntityTags) { if (window.renderEntityTags) {
@ -1521,6 +1615,7 @@
function showRelationModal() { function showRelationModal() {
relationModal.show(); relationModal.show();
updateRelationTypeHint();
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300); setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
} }
@ -1585,6 +1680,67 @@
function showCreateRelatedModal() { function showCreateRelatedModal() {
createRelatedCaseModalInstance.show(); createRelatedCaseModalInstance.show();
updateNewCaseRelationTypeHint();
}
function relationTypeMeaning(type) {
const map = {
'Relateret til': {
icon: '🔗',
text: 'Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.'
},
'Afledt af': {
icon: '↪',
text: 'Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).'
},
'Årsag til': {
icon: '➡',
text: 'Denne sag er årsag til den anden sag (du peger frem mod en konsekvens/opfølgning).'
},
'Blokkerer': {
icon: '⛔',
text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.'
}
};
return map[type] || null;
}
function updateRelationTypeHint() {
const select = document.getElementById('relationTypeSelect');
const hint = document.getElementById('relationTypeHint');
if (!select || !hint) return;
const meaning = relationTypeMeaning(select.value);
if (!meaning) {
hint.style.display = 'none';
hint.innerHTML = '';
return;
}
hint.style.display = 'block';
hint.innerHTML = `<strong>${meaning.icon} Betydning:</strong> ${meaning.text}`;
}
function updateNewCaseRelationTypeHint() {
const select = document.getElementById('newCaseRelationType');
const hint = document.getElementById('newCaseRelationTypeHint');
if (!select || !hint) return;
const selected = select.value;
if (selected === 'Afledt af') {
hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som afledt af den nye sag.';
return;
}
if (selected === 'Årsag til') {
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som årsag til den nye sag.';
return;
}
if (selected === 'Blokkerer') {
hint.innerHTML = '<strong>⛔ Effekt:</strong> Nuværende sag markeres som blokering for den nye sag.';
return;
}
hint.innerHTML = '<strong>🔗 Effekt:</strong> Sagerne kobles fagligt uden direkte afhængighed.';
} }
async function createRelatedCase() { async function createRelatedCase() {
@ -2118,7 +2274,7 @@
${l.name} ${l.name}
</div> </div>
<small>${l.location_type || '-'}</small> <small>${l.location_type || '-'}</small>
<button class="btn btn-sm btn-delete" onclick="unlinkLocation(${l.id})" title="Slet"> <button class="btn btn-sm btn-delete" onclick="unlinkLocation(${l.relation_id || l.id})" title="Slet">
</button> </button>
</div> </div>
@ -2459,10 +2615,14 @@
async function unlinkLocation(locId) { async function unlinkLocation(locId) {
if(!confirm("Fjern link til denne lokation?")) return; if(!confirm("Fjern link til denne lokation?")) return;
try { try {
await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' }); const res = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Kunne ikke fjerne lokation');
}
loadCaseLocations(); loadCaseLocations();
} catch (e) { } catch (e) {
alert("Fejl ved sletning"); alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
} }
} }
@ -2506,6 +2666,121 @@
} }
</script> </script>
<!-- Tid & Fakturering Section (Moved from Right Column) -->
<div class="card mt-3" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h5>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
<i class="bi bi-fullscreen me-1"></i>Fuld Formular
</button>
</div>
<div class="card-body">
<!-- Quick Add Time Entry Form -->
<div class="border rounded p-2 mb-2 bg-light" id="quickTimeFormContainer">
<form id="quickAddTimeForm" onsubmit="quickAddTime(event); return false;">
<div class="row g-1 align-items-end">
<div class="col-md-2 col-6">
<label for="quickTimeDate" class="form-label small mb-1">Dato</label>
<input type="date" class="form-control form-control-sm" id="quickTimeDate" name="date"
value="{{ today or '' }}" required>
</div>
<div class="col-md-1 col-3">
<label for="quickTimeHours" class="form-label small mb-1">Timer</label>
<input type="number" class="form-control form-control-sm" id="quickTimeHours" name="hours"
min="0" max="23" value="0" required>
</div>
<div class="col-md-1 col-3">
<label for="quickTimeMinutes" class="form-label small mb-1">Min</label>
<input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes"
min="0" max="59" step="15" value="0" required>
</div>
<div class="col-md-3 col-6">
<label for="quickTimeBillingMethod" class="form-label small mb-1">Afregning</label>
<select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method">
<option value="invoice" selected>Faktura</option>
{% if prepaid_cards %}
<optgroup label="Klippekort">
{% for card in prepaid_cards %}
<option value="card_{{ card.id }}">💳 Kort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t)</option>
{% endfor %}
</optgroup>
{% endif %}
{% if fixed_price_agreements %}
<optgroup label="Fastpris">
{% for agr in fixed_price_agreements %}
<option value="fpa_{{ agr.id }}">📋 #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t)</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="internal">Internt</option>
<option value="warranty">Garanti</option>
</select>
</div>
<div class="col-md-4 col-12">
<label for="quickTimeDescription" class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description"
placeholder="Hvad har du lavet?" required>
</div>
<div class="col-md-1 col-12 d-flex align-items-end">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-plus-lg me-0"></i>
</button>
</div>
</div>
</form>
</div>
<!-- Time Entries Table -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Dato</th>
<th>Beskrivelse</th>
<th>Bruger</th>
<th class="text-end">Timer</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr>
<td>{{ entry.worked_date }}</td>
<td>{{ entry.description or '-' }}</td>
<td>{{ entry.user_name }}</td>
<td class="text-end fw-bold">{{ entry.original_hours }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center py-3 text-muted">
<i class="bi bi-inbox me-2"></i>Ingen tid registreret endnu
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Prepaid Cards Info -->
{% if prepaid_cards %}
<div class="border-top mt-3 pt-3">
<h6 class="mb-2"><i class="bi bi-credit-card me-1"></i>Aktive Klippekort</h6>
<div class="row g-2">
{% for card in prepaid_cards %}
<div class="col-md-3">
<div class="border rounded p-2 bg-light">
<div class="small text-muted">Kort #{{ card.card_number or card.id }}</div>
<div class="fw-bold text-primary">{{ '%.2f' % card.remaining_hours }} timer tilbage</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div> </div>
<div class="col-lg-4" id="case-right-column"> <div class="col-lg-4" id="case-right-column">
<div class="right-modules-grid"> <div class="right-modules-grid">
@ -2655,57 +2930,6 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h6>
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
<i class="bi bi-plus-lg me-1"></i>Registrer Tid
</button>
</div>
<div class="card-body p-0" style="max-height: 180px; overflow: auto;">
<div class="table-responsive">
<table class="table table-hover mb-0" style="vertical-align: middle;">
<thead class="bg-light">
<tr>
<th class="ps-3">Dato</th>
<th>Beskrivelse</th>
<th>Bruger</th>
<th>Timer</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr>
<td class="ps-3">{{ entry.worked_date }}</td>
<td>{{ entry.description or '-' }}</td>
<td>{{ entry.user_name }}</td>
<td class="fw-bold">{{ entry.original_hours }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center py-3 text-muted">Ingen tid registreret</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="border-top px-3 py-2 small text-muted">
<div class="fw-semibold text-primary mb-1"><i class="bi bi-credit-card me-1"></i>Klippekort</div>
{% if prepaid_cards %}
<div class="d-flex flex-column gap-1">
{% for card in prepaid_cards %}
<div class="d-flex justify-content-between">
<span>#{{ card.card_number or card.id }}</span>
<span>{{ '%.2f' % card.remaining_hours }}t</span>
</div>
{% endfor %}
</div>
{% else %}
<div>Ingen aktive klippekort</div>
{% endif %}
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -2967,29 +3191,37 @@
<div id="subscriptionDetails" class="d-none"> <div id="subscriptionDetails" class="d-none">
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-md-6"> <div class="col-md-4">
<label class="small text-muted">Abonnement</label> <label class="small text-muted">Abonnement</label>
<div class="fw-semibold" id="subscriptionNumber">-</div> <div class="fw-semibold" id="subscriptionNumber">-</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<label class="small text-muted">Produkt</label> <label class="small text-muted">Produkt</label>
<div class="fw-semibold" id="subscriptionProduct">-</div> <div class="fw-semibold" id="subscriptionProduct">-</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<label class="small text-muted">Status</label>
<div class="fw-semibold" id="subscriptionStatusText">-</div>
</div>
<div class="col-md-4">
<label class="small text-muted">Interval</label> <label class="small text-muted">Interval</label>
<div class="fw-semibold" id="subscriptionInterval">-</div> <div class="fw-semibold" id="subscriptionInterval">-</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<label class="small text-muted">Pris</label> <label class="small text-muted">Pris</label>
<div class="fw-semibold" id="subscriptionPrice">-</div> <div class="fw-semibold" id="subscriptionPrice">-</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<label class="small text-muted">Startdato</label> <label class="small text-muted">Startdato</label>
<div class="fw-semibold" id="subscriptionStartDate">-</div> <div class="fw-semibold" id="subscriptionStartDate">-</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="small text-muted">Status</label> <label class="small text-muted">Periode start <i class="bi bi-info-circle" title="Nuværende faktureringsperiode"></i></label>
<div class="fw-semibold" id="subscriptionStatusText">-</div> <div class="fw-semibold" id="subscriptionPeriodStart">-</div>
</div>
<div class="col-md-6">
<label class="small text-muted">Næste faktura <i class="bi bi-info-circle" title="Dato for næste automatiske faktura"></i></label>
<div class="fw-semibold" id="subscriptionNextInvoice">-</div>
</div> </div>
</div> </div>
<div class="table-responsive mb-3"> <div class="table-responsive mb-3">
@ -3020,6 +3252,8 @@
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Interval *</label> <label class="form-label">Interval *</label>
<select class="form-select" id="subscriptionIntervalInput" required> <select class="form-select" id="subscriptionIntervalInput" required>
<option value="daily">Daglig</option>
<option value="biweekly">Hver 14. dag</option>
<option value="monthly" selected>Maaned</option> <option value="monthly" selected>Maaned</option>
<option value="quarterly">Kvartal</option> <option value="quarterly">Kvartal</option>
<option value="yearly">Aar</option> <option value="yearly">Aar</option>
@ -3125,6 +3359,8 @@
<label class="form-label">Faktureringsinterval</label> <label class="form-label">Faktureringsinterval</label>
<select class="form-select" id="subscriptionProductBillingPeriod"> <select class="form-select" id="subscriptionProductBillingPeriod">
<option value="">-</option> <option value="">-</option>
<option value="daily">Daglig</option>
<option value="biweekly">Hver 14. dag</option>
<option value="monthly">Maaned</option> <option value="monthly">Maaned</option>
<option value="quarterly">Kvartal</option> <option value="quarterly">Kvartal</option>
<option value="yearly">Aar</option> <option value="yearly">Aar</option>
@ -4660,13 +4896,21 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Relationstype *</label> <label class="form-label">Relationstype *</label>
<select class="form-select" id="newCaseRelationType"> <select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
<option value="Relateret til">Relateret til (Generel kobling)</option> <option value="Relateret til">Relateret til (Ingen direkte afhængighed)</option>
<option value="Afledt af">Afledt af (Denne sag afventer den nye)</option> <option value="Afledt af">Afledt af (Nuværende sag er afledt af den nye)</option>
<option value="Årsag til">Årsag til (Den nye sag afventer denne)</option> <option value="Årsag til">Årsag til (Nuværende sag er årsag til den nye)</option>
<option value="Blokkerer">Blokkerer (Denne sag blokkerer den nye)</option> <option value="Blokkerer">Blokkerer (Nuværende sag blokerer den nye)</option>
</select> </select>
</div> </div>
<div id="newCaseRelationTypeHint" class="alert alert-info small mb-3"></div>
<div class="alert alert-light border small">
<div class="fw-semibold mb-1">Sådan vælger du korrekt relation</div>
<div><strong>Relateret til</strong>: Samme emne/område, men ingen direkte afhængighed.</div>
<div><strong>Afledt af</strong>: Den nye sag opstår fordi den nuværende sag findes.</div>
<div><strong>Årsag til</strong>: Den nuværende sag opstår fordi den nye sag findes.</div>
<div><strong>Blokkerer</strong>: Løsning i én sag er nødvendig før den anden kan afsluttes.</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Beskrivelse</label> <label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="newCaseDescription" rows="3"></textarea> <textarea class="form-control" id="newCaseDescription" rows="3"></textarea>
@ -4889,6 +5133,55 @@
} }
} }
async function updateDeadline(value) {
try {
const res = await fetch(`/api/v1/sag/${caseIds}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ deadline: value || null })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke opdatere deadline');
}
window.location.reload();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
function shiftDeadlineDays(days) {
const input = document.getElementById('deadlineInput');
const base = input.value ? new Date(input.value) : new Date();
base.setDate(base.getDate() + days);
input.value = base.toISOString().slice(0, 10);
updateDeadline(input.value);
}
function shiftDeadlineMonths(months) {
const input = document.getElementById('deadlineInput');
const base = input.value ? new Date(input.value) : new Date();
base.setMonth(base.getMonth() + months);
input.value = base.toISOString().slice(0, 10);
updateDeadline(input.value);
}
function openDeadlineModal() {
const modal = new bootstrap.Modal(document.getElementById('deadlineModal'));
modal.show();
}
function saveDeadlineAll() {
const input = document.getElementById('deadlineInput');
updateDeadline(input.value || null);
}
function clearDeadlineAll() {
const input = document.getElementById('deadlineInput');
input.value = '';
updateDeadline(null);
}
function setDeferredFromInput() { function setDeferredFromInput() {
const input = document.getElementById('deferredUntilInput'); const input = document.getElementById('deferredUntilInput');
updateDeferredUntil(input.value || null); updateDeferredUntil(input.value || null);
@ -4986,6 +5279,80 @@
view.classList.remove('d-none'); view.classList.remove('d-none');
edit.classList.add('d-none'); edit.classList.add('d-none');
} }
if (shouldEdit) {
ensurePipelineStagesLoaded();
}
}
async function ensurePipelineStagesLoaded() {
const select = document.getElementById('pipelineStageSelect');
if (!select) return;
if (select.options.length > 1) return;
try {
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
if (!response.ok) return;
const stages = await response.json();
if (!Array.isArray(stages) || stages.length === 0) return;
const existingValue = select.value || '';
select.innerHTML = '<option value="">Ikke sat</option>' +
stages.map((stage) => `<option value="${stage.id}">${stage.name}</option>`).join('');
if (existingValue) {
select.value = existingValue;
}
} catch (error) {
console.error('Could not load pipeline stages', error);
}
}
async function saveAssignment() {
const statusEl = document.getElementById('assignmentStatus');
const userValue = document.getElementById('assignmentUserSelect')?.value || '';
const groupValue = document.getElementById('assignmentGroupSelect')?.value || '';
const payload = {
ansvarlig_bruger_id: userValue ? parseInt(userValue, 10) : null,
assigned_group_id: groupValue ? parseInt(groupValue, 10) : null
};
if (statusEl) {
statusEl.textContent = 'Gemmer...';
}
try {
const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
let message = 'Kunne ikke gemme tildeling';
try {
const data = await response.json();
message = data.detail || message;
} catch (err) {
// Keep default message
}
if (statusEl) {
statusEl.textContent = `❌ ${message}`;
}
return;
}
if (statusEl) {
statusEl.textContent = '✅ Tildeling gemt';
}
} catch (err) {
if (statusEl) {
statusEl.textContent = `❌ ${err.message}`;
}
}
} }
async function savePipeline() { async function savePipeline() {
@ -5010,8 +5377,15 @@
}); });
if (!response.ok) { if (!response.ok) {
const err = await response.json(); let message = 'Kunne ikke opdatere pipeline';
throw new Error(err.detail || 'Kunne ikke opdatere pipeline'); try {
const err = await response.json();
message = err.detail || err.message || message;
} catch (_e) {
const text = await response.text();
if (text) message = text;
}
throw new Error(`${message} (HTTP ${response.status})`);
} }
window.location.reload(); window.location.reload();
@ -5065,10 +5439,18 @@
document.querySelectorAll('[data-module]').forEach((el) => { document.querySelectorAll('[data-module]').forEach((el) => {
const moduleName = el.getAttribute('data-module'); const moduleName = el.getAttribute('data-module');
const hasContent = moduleHasContent(el); const hasContent = moduleHasContent(el);
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline'; const isTimeModule = moduleName === 'time';
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
const pref = modulePrefs[moduleName]; const pref = modulePrefs[moduleName];
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`); const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
if (isTimeModule) {
el.classList.remove('d-none');
el.classList.remove('module-empty-compact');
if (tabButton) tabButton.classList.remove('d-none');
return;
}
if (hasContent) { if (hasContent) {
el.classList.remove('d-none'); el.classList.remove('d-none');
el.classList.remove('module-empty-compact'); el.classList.remove('module-empty-compact');
@ -5150,6 +5532,7 @@
acc[p.module_key] = p.is_enabled; acc[p.module_key] = p.is_enabled;
return acc; return acc;
}, {}); }, {});
modulePrefs.time = true;
} catch (e) { } catch (e) {
console.error('Module prefs load failed', e); console.error('Module prefs load failed', e);
} }
@ -5185,12 +5568,14 @@
}); });
list.innerHTML = modules.map(m => { list.innerHTML = modules.map(m => {
const checked = modulePrefs[m.key] !== false; const isTimeModule = m.key === 'time';
const checked = isTimeModule ? true : modulePrefs[m.key] !== false;
return ` return `
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="module_${m.key}" ${checked ? 'checked' : ''} <input class="form-check-input" type="checkbox" id="module_${m.key}" ${checked ? 'checked' : ''}
${isTimeModule ? 'disabled' : ''}
onchange="toggleModulePref('${m.key}', this.checked)"> onchange="toggleModulePref('${m.key}', this.checked)">
<label class="form-check-label" for="module_${m.key}">${m.label}</label> <label class="form-check-label" for="module_${m.key}">${m.label}${isTimeModule ? ' (altid synlig)' : ''}</label>
</div> </div>
`; `;
}).join(''); }).join('');
@ -5200,6 +5585,11 @@
} }
async function toggleModulePref(moduleKey, isEnabled) { async function toggleModulePref(moduleKey, isEnabled) {
if (moduleKey === 'time') {
modulePrefs.time = true;
applyViewFromTags();
return;
}
try { try {
const res = await fetch(`/api/v1/sag/${caseIds}/modules`, { const res = await fetch(`/api/v1/sag/${caseIds}/modules`, {
method: 'POST', method: 'POST',
@ -5579,6 +5969,8 @@
function formatSubscriptionInterval(interval) { function formatSubscriptionInterval(interval) {
const map = { const map = {
'daily': 'Daglig',
'biweekly': '14-dage',
'monthly': 'Maaned', 'monthly': 'Maaned',
'quarterly': 'Kvartal', 'quarterly': 'Kvartal',
'yearly': 'Aar' 'yearly': 'Aar'
@ -5856,6 +6248,24 @@
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date); document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
document.getElementById('subscriptionStatusText').textContent = subscription.status || '-'; document.getElementById('subscriptionStatusText').textContent = subscription.status || '-';
// New fields
const periodStartEl = document.getElementById('subscriptionPeriodStart');
const nextInvoiceEl = document.getElementById('subscriptionNextInvoice');
if (periodStartEl) {
periodStartEl.textContent = subscription.period_start ? formatSubscriptionDate(subscription.period_start) : '-';
}
if (nextInvoiceEl) {
const nextDate = subscription.next_invoice_date ? formatSubscriptionDate(subscription.next_invoice_date) : '-';
nextInvoiceEl.textContent = nextDate;
// Highlight if invoice is due soon
if (subscription.next_invoice_date) {
const daysUntil = Math.ceil((new Date(subscription.next_invoice_date) - new Date()) / (1000 * 60 * 60 * 24));
if (daysUntil <= 7 && daysUntil >= 0) {
nextInvoiceEl.innerHTML = `${nextDate} <span class="badge bg-warning text-dark">Om ${daysUntil} dage</span>`;
}
}
}
setSubscriptionBadge(subscription.status); setSubscriptionBadge(subscription.status);
const itemsBody = document.getElementById('subscriptionItemsBody'); const itemsBody = document.getElementById('subscriptionItemsBody');
@ -5898,7 +6308,7 @@
async function loadSubscriptionForCase() { async function loadSubscriptionForCase() {
try { try {
const res = await fetch(`/api/v1/subscriptions/by-sag/${subscriptionCaseId}`); const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`);
if (res.status === 404) { if (res.status === 404) {
showSubscriptionCreateForm(); showSubscriptionCreateForm();
setModuleContentState('subscription', false); setModuleContentState('subscription', false);
@ -5935,7 +6345,7 @@
} }
try { try {
const res = await fetch('/api/v1/subscriptions', { const res = await fetch('/api/v1/sag-subscriptions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -5967,7 +6377,7 @@
} }
try { try {
const res = await fetch(`/api/v1/subscriptions/${currentSubscription.id}/status`, { const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }) body: JSON.stringify({ status })
@ -5989,6 +6399,99 @@
loadSubscriptionProducts(); loadSubscriptionProducts();
loadSubscriptionForCase(); loadSubscriptionForCase();
}); });
// === Quick Time Entry Functions (for inline time tracking) ===
function toggleQuickTimeForm() {
const container = document.getElementById('quickTimeFormContainer');
if (container) {
container.classList.remove('d-none');
}
}
// Make function globally available for onclick handler
window.toggleQuickTimeForm = toggleQuickTimeForm;
async function quickAddTime(event) {
event.preventDefault();
const form = document.getElementById('quickAddTimeForm');
const formData = new FormData(form);
// Parse hours and minutes
const hours = parseInt(formData.get('hours')) || 0;
const minutes = parseInt(formData.get('minutes')) || 0;
const totalHours = hours + (minutes / 60);
if (totalHours === 0) {
alert('Angiv venligst timer eller minutter');
return;
}
const billingSelect = document.getElementById('quickTimeBillingMethod');
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
let prepaidCardId = null;
let fixedPriceAgreementId = null;
if (billingMethod.startsWith('card_')) {
prepaidCardId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'prepaid';
}
if (billingMethod.startsWith('fpa_')) {
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'fixed_price';
}
const isInternal = billingMethod === 'internal';
// Build payload
const payload = {
sag_id: {{ case.id }},
worked_date: formData.get('date'),
original_hours: totalHours,
description: formData.get('description'),
billing_method: billingMethod,
is_internal: isInternal
};
if (prepaidCardId) {
payload.prepaid_card_id = prepaidCardId;
}
if (fixedPriceAgreementId) {
payload.fixed_price_agreement_id = fixedPriceAgreementId;
}
try {
const response = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke gemme tidsregistrering');
}
// Success - reload page to show new entry
window.location.reload();
} catch (error) {
alert('Fejl: ' + error.message);
console.error('Quick add time error:', error);
}
}
// Set today's date as default for quick time form
document.addEventListener('DOMContentLoaded', function() {
const dateInput = document.getElementById('quickTimeDate');
if (dateInput && !dateInput.value) {
const today = new Date().toISOString().split('T')[0];
dateInput.value = today;
}
});
</script> </script>
</div> </div>

View File

@ -212,13 +212,28 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ansvarlig_bruger_id">Ansvarlig Bruger (valgfrit)</label> <label for="ansvarlig_bruger_id">Ansvarlig medarbejder</label>
<input type="number" class="form-control" id="ansvarlig_bruger_id" placeholder="Brugers ID" value="{{ case.ansvarlig_bruger_id or '' }}"> <select class="form-select" id="ansvarlig_bruger_id">
<option value="">Ingen</option>
{% for user in assignment_users or [] %}
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="assigned_group_id">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id">
<option value="">Ingen</option>
{% for group in assignment_groups or [] %}
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="deadline">Deadline (valgfrit)</label> <label for="deadline">Deadline (valgfrit)</label>
<input type="datetime-local" class="form-control" id="deadline" value="{{ (case.deadline | string | truncate(19, True, '')) if case.deadline else '' }}"> <input type="datetime-local" class="form-control" id="deadline" value="{{ case.deadline.strftime('%Y-%m-%dT%H:%M') if case.deadline else '' }}">
</div> </div>
<div class="button-group"> <div class="button-group">
@ -278,6 +293,7 @@
type: type, type: type,
status: status, status: status,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null, ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
deadline: document.getElementById('deadline').value || null deadline: document.getElementById('deadline').value || null
}; };

View File

@ -298,6 +298,27 @@
<option value="all">Alle typer</option> <option value="all">Alle typer</option>
</select> </select>
</div> </div>
<form id="assignmentFilterForm" class="d-flex flex-wrap gap-2 align-items-center" method="get" action="/sag">
<div style="min-width: 220px;">
<select class="form-select" name="ansvarlig_bruger_id" id="assigneeFilter">
<option value="">Alle medarbejdere</option>
{% for user in assignment_users or [] %}
<option value="{{ user.user_id }}" {% if current_ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div style="min-width: 220px;">
<select class="form-select" name="assigned_group_id" id="groupFilter">
<option value="">Alle grupper</option>
{% for group in assignment_groups or [] %}
<option value="{{ group.id }}" {% if current_assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</select>
</div>
{% if include_deferred %}
<input type="hidden" name="include_deferred" value="1">
{% endif %}
</form>
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}"> <a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %} {% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
</a> </a>
@ -314,6 +335,8 @@
<th style="width: 120px;">Type</th> <th style="width: 120px;">Type</th>
<th style="width: 180px;">Kunde</th> <th style="width: 180px;">Kunde</th>
<th style="width: 150px;">Hovedkontakt</th> <th style="width: 150px;">Hovedkontakt</th>
<th style="width: 160px;">Ansvarlig</th>
<th style="width: 160px;">Gruppe</th>
<th style="width: 100px;">Status</th> <th style="width: 100px;">Status</th>
<th style="width: 120px;">Udsat start</th> <th style="width: 120px;">Udsat start</th>
<th style="width: 120px;">Oprettet</th> <th style="width: 120px;">Oprettet</th>
@ -327,7 +350,7 @@
<tr class="tree-row {% if has_relations %}has-children{% endif %}" <tr class="tree-row {% if has_relations %}has-children{% endif %}"
data-sag-id="{{ sag.id }}" data-sag-id="{{ sag.id }}"
data-status="{{ sag.status }}" data-status="{{ sag.status }}"
data-type="{{ sag.type or 'ticket' }}"> data-type="{{ sag.template_key or sag.type or 'ticket' }}">
<td> <td>
{% if has_relations %} {% if has_relations %}
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span> <span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
@ -341,7 +364,7 @@
{% endif %} {% endif %}
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'"> <td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="badge bg-light text-dark border">{{ sag.type or 'ticket' }}</span> <span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }} {{ sag.customer_name if sag.customer_name else '-' }}
@ -349,6 +372,12 @@
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }} {{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'"> <td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span> <span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
</td> </td>
@ -369,7 +398,7 @@
{% if related_sag and rel.target_id not in seen_targets %} {% if related_sag and rel.target_id not in seen_targets %}
{% set _ = seen_targets.append(rel.target_id) %} {% set _ = seen_targets.append(rel.target_id) %}
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %} {% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.type or 'ticket' }}" style="display: none;"> <tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" style="display: none;">
<td> <td>
<span class="sag-id">#{{ related_sag.id }}</span> <span class="sag-id">#{{ related_sag.id }}</span>
</td> </td>
@ -383,7 +412,7 @@
{% endif %} {% endif %}
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="badge bg-light text-dark border">{{ related_sag.type or 'ticket' }}</span> <span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }} {{ related_sag.customer_name if related_sag.customer_name else '-' }}
@ -391,6 +420,12 @@
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }} {{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'"> <td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span> <span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
</td> </td>
@ -449,6 +484,17 @@
let currentFilter = 'all'; let currentFilter = 'all';
let currentType = 'all'; let currentType = 'all';
const assigneeFilter = document.getElementById('assigneeFilter');
const groupFilter = document.getElementById('groupFilter');
const assignmentFilterForm = document.getElementById('assignmentFilterForm');
if (assigneeFilter && assignmentFilterForm) {
assigneeFilter.addEventListener('change', () => assignmentFilterForm.submit());
}
if (groupFilter && assignmentFilterForm) {
groupFilter.addEventListener('change', () => assignmentFilterForm.submit());
}
function applyFilters() { function applyFilters() {
const search = currentSearch; const search = currentSearch;
@ -512,14 +558,26 @@
async function loadTypeFilters() { async function loadTypeFilters() {
if (!typeFilter) return; if (!typeFilter) return;
try { try {
const rowTypes = new Set(Array.from(document.querySelectorAll('.tree-row[data-type], .tree-child[data-type]'))
.map((row) => (row.dataset.type || '').trim())
.filter(Boolean));
const res = await fetch('/api/v1/settings/case_types'); const res = await fetch('/api/v1/settings/case_types');
if (!res.ok) return; let configuredTypes = [];
const setting = await res.json(); if (res.ok) {
const types = JSON.parse(setting.value || '[]'); const setting = await res.json();
if (!Array.isArray(types) || types.length === 0) return; const types = JSON.parse(setting.value || '[]');
if (Array.isArray(types)) {
configuredTypes = types;
}
}
configuredTypes.forEach((t) => rowTypes.add(String(t || '').trim()));
const mergedTypes = Array.from(rowTypes).filter(Boolean).sort((a, b) => a.localeCompare(b, 'da'));
if (mergedTypes.length === 0) return;
typeFilter.innerHTML = `<option value="all">Alle typer</option>` + typeFilter.innerHTML = `<option value="all">Alle typer</option>` +
types.map(type => `<option value="${type}">${type}</option>`).join(''); mergedTypes.map(type => `<option value="${type}">${type}</option>`).join('');
} catch (err) { } catch (err) {
console.error('Failed to load case types', err); console.error('Failed to load case types', err);
} }

View File

@ -47,6 +47,29 @@
border: 1px solid rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.1);
background: var(--bg-light); background: var(--bg-light);
} }
.table-secondary {
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.08) !important;
cursor: pointer;
transition: background-color 0.2s;
}
.table-secondary:hover {
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.15) !important;
}
.table-secondary td {
padding: 0.75rem !important;
}
.case-lines-container {
display: none;
}
.case-lines-container.show {
display: table-row;
}
.expand-icon {
transition: transform 0.3s;
}
.expand-icon.expanded {
transform: rotate(90deg);
}
</style> </style>
{% endblock %} {% endblock %}
@ -142,9 +165,9 @@
<table class="table table-hover mb-0" style="vertical-align: middle;"> <table class="table table-hover mb-0" style="vertical-align: middle;">
<thead> <thead>
<tr> <tr>
<th style="width: 30px;"></th>
<th>Dato</th> <th>Dato</th>
<th>Beskrivelse</th> <th>Beskrivelse</th>
<th>Sag</th>
<th>Kunde</th> <th>Kunde</th>
<th>Antal</th> <th>Antal</th>
<th>Enhed</th> <th>Enhed</th>
@ -172,9 +195,9 @@
<table class="table table-hover mb-0" style="vertical-align: middle;"> <table class="table table-hover mb-0" style="vertical-align: middle;">
<thead> <thead>
<tr> <tr>
<th style="width: 30px;"></th>
<th>Dato</th> <th>Dato</th>
<th>Beskrivelse</th> <th>Beskrivelse</th>
<th>Sag</th>
<th>Kunde</th> <th>Kunde</th>
<th>Antal</th> <th>Antal</th>
<th>Enhed</th> <th>Enhed</th>
@ -216,23 +239,83 @@
return; return;
} }
tbody.innerHTML = items.map(item => { // Group items by case (sag_id)
const statusLabel = item.status || 'draft'; const grouped = {};
const caseLink = item.sag_id ? `<a href="/sag/${item.sag_id}" class="text-decoration-none">${item.sag_titel || 'Sag ' + item.sag_id}</a>` : '-'; items.forEach((item, originalIndex) => {
return ` const caseKey = item.sag_id || 'ingen-sag';
<tr> if (!grouped[caseKey]) {
<td>${item.line_date || '-'}</td> grouped[caseKey] = {
<td>${item.description || '-'}</td> sag_id: item.sag_id || null,
<td>${caseLink}</td> sag_titel: item.sag_titel || 'Ingen sag',
<td>${item.customer_name || '-'}</td> items: []
<td>${item.quantity ?? '-'}</td> };
<td>${item.unit || '-'}</td> }
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td> grouped[caseKey].items.push({ ...item, originalIndex });
<td class="fw-bold">${formatCurrency(item.amount)}</td> });
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
// Render grouped rows with collapsible structure
let html = '';
Object.keys(grouped).forEach((caseKey, groupIndex) => {
const group = grouped[caseKey];
const groupTotal = group.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const tableId = tbodyId.replace('Body', ''); // Extract table identifier
const groupId = `${tableId}-case-${groupIndex}`;
// Case header row (clickable to expand/collapse)
const caseLink = group.sag_id
? `<a href="/sag/${group.sag_id}" class="text-decoration-none fw-bold" onclick="event.stopPropagation();">${group.sag_titel} <span class="badge bg-light text-dark border">Sag ${group.sag_id}</span></a>`
: `<span class="fw-bold">${group.sag_titel}</span>`;
html += `
<tr class="table-secondary" onclick="toggleCaseLines('${groupId}')">
<td>
<i class="bi bi-chevron-right expand-icon" id="icon-${groupId}"></i>
</td>
<td colspan="8">
<div class="d-flex justify-content-between align-items-center">
<div>${caseLink}</div>
<div class="text-end">
<span class="badge bg-primary me-2">${group.items.length} ${group.items.length === 1 ? 'linje' : 'linjer'}</span>
<span class="fw-bold">${formatCurrency(groupTotal)}</span>
</div>
</div>
</td>
</tr> </tr>
`; `;
}).join('');
// Render lines in this case (hidden by default)
group.items.forEach(item => {
const statusLabel = item.status || 'draft';
html += `
<tr class="case-lines-container" data-case="${groupId}">
<td></td>
<td>${item.line_date || '-'}</td>
<td>${item.description || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td>${item.quantity ?? '-'}</td>
<td>${item.unit || '-'}</td>
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
<td class="fw-bold">${formatCurrency(item.amount)}</td>
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
</tr>
`;
});
});
tbody.innerHTML = html;
}
function toggleCaseLines(caseId) {
const lines = document.querySelectorAll(`tr[data-case="${caseId}"]`);
const icon = document.getElementById(`icon-${caseId}`);
lines.forEach(line => {
line.classList.toggle('show');
});
if (icon) {
icon.classList.toggle('expanded');
}
} }
async function loadOrders() { async function loadOrders() {

View File

@ -217,6 +217,11 @@ async def yealink_established(
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8) kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
kontakt_id = kontakt.get("id") if kontakt else None kontakt_id = kontakt.get("id") if kontakt else None
# Get extended contact details if we found a contact
contact_details = {}
if kontakt_id:
contact_details = TelefoniService.get_contact_details(kontakt_id)
payload = { payload = {
"callid": resolved_callid, "callid": resolved_callid,
"call_id": call_id, "call_id": call_id,
@ -252,6 +257,8 @@ async def yealink_established(
"number": ekstern_e164 or (ekstern_raw or ""), "number": ekstern_e164 or (ekstern_raw or ""),
"direction": direction, "direction": direction,
"contact": kontakt, "contact": kontakt,
"recent_cases": contact_details.get("recent_cases", []),
"last_call": contact_details.get("last_call"),
}, },
) )
else: else:
@ -395,6 +402,15 @@ async def telefoni_test_popup(
"name": "Test popup", "name": "Test popup",
"company": "BMC Hub", "company": "BMC Hub",
}, },
"recent_cases": [
{"id": 1, "titel": "Test sag 1", "created_at": datetime.utcnow()},
{"id": 2, "titel": "Test sag 2", "created_at": datetime.utcnow()},
],
"last_call": {
"started_at": datetime.utcnow(),
"bruger_navn": "Test Medarbejder",
"duration_sec": 125,
},
}, },
) )
return { return {

View File

@ -109,3 +109,67 @@ class TelefoniService:
(duration_sec, callid), (duration_sec, callid),
) )
return bool(rows) return bool(rows)
@staticmethod
def get_contact_details(contact_id: int) -> dict:
"""
Get extended contact details including:
- Latest 3 open cases
- Last call date
"""
if not contact_id:
return {"recent_cases": [], "last_call": None}
# Get the 3 newest open cases for this contact
cases_query = """
SELECT
s.id,
s.titel,
s.created_at
FROM sag_sager s
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
WHERE sk.contact_id = %s
AND s.status = 'åben'
AND s.deleted_at IS NULL
AND sk.deleted_at IS NULL
ORDER BY s.created_at DESC
LIMIT 3
"""
cases = execute_query(cases_query, (contact_id,)) or []
# Get the most recent call for this contact
last_call_query = """
SELECT
t.started_at,
t.bruger_id,
t.duration_sec,
u.full_name,
u.username
FROM telefoni_opkald t
LEFT JOIN users u ON t.bruger_id = u.user_id
WHERE t.kontakt_id = %s
AND t.ended_at IS NOT NULL
ORDER BY t.started_at DESC
LIMIT 1
"""
last_call_row = execute_query_single(last_call_query, (contact_id,))
last_call_data = None
if last_call_row:
last_call_data = {
"started_at": last_call_row.get("started_at"),
"bruger_navn": last_call_row.get("full_name") or last_call_row.get("username"),
"duration_sec": last_call_row.get("duration_sec"),
}
return {
"recent_cases": [
{
"id": case["id"],
"titel": case["titel"],
"created_at": case["created_at"],
}
for case in cases
],
"last_call": last_call_data,
}

View File

@ -39,18 +39,28 @@ async def list_opportunities(
s.customer_id, s.customer_id,
COALESCE(c.name, 'Ukendt kunde') as customer_name, COALESCE(c.name, 'Ukendt kunde') as customer_name,
s.ansvarlig_bruger_id, s.ansvarlig_bruger_id,
COALESCE(u.first_name || ' ' || COALESCE(u.last_name, ''), 'Ingen') as ansvarlig_navn COALESCE(u.full_name, u.username, 'Ingen') as ansvarlig_navn
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON s.ansvarlig_bruger_id = u.id LEFT JOIN users u ON s.ansvarlig_bruger_id = u.user_id
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND ( AND (
s.template_key = 'pipeline' s.template_key = 'pipeline'
OR EXISTS ( OR EXISTS (
SELECT 1 FROM sag_tags st SELECT 1
JOIN tags t ON st.tag_id = t.id FROM entity_tags et
WHERE st.sag_id = s.id AND t.name = 'pipeline' JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'case'
AND et.entity_id = s.id
AND LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
) )
) )
""" """
@ -136,12 +146,26 @@ async def create_opportunity(
@router.get("/pipeline/stages", tags=["Opportunities"]) @router.get("/pipeline/stages", tags=["Opportunities"])
async def list_pipeline_stages(): async def list_pipeline_stages():
""" """List available pipeline stages from DB with a safe static fallback."""
Legacy endpoint for stages. try:
Returns static stages mapped to Case statuses for compatibility. stages = execute_query(
""" """
SELECT id, name, color, sort_order
FROM pipeline_stages
WHERE COALESCE(is_active, TRUE) = TRUE
ORDER BY sort_order ASC, id ASC
"""
)
if stages:
return stages
except Exception as e:
logger.warning("Could not load pipeline stages from DB: %s", e)
return [ return [
{"id": "open", "name": "Åben"}, {"id": 1, "name": "Lead", "color": "#6c757d", "sort_order": 10},
{"id": "won", "name": "Vundet"}, {"id": 2, "name": "Kontakt", "color": "#17a2b8", "sort_order": 20},
{"id": "lost", "name": "Tabt"} {"id": 3, "name": "Tilbud", "color": "#ffc107", "sort_order": 30},
{"id": 4, "name": "Forhandling", "color": "#fd7e14", "sort_order": 40},
{"id": 5, "name": "Vundet", "color": "#28a745", "sort_order": 50},
{"id": 6, "name": "Tabt", "color": "#dc3545", "sort_order": 60},
] ]

View File

@ -10,6 +10,7 @@ import logging
import os import os
import aiohttp import aiohttp
import json import json
import asyncio
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -22,6 +23,57 @@ def _apigw_headers() -> Dict[str, str]:
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
def _apigw_base_url() -> str:
base_url = (
settings.APIGW_BASE_URL
or settings.APIGATEWAY_URL
or os.getenv("APIGW_BASE_URL")
or os.getenv("APIGATEWAY_URL")
or ""
).strip()
if not base_url:
raise HTTPException(status_code=500, detail="API Gateway base URL is not configured")
return base_url.rstrip("/")
def _extract_error_detail(payload: Any) -> str:
if payload is None:
return ""
if isinstance(payload, str):
return payload.strip()
if isinstance(payload, dict):
for key in ("detail", "message", "error", "description"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return json.dumps(payload, ensure_ascii=False)
if isinstance(payload, list):
parts = [_extract_error_detail(item) for item in payload]
cleaned = [part for part in parts if part]
return "; ".join(cleaned)
return str(payload).strip()
async def _read_apigw_error(response: aiohttp.ClientResponse) -> str:
try:
body = await response.json(content_type=None)
detail = _extract_error_detail(body)
if detail:
return detail
except Exception:
pass
text = (await response.text() or "").strip()
if text:
return text
return f"API Gateway request failed (HTTP {response.status})"
def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: str = "manual") -> Dict[str, Any]: def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: str = "manual") -> Dict[str, Any]:
supplier_name = payload.get("supplier_name") supplier_name = payload.get("supplier_name")
supplier_code = payload.get("supplier_code") supplier_code = payload.get("supplier_code")
@ -157,7 +209,7 @@ def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens:
supplier = str(product.get("supplier_name") or "") supplier = str(product.get("supplier_name") or "")
haystack = " ".join( haystack = " ".join(
"".join(ch.lower() if ch.isalnum() else " " for ch in value).split() " ".join("".join(ch.lower() if ch.isalnum() else " " for ch in value).split())
for value in (name, sku, manufacturer, category, supplier) for value in (name, sku, manufacturer, category, supplier)
if value if value
) )
@ -220,8 +272,7 @@ async def search_apigw_products(
if not params: if not params:
raise HTTPException(status_code=400, detail="Provide at least one search parameter") raise HTTPException(status_code=400, detail="Provide at least one search parameter")
base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL url = f"{_apigw_base_url()}/api/v1/products/search"
url = f"{base_url.rstrip('/')}/api/v1/products/search"
logger.info("🔍 APIGW product search: %s", params) logger.info("🔍 APIGW product search: %s", params)
timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS) timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
@ -229,8 +280,11 @@ async def search_apigw_products(
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=_apigw_headers(), params=params) as response: async with session.get(url, headers=_apigw_headers(), params=params) as response:
if response.status >= 400: if response.status >= 400:
detail = await response.text() detail = await _read_apigw_error(response)
raise HTTPException(status_code=response.status, detail=detail) raise HTTPException(
status_code=502,
detail=f"API Gateway product search failed ({response.status}): {detail}",
)
data = await response.json() data = await response.json()
if q and isinstance(data, dict) and isinstance(data.get("products"), list): if q and isinstance(data, dict) and isinstance(data.get("products"), list):
@ -243,6 +297,12 @@ async def search_apigw_products(
return data return data
except HTTPException: except HTTPException:
raise raise
except asyncio.TimeoutError:
logger.error("❌ APIGW product search timeout for params: %s", params, exc_info=True)
raise HTTPException(status_code=504, detail="API Gateway product search timed out")
except aiohttp.ClientError as e:
logger.error("❌ APIGW product search connection error: %s", e, exc_info=True)
raise HTTPException(status_code=502, detail=f"API Gateway connection failed: {e}")
except Exception as e: except Exception as e:
logger.error("❌ Error searching APIGW products: %s", e, exc_info=True) logger.error("❌ Error searching APIGW products: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@ -348,7 +348,14 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Type</label> <label class="form-label">Type</label>
<input type="text" class="form-control" id="productType" placeholder="subscription, service, hardware"> <select class="form-select" id="productType">
<option value="">- Vaelg type -</option>
<option value="hardware">Hardware</option>
<option value="service">Service</option>
<option value="subscription">Abonnement</option>
<option value="bundle">Bundle</option>
</select>
<div class="form-text">Vaelg produkttype for korrekt kategorisering.</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Status</label> <label class="form-label">Status</label>
@ -359,15 +366,25 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">SKU</label> <label class="form-label">SKU</label>
<input type="text" class="form-control" id="productSku"> <input type="text" class="form-control" id="productSku" placeholder="Internt varenummer">
</div>
<div class="col-md-6">
<label class="form-label">EAN</label>
<input type="text" class="form-control" id="productEan" placeholder="Fx 5701234567890" inputmode="numeric">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Salgspris</label> <label class="form-label">Salgspris</label>
<input type="number" class="form-control" id="productSalesPrice" step="0.01" min="0"> <div class="input-group">
<input type="number" class="form-control" id="productSalesPrice" step="0.01" min="0" placeholder="0,00">
<span class="input-group-text">DKK</span>
</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Kostpris</label> <label class="form-label">Købspris</label>
<input type="number" class="form-control" id="productCostPrice" step="0.01" min="0"> <div class="input-group">
<input type="number" class="form-control" id="productCostPrice" step="0.01" min="0" placeholder="0,00">
<span class="input-group-text">DKK</span>
</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Moms (%)</label> <label class="form-label">Moms (%)</label>
@ -685,6 +702,7 @@ async function createProduct() {
type: document.getElementById('productType').value.trim() || null, type: document.getElementById('productType').value.trim() || null,
status: document.getElementById('productStatus').value, status: document.getElementById('productStatus').value,
sku_internal: document.getElementById('productSku').value.trim() || null, sku_internal: document.getElementById('productSku').value.trim() || null,
ean: document.getElementById('productEan').value.trim() || null,
sales_price: document.getElementById('productSalesPrice').value || null, sales_price: document.getElementById('productSalesPrice').value || null,
cost_price: document.getElementById('productCostPrice').value || null, cost_price: document.getElementById('productCostPrice').value || null,
vat_rate: document.getElementById('productVatRate').value || null, vat_rate: document.getElementById('productVatRate').value || null,

View File

@ -29,6 +29,8 @@ class SimplyCRMService:
self.session_name: Optional[str] = None self.session_name: Optional[str] = None
self.session: Optional[aiohttp.ClientSession] = None self.session: Optional[aiohttp.ClientSession] = None
self.last_query_error: Optional[Dict[str, Any]] = None
self._denied_relation_fields: set[str] = set()
if not all([self.base_url, self.username, self.access_key]): if not all([self.base_url, self.username, self.access_key]):
logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)") logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)")
@ -169,14 +171,20 @@ class SimplyCRMService:
data = await response.json() data = await response.json()
if not data.get("success"): if not data.get("success"):
error = data.get("error", {}) error = data.get("error", {})
logger.error(f"❌ Simply-CRM query error: {error}") self.last_query_error = error if isinstance(error, dict) else {"message": str(error)}
if (self.last_query_error or {}).get("code") == "ACCESS_DENIED":
logger.warning(f"⚠️ Simply-CRM query access denied: {error}")
else:
logger.error(f"❌ Simply-CRM query error: {error}")
return [] return []
result = data.get("result", []) result = data.get("result", [])
self.last_query_error = None
logger.debug(f"✅ Simply-CRM query returned {len(result)} records") logger.debug(f"✅ Simply-CRM query returned {len(result)} records")
return result return result
except Exception as e: except Exception as e:
self.last_query_error = {"message": str(e)}
logger.error(f"❌ Simply-CRM query error: {e}") logger.error(f"❌ Simply-CRM query error: {e}")
return [] return []
@ -224,8 +232,26 @@ class SimplyCRMService:
""" """
module_name = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_MODULE", "ModComments") module_name = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_MODULE", "ModComments")
relation_field = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_RELATION_FIELD", "related_to") relation_field = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_RELATION_FIELD", "related_to")
query = f"SELECT * FROM {module_name} WHERE {relation_field} = '{ticket_id}' ORDER BY createdtime ASC;" relation_candidates: List[str] = []
return await self.query(query) for candidate in [relation_field, "parent_id", "ticket_id", "crmid", "related_to"]:
if candidate and candidate not in relation_candidates:
relation_candidates.append(candidate)
for candidate in relation_candidates:
if candidate in self._denied_relation_fields:
continue
query = f"SELECT * FROM {module_name} WHERE {candidate} = '{ticket_id}' ORDER BY createdtime ASC;"
result = await self.query(query)
error_code = (self.last_query_error or {}).get("code")
if error_code == "ACCESS_DENIED":
self._denied_relation_fields.add(candidate)
continue
return result
return []
async def fetch_ticket_emails(self, ticket_id: str) -> List[Dict]: async def fetch_ticket_emails(self, ticket_id: str) -> List[Dict]:
""" """
@ -242,12 +268,26 @@ class SimplyCRMService:
fallback_relation_field = getattr(settings, "SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD", "related_to") fallback_relation_field = getattr(settings, "SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD", "related_to")
records: List[Dict] = [] records: List[Dict] = []
query = f"SELECT * FROM {module_name} WHERE {relation_field} = '{ticket_id}';" relation_candidates: List[str] = []
records.extend(await self.query(query)) for candidate in [relation_field, fallback_relation_field, "parent_id", "ticket_id", "crmid"]:
if candidate and candidate not in relation_candidates:
relation_candidates.append(candidate)
if not records and fallback_relation_field: for candidate in relation_candidates:
query = f"SELECT * FROM {module_name} WHERE {fallback_relation_field} = '{ticket_id}';" if candidate in self._denied_relation_fields:
records.extend(await self.query(query)) continue
query = f"SELECT * FROM {module_name} WHERE {candidate} = '{ticket_id}';"
batch = await self.query(query)
error_code = (self.last_query_error or {}).get("code")
if error_code == "ACCESS_DENIED":
self._denied_relation_fields.add(candidate)
continue
records.extend(batch)
if records:
break
# De-duplicate by record id if present # De-duplicate by record id if present
seen_ids = set() seen_ids = set()

View File

@ -28,6 +28,9 @@ class VTigerService:
if not all([self.base_url, self.username, self.api_key]): if not all([self.base_url, self.username, self.api_key]):
logger.warning("⚠️ vTiger credentials not fully configured") logger.warning("⚠️ vTiger credentials not fully configured")
self.last_query_status: Optional[int] = None
self.last_query_error: Optional[Dict] = None
def _get_auth(self): def _get_auth(self):
"""Get HTTP Basic Auth credentials""" """Get HTTP Basic Auth credentials"""
if not self.api_key: if not self.api_key:
@ -49,6 +52,8 @@ class VTigerService:
try: try:
auth = self._get_auth() auth = self._get_auth()
self.last_query_status = None
self.last_query_error = None
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
f"{self.rest_endpoint}/query", f"{self.rest_endpoint}/query",
@ -56,6 +61,7 @@ class VTigerService:
auth=auth auth=auth
) as response: ) as response:
text = await response.text() text = await response.text()
self.last_query_status = response.status
if response.status == 200: if response.status == 200:
# vTiger returns text/json instead of application/json # vTiger returns text/json instead of application/json
@ -69,16 +75,28 @@ class VTigerService:
if data.get('success'): if data.get('success'):
result = data.get('result', []) result = data.get('result', [])
logger.info(f"✅ Query returned {len(result)} records") logger.info(f"✅ Query returned {len(result)} records")
self.last_query_error = None
return result return result
else: else:
logger.error(f"❌ vTiger query failed: {data.get('error')}") self.last_query_error = data.get('error')
logger.error(f"❌ vTiger query failed: {self.last_query_error}")
return [] return []
else: else:
logger.error(f"❌ vTiger query HTTP error {response.status}") try:
parsed = json.loads(text) if text else {}
self.last_query_error = parsed.get('error')
except Exception:
self.last_query_error = None
if response.status == 429:
logger.warning(f"⚠️ vTiger query rate-limited (HTTP {response.status})")
else:
logger.error(f"❌ vTiger query HTTP error {response.status}")
logger.error(f"Query: {query_string}") logger.error(f"Query: {query_string}")
logger.error(f"Response: {text[:500]}") logger.error(f"Response: {text[:500]}")
return [] return []
except Exception as e: except Exception as e:
self.last_query_status = None
self.last_query_error = {"message": str(e)}
logger.error(f"❌ vTiger query error: {e}") logger.error(f"❌ vTiger query error: {e}")
return [] return []

View File

@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db_connection, release_db_connection from app.core.database import get_db_connection, release_db_connection, execute_query_single
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
@ -19,9 +19,27 @@ templates = Jinja2Templates(directory="app")
@router.get("/settings", response_class=HTMLResponse, tags=["Frontend"]) @router.get("/settings", response_class=HTMLResponse, tags=["Frontend"])
async def settings_page(request: Request): async def settings_page(request: Request):
"""Render settings page""" """Render settings page"""
default_dashboard_path = ""
user_id = getattr(request.state, "user_id", None)
if user_id:
try:
row = execute_query_single(
"""
SELECT default_dashboard_path
FROM user_dashboard_preferences
WHERE user_id = %s
""",
(int(user_id),)
)
default_dashboard_path = (row or {}).get("default_dashboard_path") or ""
except Exception:
default_dashboard_path = ""
return templates.TemplateResponse("settings/frontend/settings.html", { return templates.TemplateResponse("settings/frontend/settings.html", {
"request": request, "request": request,
"title": "Indstillinger" "title": "Indstillinger",
"default_dashboard_path": default_dashboard_path
}) })

View File

@ -1020,6 +1020,40 @@ async def scan_document(file_path: str):
<!-- System Settings --> <!-- System Settings -->
<div class="tab-pane fade" id="system"> <div class="tab-pane fade" id="system">
<div class="card p-4 mb-4">
<h5 class="mb-3 fw-bold">Standard Dashboard</h5>
<p class="text-muted mb-3">Dashboard vises altid fra roden af sitet via <code>/</code>. Vælg her hvilken side der skal åbnes som dit standard-dashboard.</p>
<form method="post" action="/dashboard/default" class="row g-2 align-items-end">
<div class="col-lg-8">
<label class="form-label small text-muted" for="defaultDashboardPathInput">Dashboard</label>
<select id="defaultDashboardPathInput" name="dashboard_path" class="form-select" required>
<option value="/ticket/dashboard/technician/v1" {% if (default_dashboard_path or '/ticket/dashboard/technician/v1') == '/ticket/dashboard/technician/v1' %}selected{% endif %}>Tekniker Dashboard V1</option>
<option value="/ticket/dashboard/technician/v2" {% if default_dashboard_path == '/ticket/dashboard/technician/v2' %}selected{% endif %}>Tekniker Dashboard V2</option>
<option value="/ticket/dashboard/technician/v3" {% if default_dashboard_path == '/ticket/dashboard/technician/v3' %}selected{% endif %}>Tekniker Dashboard V3</option>
<option value="/dashboard/sales" {% if default_dashboard_path == '/dashboard/sales' %}selected{% endif %}>Salg Dashboard</option>
{% if default_dashboard_path and default_dashboard_path not in ['/ticket/dashboard/technician/v1', '/ticket/dashboard/technician/v2', '/ticket/dashboard/technician/v3', '/dashboard/sales'] %}
<option value="{{ default_dashboard_path }}" selected>Nuværende (tilpasset): {{ default_dashboard_path }}</option>
{% endif %}
</select>
<div class="form-text">Vælg et gyldigt dashboard fra listen.</div>
</div>
<div class="col-lg-4 d-flex gap-2">
<input type="hidden" name="redirect_to" value="/settings#system">
<button class="btn btn-primary" type="submit">
<i class="bi bi-save me-2"></i>Gem standard
</button>
</div>
</form>
<div class="d-flex gap-2 mt-3 flex-wrap">
<form method="post" action="/dashboard/default/clear" class="d-inline">
<input type="hidden" name="redirect_to" value="/settings#system">
<button class="btn btn-sm btn-outline-secondary" type="submit">Ryd standard</button>
</form>
</div>
</div>
<div class="card p-4"> <div class="card p-4">
<h5 class="mb-4 fw-bold">System Indstillinger</h5> <h5 class="mb-4 fw-bold">System Indstillinger</h5>
<div id="systemSettings"> <div id="systemSettings">

View File

@ -261,7 +261,7 @@
</a> </a>
<ul class="dropdown-menu mt-2"> <ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="#">Tilbud</a></li> <li><a class="dropdown-item py-2" href="#">Tilbud</a></li>
<li><a class="dropdown-item py-2" href="#">Ordre</a></li> <li><a class="dropdown-item py-2" href="/ordre"><i class="bi bi-receipt me-2"></i>Ordre</a></li>
<li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li> <li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li> <li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
@ -523,7 +523,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/tag-picker.js?v=2.0"></script> <script src="/static/js/tag-picker.js?v=2.0"></script>
<script src="/static/js/notifications.js?v=1.0"></script> <script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=1.0"></script> <script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/sms.js?v=1.0"></script> <script src="/static/js/sms.js?v=1.0"></script>
<script> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic

View File

@ -3,19 +3,104 @@ Subscriptions API
Sag-based subscriptions listing and stats Sag-based subscriptions listing and stats
""" """
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import List, Dict, Any from typing import List, Dict, Any, Optional
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
import logging import logging
import hashlib
import json
from uuid import uuid4
from datetime import datetime, date, timedelta
from dateutil.relativedelta import relativedelta
from fastapi import Request
from app.services.simplycrm_service import SimplyCRMService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"} ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))"
@router.get("/subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any]) def _staging_status_with_mapping(status: str, has_customer: bool) -> str:
if status == "approved":
return "approved"
return "mapped" if has_customer else "pending"
def _safe_date(value: Optional[Any]) -> Optional[date]:
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 _simply_to_hub_interval(frequency: Optional[str]) -> str:
normalized = (frequency or "").strip().lower()
mapping = {
"daily": "daily",
"biweekly": "biweekly",
"weekly": "biweekly",
"monthly": "monthly",
"quarterly": "quarterly",
"yearly": "yearly",
"annually": "yearly",
"semi_annual": "yearly",
}
return mapping.get(normalized, "monthly")
def _next_invoice_date(start_date: date, interval: str) -> date:
if interval == "daily":
return start_date + timedelta(days=1)
if interval == "biweekly":
return start_date + timedelta(days=14)
if interval == "quarterly":
return start_date + relativedelta(months=3)
if interval == "yearly":
return start_date + relativedelta(years=1)
return start_date + relativedelta(months=1)
def _auto_map_customer(account_id: Optional[str], customer_name: Optional[str], customer_cvr: Optional[str]) -> Optional[int]:
if account_id:
row = execute_query_single(
"SELECT id FROM customers WHERE vtiger_id = %s LIMIT 1",
(account_id,)
)
if row and row.get("id"):
return int(row["id"])
if customer_cvr:
row = execute_query_single(
"SELECT id FROM customers WHERE cvr_number = %s LIMIT 1",
(customer_cvr,)
)
if row and row.get("id"):
return int(row["id"])
if customer_name:
row = execute_query_single(
"SELECT id FROM customers WHERE LOWER(name) = LOWER(%s) LIMIT 1",
(customer_name,)
)
if row and row.get("id"):
return int(row["id"])
return None
@router.get("/sag-subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any])
async def get_subscription_by_sag(sag_id: int): async def get_subscription_by_sag(sag_id: int):
"""Get latest subscription for a case.""" """Get latest subscription for a case."""
try: try:
@ -72,7 +157,7 @@ async def get_subscription_by_sag(sag_id: int):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/subscriptions", response_model=Dict[str, Any]) @router.post("/sag-subscriptions", response_model=Dict[str, Any])
async def create_subscription(payload: Dict[str, Any]): async def create_subscription(payload: Dict[str, Any]):
"""Create a new subscription tied to a case (status = draft).""" """Create a new subscription tied to a case (status = draft)."""
try: try:
@ -158,6 +243,25 @@ async def create_subscription(payload: Dict[str, Any]):
if len(cleaned_items) > 1: if len(cleaned_items) > 1:
product_name = f"{product_name} (+{len(cleaned_items) - 1})" product_name = f"{product_name} (+{len(cleaned_items) - 1})"
# Calculate next_invoice_date based on billing_interval
start_dt = datetime.strptime(start_date, "%Y-%m-%d").date()
period_start = start_dt
# Calculate next invoice date
if billing_interval == "daily":
next_invoice_date = start_dt + timedelta(days=1)
elif billing_interval == "biweekly":
next_invoice_date = start_dt + timedelta(days=14)
elif billing_interval == "monthly":
next_invoice_date = start_dt + relativedelta(months=1)
elif billing_interval == "quarterly":
next_invoice_date = start_dt + relativedelta(months=3)
elif billing_interval == "yearly":
next_invoice_date = start_dt + relativedelta(years=1)
else:
next_invoice_date = start_dt + relativedelta(months=1) # Default to monthly
conn = get_db_connection() conn = get_db_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor: with conn.cursor(cursor_factory=RealDictCursor) as cursor:
@ -171,9 +275,11 @@ async def create_subscription(payload: Dict[str, Any]):
billing_day, billing_day,
price, price,
start_date, start_date,
period_start,
next_invoice_date,
status, status,
notes notes
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'draft', %s) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING * RETURNING *
""", """,
( (
@ -184,6 +290,8 @@ async def create_subscription(payload: Dict[str, Any]):
billing_day, billing_day,
total_price, total_price,
start_date, start_date,
period_start,
next_invoice_date,
notes, notes,
) )
) )
@ -226,7 +334,165 @@ async def create_subscription(payload: Dict[str, Any]):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.patch("/subscriptions/{subscription_id}/status", response_model=Dict[str, Any]) @router.get("/sag-subscriptions/{subscription_id}", response_model=Dict[str, Any])
async def get_subscription(subscription_id: int):
"""Get a single subscription by ID with all details."""
try:
query = """
SELECT
s.id,
s.subscription_number,
s.sag_id,
sg.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_day,
s.price,
s.start_date,
s.end_date,
s.next_invoice_date,
s.period_start,
s.notice_period_days,
s.status,
s.notes,
s.cancelled_at,
s.cancellation_reason,
s.created_at,
s.updated_at
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.id = %s
"""
subscription = execute_query_single(query, (subscription_id,))
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
# Get line items
items = execute_query(
"""
SELECT
i.id,
i.line_no,
i.product_id,
p.name AS product_name,
i.description,
i.quantity,
i.unit_price,
i.line_total
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
WHERE i.subscription_id = %s
ORDER BY i.line_no ASC, i.id ASC
""",
(subscription_id,)
)
subscription["line_items"] = items or []
return subscription
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error loading subscription: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/sag-subscriptions/{subscription_id}", response_model=Dict[str, Any])
async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
"""Update subscription - all fields editable including line items."""
try:
subscription = execute_query_single(
"SELECT id, status FROM sag_subscriptions WHERE id = %s",
(subscription_id,)
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
# Extract line_items before processing other fields
line_items = payload.pop("line_items", None)
# Build dynamic update query
allowed_fields = {
"product_name", "billing_interval", "billing_day", "price",
"start_date", "end_date", "next_invoice_date", "period_start",
"notice_period_days", "status", "notes"
}
updates = []
values = []
for field, value in payload.items():
if field in allowed_fields:
updates.append(f"{field} = %s")
values.append(value)
# Validate status if provided
if "status" in payload and payload["status"] not in ALLOWED_STATUSES:
raise HTTPException(status_code=400, detail="Invalid status")
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
# Update subscription fields if any
if updates:
values.append(subscription_id)
query = f"""
UPDATE sag_subscriptions
SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
cursor.execute(query, tuple(values))
result = cursor.fetchone()
else:
cursor.execute("SELECT * FROM sag_subscriptions WHERE id = %s", (subscription_id,))
result = cursor.fetchone()
# Update line items if provided
if line_items is not None:
# Delete existing line items
cursor.execute(
"DELETE FROM sag_subscription_items WHERE subscription_id = %s",
(subscription_id,)
)
# Insert new line items
for idx, item in enumerate(line_items, start=1):
description = item.get("description", "").strip()
quantity = float(item.get("quantity", 0))
unit_price = float(item.get("unit_price", 0))
if not description or quantity <= 0:
continue
line_total = quantity * unit_price
cursor.execute(
"""
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)
""",
(
subscription_id, idx, description,
quantity, unit_price, line_total,
item.get("product_id")
)
)
conn.commit()
return result
finally:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating subscription: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/sag-subscriptions/{subscription_id}/status", response_model=Dict[str, Any])
async def update_subscription_status(subscription_id: int, payload: Dict[str, Any]): async def update_subscription_status(subscription_id: int, payload: Dict[str, Any]):
"""Update subscription status.""" """Update subscription status."""
try: try:
@ -251,9 +517,9 @@ async def update_subscription_status(subscription_id: int, payload: Dict[str, An
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/subscriptions", response_model=List[Dict[str, Any]]) @router.get("/sag-subscriptions", response_model=List[Dict[str, Any]])
async def list_subscriptions(status: str = Query("all")): async def list_subscriptions(status: str = Query("all")):
"""List subscriptions by status (default: all).""" """List subscriptions by status (default: all) with line item counts."""
try: try:
where_clause = "" where_clause = ""
params: List[Any] = [] params: List[Any] = []
@ -275,20 +541,28 @@ async def list_subscriptions(status: str = Query("all")):
s.price, s.price,
s.start_date, s.start_date,
s.end_date, s.end_date,
s.status s.status,
(SELECT COUNT(*) FROM sag_subscription_items WHERE subscription_id = s.id) as item_count
FROM sag_subscriptions s FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN customers c ON c.id = s.customer_id
{where_clause} {where_clause}
ORDER BY s.start_date DESC, s.id DESC ORDER BY s.start_date DESC, s.id DESC
""" """
return execute_query(query, tuple(params)) or [] subscriptions = execute_query(query, tuple(params)) or []
# Add line_items array with count for display
for sub in subscriptions:
item_count = sub.get('item_count', 0)
sub['line_items'] = [{'count': item_count}] if item_count > 0 else []
return subscriptions
except Exception as e: except Exception as e:
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True) logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/subscriptions/stats/summary", response_model=Dict[str, Any]) @router.get("/sag-subscriptions/stats/summary", response_model=Dict[str, Any])
async def subscription_stats(status: str = Query("all")): async def subscription_stats(status: str = Query("all")):
"""Summary stats for subscriptions by status (default: all).""" """Summary stats for subscriptions by status (default: all)."""
try: try:
@ -314,3 +588,517 @@ async def subscription_stats(status: str = Query("all")):
except Exception as e: except Exception as e:
logger.error(f"❌ Error loading subscription stats: {e}", exc_info=True) logger.error(f"❌ Error loading subscription stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/sag-subscriptions/process-invoices")
async def trigger_subscription_processing():
"""Manual trigger for subscription invoice processing (for testing)."""
try:
from app.jobs.process_subscriptions import process_subscriptions
await process_subscriptions()
return {"status": "success", "message": "Subscription processing completed"}
except Exception as e:
logger.error(f"❌ Manual subscription processing failed: {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)."""
try:
async with SimplyCRMService() as service:
raw_subscriptions = await service.fetch_active_subscriptions()
import_batch_id = str(uuid4())
account_cache: Dict[str, Dict[str, Any]] = {}
upserted = 0
auto_mapped = 0
for raw in raw_subscriptions:
normalized = service.extract_subscription_data(raw)
source_record_id = str(normalized.get("simplycrm_id") or raw.get("id") or "").strip()
if not source_record_id:
continue
source_account_id = normalized.get("account_id")
source_customer_name = None
source_customer_cvr = None
if source_account_id:
if source_account_id not in account_cache:
account_cache[source_account_id] = await service.fetch_account_by_id(source_account_id) or {}
account = account_cache[source_account_id]
source_customer_name = (account.get("accountname") or "").strip() or None
source_customer_cvr = (account.get("siccode") or account.get("vat_number") or "").strip() or None
if not source_customer_name:
source_customer_name = (raw.get("accountname") or raw.get("account_id") or "").strip() or None
hub_customer_id = _auto_map_customer(source_account_id, source_customer_name, source_customer_cvr)
if hub_customer_id:
auto_mapped += 1
source_status = (normalized.get("status") or "active").strip()
source_subject = (normalized.get("name") or raw.get("subject") or "").strip() or None
source_total_amount = float(normalized.get("total_amount") or normalized.get("subtotal") or 0)
source_currency = (normalized.get("currency") or "DKK").strip() or "DKK"
source_start_date = _safe_date(normalized.get("start_date"))
source_end_date = _safe_date(normalized.get("end_date"))
source_binding_end_date = _safe_date(normalized.get("binding_end_date"))
source_billing_frequency = _simply_to_hub_interval(normalized.get("billing_frequency"))
sync_hash = hashlib.sha256(
json.dumps(raw, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
).hexdigest()
execute_query(
"""
INSERT INTO simply_subscription_staging (
source_system,
source_record_id,
source_account_id,
source_customer_name,
source_customer_cvr,
source_salesorder_no,
source_subject,
source_status,
source_start_date,
source_end_date,
source_binding_end_date,
source_billing_frequency,
source_total_amount,
source_currency,
source_raw,
sync_hash,
hub_customer_id,
approval_status,
import_batch_id,
imported_at,
updated_at
) VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s::jsonb,
%s, %s, %s, %s::uuid, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT (source_system, source_record_id)
DO UPDATE SET
source_account_id = EXCLUDED.source_account_id,
source_customer_name = EXCLUDED.source_customer_name,
source_customer_cvr = EXCLUDED.source_customer_cvr,
source_salesorder_no = EXCLUDED.source_salesorder_no,
source_subject = EXCLUDED.source_subject,
source_status = EXCLUDED.source_status,
source_start_date = EXCLUDED.source_start_date,
source_end_date = EXCLUDED.source_end_date,
source_binding_end_date = EXCLUDED.source_binding_end_date,
source_billing_frequency = EXCLUDED.source_billing_frequency,
source_total_amount = EXCLUDED.source_total_amount,
source_currency = EXCLUDED.source_currency,
source_raw = EXCLUDED.source_raw,
sync_hash = EXCLUDED.sync_hash,
hub_customer_id = COALESCE(simply_subscription_staging.hub_customer_id, EXCLUDED.hub_customer_id),
approval_status = CASE
WHEN simply_subscription_staging.approval_status = 'approved' THEN 'approved'
ELSE %s
END,
approval_error = CASE
WHEN simply_subscription_staging.approval_status = 'approved' THEN simply_subscription_staging.approval_error
ELSE NULL
END,
import_batch_id = EXCLUDED.import_batch_id,
imported_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
""",
(
"simplycrm",
source_record_id,
source_account_id,
source_customer_name,
source_customer_cvr,
normalized.get("salesorder_no"),
source_subject,
source_status,
source_start_date,
source_end_date,
source_binding_end_date,
source_billing_frequency,
source_total_amount,
source_currency,
json.dumps(raw, ensure_ascii=False, default=str),
sync_hash,
hub_customer_id,
_staging_status_with_mapping("pending", bool(hub_customer_id)),
import_batch_id,
_staging_status_with_mapping("pending", bool(hub_customer_id)),
)
)
upserted += 1
return {
"status": "success",
"batch_id": import_batch_id,
"fetched": len(raw_subscriptions),
"upserted": upserted,
"auto_mapped": auto_mapped,
"pending_manual": max(upserted - auto_mapped, 0),
}
except Exception as e:
logger.error(f"❌ Simply staging import failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Could not import subscriptions from Simply CRM")
@router.get("/simply-subscription-staging/customers", response_model=List[Dict[str, Any]])
async def list_staging_customers(status: str = Query("pending")):
"""List staging queue grouped by customer/account key."""
try:
where_clauses = []
params: List[Any] = []
if status and status != "all":
where_clauses.append("approval_status = %s")
params.append(status)
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
query = f"""
SELECT
{STAGING_KEY_SQL} AS customer_key,
COALESCE(MAX(source_customer_name), 'Ukendt kunde') AS source_customer_name,
MAX(source_account_id) AS source_account_id,
COUNT(*) AS row_count,
COUNT(*) FILTER (WHERE hub_customer_id IS NOT NULL) AS mapped_count,
COUNT(*) FILTER (WHERE approval_status = 'approved') AS approved_count,
COUNT(*) FILTER (WHERE approval_status = 'error') AS error_count,
COALESCE(SUM(source_total_amount), 0) AS total_amount,
MAX(updated_at) AS updated_at
FROM simply_subscription_staging
{where_sql}
GROUP BY {STAGING_KEY_SQL}
ORDER BY MAX(updated_at) DESC
"""
return execute_query(query, tuple(params)) or []
except Exception as e:
logger.error(f"❌ Failed listing staging customers: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Could not list staging customers")
@router.get("/simply-subscription-staging/customers/{customer_key}/rows", response_model=List[Dict[str, Any]])
async def list_staging_customer_rows(customer_key: str):
"""List staging rows for one customer group."""
try:
query = f"""
SELECT
s.id,
s.source_record_id,
s.source_salesorder_no,
s.source_subject,
s.source_status,
s.source_billing_frequency,
s.source_start_date,
s.source_end_date,
s.source_total_amount,
s.source_currency,
s.hub_customer_id,
c.name AS hub_customer_name,
s.hub_sag_id,
s.approval_status,
s.approval_error,
s.approved_at,
s.updated_at
FROM simply_subscription_staging s
LEFT JOIN customers c ON c.id = s.hub_customer_id
WHERE {STAGING_KEY_SQL} = %s
ORDER BY s.source_salesorder_no NULLS LAST, s.id ASC
"""
return execute_query(query, (customer_key,)) or []
except Exception as e:
logger.error(f"❌ Failed listing staging rows for customer key {customer_key}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Could not list staging rows")
@router.get("/simply-subscription-staging/rows", response_model=List[Dict[str, Any]])
async def list_all_staging_rows(
status: str = Query("all"),
limit: int = Query(500, ge=1, le=2000),
):
"""List all imported staging rows for overview page/table."""
try:
where_clauses = []
params: List[Any] = []
if status and status != "all":
where_clauses.append("s.approval_status = %s")
params.append(status)
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
query = f"""
SELECT
s.id,
s.source_record_id,
s.source_salesorder_no,
s.source_account_id,
s.source_customer_name,
s.source_customer_cvr,
s.source_subject,
s.source_status,
s.source_billing_frequency,
s.source_start_date,
s.source_end_date,
s.source_total_amount,
s.source_currency,
s.hub_customer_id,
c.name AS hub_customer_name,
s.hub_sag_id,
s.approval_status,
s.approval_error,
s.approved_at,
s.import_batch_id,
s.imported_at,
s.updated_at
FROM simply_subscription_staging s
LEFT JOIN customers c ON c.id = s.hub_customer_id
{where_sql}
ORDER BY s.updated_at DESC, s.id DESC
LIMIT %s
"""
params.append(limit)
return execute_query(query, tuple(params)) or []
except Exception as e:
logger.error(f"❌ Failed listing all staging rows: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Could not list imported staging rows")
@router.patch("/simply-subscription-staging/{staging_id}/map", response_model=Dict[str, Any])
async def map_staging_row(staging_id: int, payload: Dict[str, Any]):
"""Map a staging row to Hub customer (and optional existing sag)."""
try:
hub_customer_id = payload.get("hub_customer_id")
hub_sag_id = payload.get("hub_sag_id")
if not hub_customer_id:
raise HTTPException(status_code=400, detail="hub_customer_id is required")
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (hub_customer_id,))
if not customer:
raise HTTPException(status_code=400, detail="Hub customer not found")
if hub_sag_id:
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(hub_sag_id,)
)
if not sag:
raise HTTPException(status_code=400, detail="Hub sag not found")
if int(sag.get("customer_id") or 0) != int(hub_customer_id):
raise HTTPException(status_code=400, detail="Hub sag does not belong to selected customer")
result = execute_query(
"""
UPDATE simply_subscription_staging
SET hub_customer_id = %s,
hub_sag_id = %s,
approval_status = CASE WHEN approval_status = 'approved' THEN 'approved' ELSE 'mapped' END,
approval_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
""",
(hub_customer_id, hub_sag_id, staging_id)
)
if not result:
raise HTTPException(status_code=404, detail="Staging row not found")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed mapping staging row: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Could not map staging row")
@router.post("/simply-subscription-staging/customers/{customer_key}/approve", response_model=Dict[str, Any])
async def approve_staging_customer_rows(customer_key: str, payload: Dict[str, Any], request: Request):
"""Approve selected rows for one customer key and copy to Hub subscriptions."""
try:
row_ids = payload.get("row_ids") or []
if not isinstance(row_ids, list) or not row_ids:
raise HTTPException(status_code=400, detail="row_ids is required")
user_id = getattr(request.state, "user_id", None)
created_by_user_id = int(user_id) if user_id is not None else 1
rows = execute_query(
f"""
SELECT *
FROM simply_subscription_staging
WHERE {STAGING_KEY_SQL} = %s
AND id = ANY(%s)
""",
(customer_key, row_ids)
) or []
if not rows:
raise HTTPException(status_code=404, detail="No staging rows found for customer + selection")
success_rows: List[int] = []
error_rows: List[Dict[str, Any]] = []
for row in rows:
row_id = int(row["id"])
hub_customer_id = row.get("hub_customer_id")
if not hub_customer_id:
error_message = "Missing hub_customer_id mapping"
execute_query(
"""
UPDATE simply_subscription_staging
SET approval_status = 'error',
approval_error = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(error_message, row_id)
)
error_rows.append({"id": row_id, "error": error_message})
continue
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
start_date = _safe_date(row.get("source_start_date")) or date.today()
billing_interval = _simply_to_hub_interval(row.get("source_billing_frequency"))
billing_day = min(max(start_date.day, 1), 31)
next_invoice_date = _next_invoice_date(start_date, billing_interval)
source_subject = (row.get("source_subject") or row.get("source_salesorder_no") or "Simply abonnement").strip()
source_record_id = row.get("source_record_id") or str(row_id)
source_salesorder_no = row.get("source_salesorder_no") or source_record_id
amount = float(row.get("source_total_amount") or 0)
sag_id = row.get("hub_sag_id")
if not sag_id:
cursor.execute(
"""
INSERT INTO sag_sager (
titel,
beskrivelse,
template_key,
status,
customer_id,
created_by_user_id
) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
f"Simply abonnement {source_salesorder_no}",
f"Auto-oprettet fra Simply CRM staging row {source_record_id}",
"subscription",
"åben",
hub_customer_id,
created_by_user_id,
)
)
sag_id = cursor.fetchone()["id"]
cursor.execute(
"""
INSERT INTO sag_subscriptions (
sag_id,
customer_id,
product_name,
billing_interval,
billing_day,
price,
start_date,
period_start,
next_invoice_date,
status,
notes
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING id
""",
(
sag_id,
hub_customer_id,
source_subject,
billing_interval,
billing_day,
amount,
start_date,
start_date,
next_invoice_date,
f"Imported from Simply CRM source {source_record_id}",
)
)
subscription_id = cursor.fetchone()["id"]
cursor.execute(
"""
INSERT INTO sag_subscription_items (
subscription_id,
line_no,
product_id,
description,
quantity,
unit_price,
line_total
) VALUES (%s, 1, NULL, %s, 1, %s, %s)
""",
(
subscription_id,
source_subject,
amount,
amount,
)
)
cursor.execute(
"""
UPDATE simply_subscription_staging
SET hub_sag_id = %s,
approval_status = 'approved',
approval_error = NULL,
approved_at = CURRENT_TIMESTAMP,
approved_by_user_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(sag_id, created_by_user_id, row_id)
)
conn.commit()
success_rows.append(row_id)
except Exception as row_exc:
conn.rollback()
error_message = str(row_exc)
execute_query(
"""
UPDATE simply_subscription_staging
SET approval_status = 'error',
approval_error = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(error_message[:1000], row_id)
)
error_rows.append({"id": row_id, "error": error_message})
finally:
release_db_connection(conn)
return {
"status": "completed",
"selected_count": len(row_ids),
"approved_count": len(success_rows),
"error_count": len(error_rows),
"approved_row_ids": success_rows,
"errors": error_rows,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed approving staging rows: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Could not approve selected staging rows")

View File

@ -9,7 +9,10 @@
<h1 class="h3 mb-0">🔁 Abonnementer</h1> <h1 class="h3 mb-0">🔁 Abonnementer</h1>
<p class="text-muted">Alle solgte, aktive abonnementer</p> <p class="text-muted">Alle solgte, aktive abonnementer</p>
</div> </div>
<div class="col-auto"> <div class="col-auto d-flex gap-2 align-items-start">
<a href="/subscriptions/simply-imports" class="btn btn-outline-primary">
<i class="bi bi-cloud-arrow-down me-1"></i>Simply Import Oversigt
</a>
<select class="form-select" id="subscriptionStatusFilter" style="min-width: 180px;"> <select class="form-select" id="subscriptionStatusFilter" style="min-width: 180px;">
<option value="all" selected>Alle statuser</option> <option value="all" selected>Alle statuser</option>
<option value="active">Aktiv</option> <option value="active">Aktiv</option>
@ -64,11 +67,12 @@
<th>Pris</th> <th>Pris</th>
<th>Start</th> <th>Start</th>
<th>Status</th> <th>Status</th>
<th width="150">Handlinger</th>
</tr> </tr>
</thead> </thead>
<tbody id="subscriptionsBody"> <tbody id="subscriptionsBody">
<tr> <tr>
<td colspan="8" class="text-center text-muted py-5"> <td colspan="9" class="text-center text-muted py-5">
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser... <span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
</td> </td>
</tr> </tr>
@ -79,16 +83,444 @@
</div> </div>
</div> </div>
<!-- Edit Subscription Modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rediger Abonnement</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editSubId">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Produkt navn</label>
<input type="text" class="form-control" id="editProductName">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Pris (DKK)</label>
<input type="number" class="form-control" id="editPrice" step="0.01" min="0">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Billing interval</label>
<select class="form-select" id="editInterval">
<option value="daily">Daglig</option>
<option value="biweekly">Hver 14. dag</option>
<option value="monthly">Månedlig</option>
<option value="quarterly">Kvartalsvis</option>
<option value="yearly">Årlig</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Billing dag (1-31)</label>
<input type="number" class="form-control" id="editBillingDay" min="1" max="31">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Start dato</label>
<input type="date" class="form-control" id="editStartDate">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Slut dato (valgfri)</label>
<input type="date" class="form-control" id="editEndDate">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Periode start <i class="bi bi-info-circle" title="Startdato for nuværende faktureringsperiode"></i></label>
<input type="date" class="form-control" id="editPeriodStart">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Næste faktura dato <i class="bi bi-info-circle" title="Dato for næste automatiske faktura"></i></label>
<input type="date" class="form-control" id="editNextInvoiceDate">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Opsigelsesfrist (dage)</label>
<input type="number" class="form-control" id="editNoticePeriod" min="0" value="30">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Status</label>
<select class="form-select" id="editStatus">
<option value="draft">Kladde</option>
<option value="active">Aktiv</option>
<option value="paused">Pauset</option>
<option value="cancelled">Opsagt</option>
</select>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Noter</label>
<textarea class="form-control" id="editNotes" rows="3"></textarea>
</div>
<hr class="my-4">
<!-- Line Items Section -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">📦 Abonnementsvarer</h6>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addLineItem()">
<i class="bi bi-plus-circle"></i> Tilføj vare
</button>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered" id="lineItemsTable">
<thead class="table-light">
<tr>
<th width="40%">Beskrivelse</th>
<th width="15%">Antal</th>
<th width="20%">Pris/stk</th>
<th width="20%">Total</th>
<th width="5%"></th>
</tr>
</thead>
<tbody id="lineItemsBody">
<!-- Line items will be inserted here -->
</tbody>
<tfoot>
<tr class="table-light">
<td colspan="3" class="text-end"><strong>Total:</strong></td>
<td><strong id="lineItemsTotal">0,00 kr</strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()">Gem ændringer</button>
</div>
</div>
</div>
</div>
<!-- Cancel Subscription Modal -->
<div class="modal fade" id="cancelModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">Opsig Abonnement</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="cancelSubId">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
Dette opretter en opsigelsessag og beregner slutdato baseret på opsigelsesfrist.
</div>
<p><strong>Abonnement:</strong> <span id="cancelSubName"></span></p>
<p><strong>Kunde:</strong> <span id="cancelCustomerName"></span></p>
<p><strong>Opsigelsesfrist:</strong> <span id="cancelNoticeDays"></span> dage</p>
<p><strong>Beregnet slutdato:</strong> <span id="cancelEndDate"></span></p>
<div class="mb-3 mt-4">
<label class="form-label">Årsag til opsigelse</label>
<textarea class="form-control" id="cancelReason" rows="3" placeholder="Angiv årsag..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-danger" onclick="confirmCancel()">Opsig abonnement</button>
</div>
</div>
</div>
</div>
<script> <script>
let currentSubscriptions = [];
let currentStagingCustomerKey = null;
function stagingStatusBadge(status) {
const badges = {
pending: '<span class="badge bg-light text-dark">Pending</span>',
mapped: '<span class="badge bg-info text-dark">Mapped</span>',
approved: '<span class="badge bg-success">Approved</span>',
error: '<span class="badge bg-danger">Fejl</span>'
};
return badges[status] || `<span class="badge bg-light text-dark">${status || '-'}</span>`;
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
async function importSimplyStaging() {
try {
const response = await fetch('/api/v1/simply-subscription-staging/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Import fejlede');
}
alert(`✅ Import færdig\nHentet: ${data.fetched}\nUpserted: ${data.upserted}\nAuto-mapped: ${data.auto_mapped}`);
await loadStagingCustomers();
await loadStagingOverview();
} catch (err) {
alert(`❌ Import fejl: ${err.message}`);
}
}
async function loadStagingOverview() {
const tbody = document.getElementById('stagingOverviewBody');
if (!tbody) return;
const status = document.getElementById('stagingOverviewStatusFilter')?.value || 'all';
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-3">Indlæser...</td></tr>';
try {
const response = await fetch(`/api/v1/simply-subscription-staging/rows?status=${encodeURIComponent(status)}&limit=500`);
const rows = await response.json();
if (!response.ok) {
throw new Error(rows.detail || 'Kunne ikke hente oversigt');
}
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-3">Ingen importerede rækker</td></tr>';
return;
}
tbody.innerHTML = rows.map(row => {
const amount = formatCurrency(row.source_total_amount || 0);
const updated = row.updated_at ? formatDate(row.updated_at) : '-';
const hubCustomer = row.hub_customer_name
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
: (row.hub_customer_id ? `#${row.hub_customer_id}` : '-');
const sag = row.hub_sag_id ? `<a href="/sag/${row.hub_sag_id}">#${row.hub_sag_id}</a>` : '-';
return `
<tr>
<td>${row.id}</td>
<td>${escapeHtml(row.source_salesorder_no || '-')}</td>
<td>${escapeHtml(row.source_customer_name || '-')}</td>
<td>${hubCustomer}</td>
<td>${sag}</td>
<td>${escapeHtml(row.source_subject || '-')}</td>
<td>${amount}</td>
<td>${stagingStatusBadge(row.approval_status)}</td>
<td>${updated}</td>
</tr>
`;
}).join('');
} catch (err) {
tbody.innerHTML = `<tr><td colspan="9" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
}
}
async function loadStagingCustomers() {
const tbody = document.getElementById('stagingCustomersBody');
if (!tbody) return;
try {
const response = await fetch('/api/v1/simply-subscription-staging/customers?status=all');
const rows = await response.json();
if (!response.ok) {
throw new Error(rows.detail || 'Kunne ikke hente kø');
}
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-muted text-center py-3">Ingen rækker i parkeringsplads</td></tr>';
return;
}
tbody.innerHTML = rows.map(item => {
const encodedKey = encodeURIComponent(item.customer_key || '');
const safeName = escapeHtml(item.source_customer_name || 'Ukendt');
return `
<tr>
<td>${safeName}</td>
<td>${item.row_count || 0}</td>
<td>${item.mapped_count || 0}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="openStagingCustomerEncoded('${encodedKey}', '${safeName}')">Åbn</button>
</td>
</tr>
`;
}).join('');
} catch (err) {
tbody.innerHTML = `<tr><td colspan="4" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
}
}
function openStagingCustomerEncoded(encodedKey, safeName) {
const key = decodeURIComponent(encodedKey || '');
openStagingCustomer(key, safeName);
}
async function openStagingCustomer(customerKey, customerName) {
currentStagingCustomerKey = customerKey;
document.getElementById('selectedStagingCustomerName').textContent = customerName || 'Ukendt';
document.getElementById('approveSelectedBtn').disabled = false;
const tbody = document.getElementById('stagingRowsBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">Indlæser...</td></tr>';
try {
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(customerKey)}/rows`);
const rows = await response.json();
if (!response.ok) {
throw new Error(rows.detail || 'Kunne ikke hente rækker');
}
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">Ingen rækker fundet</td></tr>';
return;
}
tbody.innerHTML = rows.map(row => {
const approved = row.approval_status === 'approved';
const amount = formatCurrency(row.source_total_amount || 0);
const title = row.source_subject || row.source_salesorder_no || row.source_record_id || `#${row.id}`;
return `
<tr>
<td>
<input type="checkbox" class="form-check-input staging-row-check" data-row-id="${row.id}" ${approved ? 'disabled' : 'checked'}>
</td>
<td>
<div class="fw-semibold">${escapeHtml(title)}</div>
<div class="small text-muted">${escapeHtml(row.source_billing_frequency || '-')}</div>
</td>
<td>${amount}</td>
<td>
<input type="number" class="form-control form-control-sm" id="mapCustomer-${row.id}" value="${row.hub_customer_id || ''}" placeholder="kunde id">
</td>
<td>
<input type="number" class="form-control form-control-sm" id="mapSag-${row.id}" value="${row.hub_sag_id || ''}" placeholder="auto">
</td>
<td>
${stagingStatusBadge(row.approval_status)}
${row.approval_error ? `<div class="small text-danger mt-1">${escapeHtml(row.approval_error)}</div>` : ''}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary" onclick="saveStagingMap(${row.id})" ${approved ? 'disabled' : ''}>Gem map</button>
</td>
</tr>
`;
}).join('');
} catch (err) {
tbody.innerHTML = `<tr><td colspan="7" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
}
}
async function saveStagingMap(rowId) {
const customerValue = document.getElementById(`mapCustomer-${rowId}`)?.value;
const sagValue = document.getElementById(`mapSag-${rowId}`)?.value;
if (!customerValue) {
alert('Angiv Hub kunde-ID før mapping');
return;
}
try {
const response = await fetch(`/api/v1/simply-subscription-staging/${rowId}/map`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hub_customer_id: parseInt(customerValue, 10),
hub_sag_id: sagValue ? parseInt(sagValue, 10) : null,
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Kunne ikke gemme mapping');
}
if (currentStagingCustomerKey) {
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
}
await loadStagingCustomers();
await loadStagingOverview();
} catch (err) {
alert(`❌ Mapping fejl: ${err.message}`);
}
}
async function approveSelectedStagingRows() {
if (!currentStagingCustomerKey) {
alert('Vælg en kunde først');
return;
}
const selectedRowIds = Array.from(document.querySelectorAll('.staging-row-check:checked'))
.map(el => parseInt(el.getAttribute('data-row-id'), 10))
.filter(Number.isInteger);
if (selectedRowIds.length === 0) {
alert('Vælg mindst én række');
return;
}
try {
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(currentStagingCustomerKey)}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_ids: selectedRowIds })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Godkendelse fejlede');
}
alert(`✅ Godkendelse færdig\nApproved: ${result.approved_count}\nErrors: ${result.error_count}`);
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
await loadStagingCustomers();
await loadStagingOverview();
await loadSubscriptions();
} catch (err) {
alert(`❌ Godkendelsesfejl: ${err.message}`);
}
}
async function loadSubscriptions() { async function loadSubscriptions() {
try { try {
const status = document.getElementById('subscriptionStatusFilter')?.value || 'all'; const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
const stats = await fetch(`/api/v1/subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json()); const stats = await fetch(`/api/v1/sag-subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
document.getElementById('activeCount').textContent = stats.subscription_count || 0; document.getElementById('activeCount').textContent = stats.subscription_count || 0;
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0); document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0); document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
const subscriptions = await fetch(`/api/v1/subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json()); const subscriptions = await fetch(`/api/v1/sag-subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
currentSubscriptions = subscriptions;
renderSubscriptions(subscriptions); renderSubscriptions(subscriptions);
const title = document.getElementById('subscriptionsTitle'); const title = document.getElementById('subscriptionsTitle');
@ -105,7 +537,7 @@ async function loadSubscriptions() {
} catch (e) { } catch (e) {
console.error('Error loading subscriptions:', e); console.error('Error loading subscriptions:', e);
document.getElementById('subscriptionsBody').innerHTML = ` document.getElementById('subscriptionsBody').innerHTML = `
<tr><td colspan="8" class="text-center text-danger py-5"> <tr><td colspan="9" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i> <i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlaesning</p> <p>Fejl ved indlaesning</p>
</td></tr> </td></tr>
@ -118,7 +550,7 @@ function renderSubscriptions(subscriptions) {
if (!subscriptions || subscriptions.length === 0) { if (!subscriptions || subscriptions.length === 0) {
tbody.innerHTML = ` tbody.innerHTML = `
<tr><td colspan="8" class="text-center text-muted py-5"> <tr><td colspan="9" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 mb-3"></i> <i class="bi bi-inbox fs-1 mb-3"></i>
<p>Ingen aktive abonnementer</p> <p>Ingen aktive abonnementer</p>
</td></tr> </td></tr>
@ -132,23 +564,262 @@ function renderSubscriptions(subscriptions) {
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-'; const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
const subNumber = sub.subscription_number || `#${sub.id}`; const subNumber = sub.subscription_number || `#${sub.id}`;
// Show product name with item count if available
let productDisplay = sub.product_name || '-';
if (sub.line_items && sub.line_items.length > 0) {
productDisplay = `${sub.product_name} <span class="badge bg-light text-dark">${sub.line_items.length} varer</span>`;
}
const canEdit = sub.status !== 'cancelled';
const canCancel = sub.status === 'active' || sub.status === 'paused';
const actions = `
<div class="btn-group btn-group-sm">
${canEdit ? `<button class="btn btn-outline-primary" onclick="openEditModal(${sub.id})" title="Rediger">
<i class="bi bi-pencil"></i>
</button>` : ''}
${canCancel ? `<button class="btn btn-outline-danger" onclick="openCancelModal(${sub.id})" title="Opsig">
<i class="bi bi-x-circle"></i>
</button>` : ''}
</div>
`;
return ` return `
<tr> <tr>
<td><strong>${subNumber}</strong></td> <td><strong>${subNumber}</strong></td>
<td>${sub.customer_name || '-'}</td> <td>${sub.customer_name || '-'}</td>
<td>${sagLink}</td> <td>${sagLink}</td>
<td>${sub.product_name || '-'}</td> <td>${productDisplay}</td>
<td>${intervalLabel}${sub.billing_day ? ' (dag ' + sub.billing_day + ')' : ''}</td> <td>${intervalLabel}${sub.billing_day ? ' (dag ' + sub.billing_day + ')' : ''}</td>
<td>${formatCurrency(sub.price || 0)}</td> <td>${formatCurrency(sub.price || 0)}</td>
<td>${formatDate(sub.start_date)}</td> <td>${formatDate(sub.start_date)}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td>${actions}</td>
</tr> </tr>
`; `;
}).join(''); }).join('');
} }
async function openEditModal(subId) {
try {
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const sub = await response.json();
console.log('Loaded subscription:', sub);
document.getElementById('editSubId').value = sub.id || '';
document.getElementById('editProductName').value = sub.product_name || '';
document.getElementById('editPrice').value = sub.price || 0;
document.getElementById('editInterval').value = sub.billing_interval || 'monthly';
document.getElementById('editBillingDay').value = sub.billing_day || 1;
document.getElementById('editStartDate').value = sub.start_date || '';
document.getElementById('editEndDate').value = sub.end_date || '';
document.getElementById('editPeriodStart').value = sub.period_start || '';
document.getElementById('editNextInvoiceDate').value = sub.next_invoice_date || '';
document.getElementById('editNoticePeriod').value = sub.notice_period_days || 30;
document.getElementById('editStatus').value = sub.status || 'draft';
document.getElementById('editNotes').value = sub.notes || '';
// Load line items
renderLineItems(sub.line_items || []);
new bootstrap.Modal(document.getElementById('editModal')).show();
} catch (e) {
console.error('Error loading subscription:', e);
alert('Fejl ved indlæsning af abonnement: ' + e.message);
}
}
let lineItemsData = [];
function renderLineItems(items) {
lineItemsData = items.map((item, idx) => ({
id: item.id || null,
description: item.description || '',
quantity: item.quantity || 1,
unit_price: item.unit_price || 0,
product_id: item.product_id || null
}));
updateLineItemsTable();
}
function updateLineItemsTable() {
const tbody = document.getElementById('lineItemsBody');
if (lineItemsData.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen varer - klik "Tilføj vare" for at tilføje</td></tr>';
document.getElementById('lineItemsTotal').textContent = '0,00 kr';
return;
}
tbody.innerHTML = lineItemsData.map((item, idx) => {
const total = (parseFloat(item.quantity) || 0) * (parseFloat(item.unit_price) || 0);
return `
<tr>
<td>
<input type="text" class="form-control form-control-sm"
value="${item.description}"
onchange="updateLineItem(${idx}, 'description', this.value)"
placeholder="Beskrivelse">
</td>
<td>
<input type="number" class="form-control form-control-sm"
value="${item.quantity}"
step="0.01" min="0"
onchange="updateLineItem(${idx}, 'quantity', this.value)">
</td>
<td>
<input type="number" class="form-control form-control-sm"
value="${item.unit_price}"
step="0.01" min="0"
onchange="updateLineItem(${idx}, 'unit_price', this.value)">
</td>
<td class="align-middle">${formatCurrency(total)}</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="removeLineItem(${idx})" title="Fjern">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
calculateTotal();
}
function addLineItem() {
lineItemsData.push({
id: null,
description: '',
quantity: 1,
unit_price: 0,
product_id: null
});
updateLineItemsTable();
}
function removeLineItem(idx) {
if (confirm('Er du sikker på at du vil fjerne denne vare?')) {
lineItemsData.splice(idx, 1);
updateLineItemsTable();
}
}
function updateLineItem(idx, field, value) {
lineItemsData[idx][field] = value;
updateLineItemsTable();
}
function calculateTotal() {
const total = lineItemsData.reduce((sum, item) => {
return sum + ((parseFloat(item.quantity) || 0) * (parseFloat(item.unit_price) || 0));
}, 0);
document.getElementById('lineItemsTotal').textContent = formatCurrency(total);
// Also update the main price field
document.getElementById('editPrice').value = total.toFixed(2);
}
async function saveEdit() {
try {
const subId = document.getElementById('editSubId').value;
// Validate line items
const validLineItems = lineItemsData.filter(item => {
return item.description && item.description.trim() !== '' &&
parseFloat(item.quantity) > 0;
});
if (validLineItems.length === 0) {
alert('⚠️ Du skal tilføje mindst én vare');
return;
}
const payload = {
product_name: document.getElementById('editProductName').value,
price: parseFloat(document.getElementById('editPrice').value),
billing_interval: document.getElementById('editInterval').value,
billing_day: parseInt(document.getElementById('editBillingDay').value),
start_date: document.getElementById('editStartDate').value || null,
end_date: document.getElementById('editEndDate').value || null,
period_start: document.getElementById('editPeriodStart').value || null,
next_invoice_date: document.getElementById('editNextInvoiceDate').value || null,
notice_period_days: parseInt(document.getElementById('editNoticePeriod').value),
status: document.getElementById('editStatus').value,
notes: document.getElementById('editNotes').value,
line_items: validLineItems
};
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to update');
}
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
loadSubscriptions();
alert('✅ Abonnement opdateret');
} catch (e) {
alert('❌ Fejl ved opdatering: ' + e.message);
}
}
async function openCancelModal(subId) {
try {
const sub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
const noticeDays = sub.notice_period_days || 30;
const endDate = new Date();
endDate.setDate(endDate.getDate() + noticeDays);
document.getElementById('cancelSubId').value = sub.id;
document.getElementById('cancelSubName').textContent = sub.product_name || 'Ukendt';
document.getElementById('cancelCustomerName').textContent = sub.customer_name || 'Ukendt';
document.getElementById('cancelNoticeDays').textContent = noticeDays;
document.getElementById('cancelEndDate').textContent = endDate.toLocaleDateString('da-DK');
document.getElementById('cancelReason').value = '';
new bootstrap.Modal(document.getElementById('cancelModal')).show();
} catch (e) {
alert('Fejl ved indlæsning af abonnement: ' + e.message);
}
}
async function confirmCancel() {
try {
const subId = document.getElementById('cancelSubId').value;
const reason = document.getElementById('cancelReason').value;
const response = await fetch(`/api/v1/subscriptions/${subId}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, user_id: 1 })
});
if (!response.ok) throw new Error('Failed to cancel');
const result = await response.json();
bootstrap.Modal.getInstance(document.getElementById('cancelModal')).hide();
loadSubscriptions();
alert(`✅ Abonnement opsagt\nSlutdato: ${new Date(result.end_date).toLocaleDateString('da-DK')}\nSag oprettet: #${result.cancellation_case_id}`);
} catch (e) {
alert('❌ Fejl ved opsigelse: ' + e.message);
}
}
function formatInterval(interval) { function formatInterval(interval) {
const map = { const map = {
'daily': 'Daglig',
'biweekly': '14-dage',
'monthly': 'Maaned', 'monthly': 'Maaned',
'quarterly': 'Kvartal', 'quarterly': 'Kvartal',
'yearly': 'Aar' 'yearly': 'Aar'

View File

@ -0,0 +1,192 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Abonnementer - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col">
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
<p class="text-muted">Alle solgte, aktive abonnementer</p>
</div>
<div class="col-auto">
<select class="form-select" id="subscriptionStatusFilter" style="min-width: 180px;">
<option value="all" selected>Alle statuser</option>
<option value="active">Aktiv</option>
<option value="paused">Pauset</option>
<option value="cancelled">Opsagt</option>
<option value="draft">Kladde</option>
</select>
</div>
</div>
<div class="row g-3 mb-4" id="statsCards">
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Aktive Abonnementer</p>
<h3 class="mb-0" id="activeCount">-</h3>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Total Pris (aktive)</p>
<h3 class="mb-0" id="totalAmount">-</h3>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Gns. Pris</p>
<h3 class="mb-0" id="avgAmount">-</h3>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0" id="subscriptionsTitle">Abonnementer</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th>Abonnement</th>
<th>Kunde</th>
<th>Sag</th>
<th>Produkt</th>
<th>Interval</th>
<th>Pris</th>
<th>Start</th>
<th>Status</th>
</tr>
</thead>
<tbody id="subscriptionsBody">
<tr>
<td colspan="8" class="text-center text-muted py-5">
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
async function loadSubscriptions() {
try {
const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
const stats = await fetch(`/api/v1/subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
document.getElementById('activeCount').textContent = stats.subscription_count || 0;
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
const subscriptions = await fetch(`/api/v1/subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
renderSubscriptions(subscriptions);
const title = document.getElementById('subscriptionsTitle');
if (title) {
const labelMap = {
all: 'Alle abonnementer',
active: 'Aktive abonnementer',
paused: 'Pausede abonnementer',
cancelled: 'Opsagte abonnementer',
draft: 'Kladder'
};
title.textContent = labelMap[status] || 'Abonnementer';
}
} catch (e) {
console.error('Error loading subscriptions:', e);
document.getElementById('subscriptionsBody').innerHTML = `
<tr><td colspan="8" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlaesning</p>
</td></tr>
`;
}
}
function renderSubscriptions(subscriptions) {
const tbody = document.getElementById('subscriptionsBody');
if (!subscriptions || subscriptions.length === 0) {
tbody.innerHTML = `
<tr><td colspan="8" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 mb-3"></i>
<p>Ingen aktive abonnementer</p>
</td></tr>
`;
return;
}
tbody.innerHTML = subscriptions.map(sub => {
const intervalLabel = formatInterval(sub.billing_interval);
const statusBadge = getStatusBadge(sub.status);
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
const subNumber = sub.subscription_number || `#${sub.id}`;
return `
<tr>
<td><strong>${subNumber}</strong></td>
<td>${sub.customer_name || '-'}</td>
<td>${sagLink}</td>
<td>${sub.product_name || '-'}</td>
<td>${intervalLabel}${sub.billing_day ? ' (dag ' + sub.billing_day + ')' : ''}</td>
<td>${formatCurrency(sub.price || 0)}</td>
<td>${formatDate(sub.start_date)}</td>
<td>${statusBadge}</td>
</tr>
`;
}).join('');
}
function formatInterval(interval) {
const map = {
'monthly': 'Maaned',
'quarterly': 'Kvartal',
'yearly': 'Aar'
};
return map[interval] || interval || '-';
}
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'paused': '<span class="badge bg-warning">Pauset</span>',
'cancelled': '<span class="badge bg-secondary">Opsagt</span>',
'draft': '<span class="badge bg-light text-dark">Kladde</span>'
};
return badges[status] || status || '-';
}
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK');
}
document.addEventListener('DOMContentLoaded', () => {
const filter = document.getElementById('subscriptionStatusFilter');
if (filter) {
filter.addEventListener('change', loadSubscriptions);
}
loadSubscriptions();
});
</script>
{% endblock %}

View File

@ -0,0 +1,397 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Simply Import Oversigt - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">📥 Simply Import Oversigt</h1>
<p class="text-muted mb-0">Parkeringsplads for importerede abonnementer før godkendelse</p>
</div>
<div class="d-flex gap-2">
<a href="/subscriptions" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Tilbage til abonnementer
</a>
<button class="btn btn-primary" onclick="importSimplyStaging()">
<i class="bi bi-arrow-down-circle me-1"></i>Importér fra Simply CRM
</button>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">Kundekø + godkendelse</h5>
<small class="text-muted">Map kunde og godkend valgte rækker pr. kunde</small>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-lg-5">
<h6 class="mb-2">Kundekø</h6>
<div class="table-responsive" style="max-height: 340px;">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Kunde</th>
<th>Rækker</th>
<th>Mapped</th>
<th></th>
</tr>
</thead>
<tbody id="stagingCustomersBody">
<tr><td colspan="4" class="text-muted text-center py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-7">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Valgt kunde: <span id="selectedStagingCustomerName" class="text-muted">Ingen</span></h6>
<button class="btn btn-success btn-sm" id="approveSelectedBtn" onclick="approveSelectedStagingRows()" disabled>
<i class="bi bi-check2-circle me-1"></i>Godkend valgte
</button>
</div>
<div class="table-responsive" style="max-height: 340px;">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 36px;"></th>
<th>Abonnement</th>
<th>Beløb</th>
<th>Map kunde</th>
<th>Sag (valgfri)</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="stagingRowsBody">
<tr><td colspan="7" class="text-muted text-center py-3">Vælg en kunde fra køen</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">Alle importerede rækker</h5>
<small class="text-muted">Viser seneste importerede subscriptions (maks 500)</small>
</div>
<div class="d-flex gap-2">
<select class="form-select form-select-sm" id="stagingOverviewStatusFilter" style="min-width: 160px;">
<option value="all" selected>Alle statuser</option>
<option value="pending">Pending</option>
<option value="mapped">Mapped</option>
<option value="approved">Approved</option>
<option value="error">Fejl</option>
</select>
<button class="btn btn-outline-secondary btn-sm" onclick="loadStagingOverview()">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 460px;">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light" style="position: sticky; top: 0; z-index: 1;">
<tr>
<th>ID</th>
<th>SO#</th>
<th>Kunde (Simply)</th>
<th>Hub kunde</th>
<th>Sag</th>
<th>Produkt</th>
<th>Beløb</th>
<th>Status</th>
<th>Opdateret</th>
</tr>
</thead>
<tbody id="stagingOverviewBody">
<tr><td colspan="9" class="text-muted text-center py-3">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let currentStagingCustomerKey = null;
function stagingStatusBadge(status) {
const badges = {
pending: '<span class="badge bg-light text-dark">Pending</span>',
mapped: '<span class="badge bg-info text-dark">Mapped</span>',
approved: '<span class="badge bg-success">Approved</span>',
error: '<span class="badge bg-danger">Fejl</span>'
};
return badges[status] || `<span class="badge bg-light text-dark">${status || '-'}</span>`;
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(amount || 0);
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK') + ' ' + date.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' });
}
async function importSimplyStaging() {
try {
const response = await fetch('/api/v1/simply-subscription-staging/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Import fejlede');
}
alert(`✅ Import færdig\nHentet: ${data.fetched}\nUpserted: ${data.upserted}\nAuto-mapped: ${data.auto_mapped}`);
await loadStagingCustomers();
await loadStagingOverview();
} catch (err) {
alert(`❌ Import fejl: ${err.message}`);
}
}
async function loadStagingCustomers() {
const tbody = document.getElementById('stagingCustomersBody');
if (!tbody) return;
try {
const response = await fetch('/api/v1/simply-subscription-staging/customers?status=all');
const rows = await response.json();
if (!response.ok) {
throw new Error(rows.detail || 'Kunne ikke hente kø');
}
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-muted text-center py-3">Ingen rækker i parkeringsplads</td></tr>';
return;
}
tbody.innerHTML = rows.map(item => {
const encodedKey = encodeURIComponent(item.customer_key || '');
const safeName = escapeHtml(item.source_customer_name || 'Ukendt');
return `
<tr>
<td>${safeName}</td>
<td>${item.row_count || 0}</td>
<td>${item.mapped_count || 0}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="openStagingCustomerEncoded('${encodedKey}', '${safeName}')">Åbn</button>
</td>
</tr>
`;
}).join('');
} catch (err) {
tbody.innerHTML = `<tr><td colspan="4" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
}
}
async function loadStagingOverview() {
const tbody = document.getElementById('stagingOverviewBody');
if (!tbody) return;
const status = document.getElementById('stagingOverviewStatusFilter')?.value || 'all';
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-3">Indlæser...</td></tr>';
try {
const response = await fetch(`/api/v1/simply-subscription-staging/rows?status=${encodeURIComponent(status)}&limit=500`);
const rows = await response.json();
if (!response.ok) {
throw new Error(rows.detail || 'Kunne ikke hente oversigt');
}
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-3">Ingen importerede rækker</td></tr>';
return;
}
tbody.innerHTML = rows.map(row => {
const amount = formatCurrency(row.source_total_amount || 0);
const updated = row.updated_at ? formatDate(row.updated_at) : '-';
const hubCustomer = row.hub_customer_name
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
: (row.hub_customer_id ? `#${row.hub_customer_id}` : '-');
const sag = row.hub_sag_id ? `<a href="/sag/${row.hub_sag_id}">#${row.hub_sag_id}</a>` : '-';
return `
<tr>
<td>${row.id}</td>
<td>${escapeHtml(row.source_salesorder_no || '-')}</td>
<td>${escapeHtml(row.source_customer_name || '-')}</td>
<td>${hubCustomer}</td>
<td>${sag}</td>
<td>${escapeHtml(row.source_subject || '-')}</td>
<td>${amount}</td>
<td>${stagingStatusBadge(row.approval_status)}</td>
<td>${updated}</td>
</tr>
`;
}).join('');
} catch (err) {
tbody.innerHTML = `<tr><td colspan="9" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
}
}
function openStagingCustomerEncoded(encodedKey, safeName) {
const key = decodeURIComponent(encodedKey || '');
openStagingCustomer(key, safeName);
}
async function openStagingCustomer(customerKey, customerName) {
currentStagingCustomerKey = customerKey;
document.getElementById('selectedStagingCustomerName').textContent = customerName || 'Ukendt';
document.getElementById('approveSelectedBtn').disabled = false;
const tbody = document.getElementById('stagingRowsBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">Indlæser...</td></tr>';
try {
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(customerKey)}/rows`);
const rows = await response.json();
if (!response.ok) {
throw new Error(rows.detail || 'Kunne ikke hente rækker');
}
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">Ingen rækker fundet</td></tr>';
return;
}
tbody.innerHTML = rows.map(row => {
const approved = row.approval_status === 'approved';
const amount = formatCurrency(row.source_total_amount || 0);
const title = row.source_subject || row.source_salesorder_no || row.source_record_id || `#${row.id}`;
return `
<tr>
<td>
<input type="checkbox" class="form-check-input staging-row-check" data-row-id="${row.id}" ${approved ? 'disabled' : 'checked'}>
</td>
<td>
<div class="fw-semibold">${escapeHtml(title)}</div>
<div class="small text-muted">${escapeHtml(row.source_billing_frequency || '-')}</div>
</td>
<td>${amount}</td>
<td>
<input type="number" class="form-control form-control-sm" id="mapCustomer-${row.id}" value="${row.hub_customer_id || ''}" placeholder="kunde id">
</td>
<td>
<input type="number" class="form-control form-control-sm" id="mapSag-${row.id}" value="${row.hub_sag_id || ''}" placeholder="auto">
</td>
<td>
${stagingStatusBadge(row.approval_status)}
${row.approval_error ? `<div class="small text-danger mt-1">${escapeHtml(row.approval_error)}</div>` : ''}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary" onclick="saveStagingMap(${row.id})" ${approved ? 'disabled' : ''}>Gem map</button>
</td>
</tr>
`;
}).join('');
} catch (err) {
tbody.innerHTML = `<tr><td colspan="7" class="text-danger text-center py-3">${escapeHtml(err.message)}</td></tr>`;
}
}
async function saveStagingMap(rowId) {
const customerValue = document.getElementById(`mapCustomer-${rowId}`)?.value;
const sagValue = document.getElementById(`mapSag-${rowId}`)?.value;
if (!customerValue) {
alert('Angiv Hub kunde-ID før mapping');
return;
}
try {
const response = await fetch(`/api/v1/simply-subscription-staging/${rowId}/map`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hub_customer_id: parseInt(customerValue, 10),
hub_sag_id: sagValue ? parseInt(sagValue, 10) : null,
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Kunne ikke gemme mapping');
}
if (currentStagingCustomerKey) {
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
}
await loadStagingCustomers();
await loadStagingOverview();
} catch (err) {
alert(`❌ Mapping fejl: ${err.message}`);
}
}
async function approveSelectedStagingRows() {
if (!currentStagingCustomerKey) {
alert('Vælg en kunde først');
return;
}
const selectedRowIds = Array.from(document.querySelectorAll('.staging-row-check:checked'))
.map(el => parseInt(el.getAttribute('data-row-id'), 10))
.filter(Number.isInteger);
if (selectedRowIds.length === 0) {
alert('Vælg mindst én række');
return;
}
try {
const response = await fetch(`/api/v1/simply-subscription-staging/customers/${encodeURIComponent(currentStagingCustomerKey)}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_ids: selectedRowIds })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Godkendelse fejlede');
}
alert(`✅ Godkendelse færdig\nApproved: ${result.approved_count}\nErrors: ${result.error_count}`);
await openStagingCustomer(currentStagingCustomerKey, document.getElementById('selectedStagingCustomerName').textContent);
await loadStagingCustomers();
await loadStagingOverview();
} catch (err) {
alert(`❌ Godkendelsesfejl: ${err.message}`);
}
}
document.addEventListener('DOMContentLoaded', () => {
const overviewFilter = document.getElementById('stagingOverviewStatusFilter');
if (overviewFilter) {
overviewFilter.addEventListener('change', loadStagingOverview);
}
loadStagingCustomers();
loadStagingOverview();
});
</script>
{% endblock %}

View File

@ -17,3 +17,11 @@ async def subscriptions_list(request: Request):
return templates.TemplateResponse("subscriptions/frontend/list.html", { return templates.TemplateResponse("subscriptions/frontend/list.html", {
"request": request "request": request
}) })
@router.get("/subscriptions/simply-imports", response_class=HTMLResponse)
async def subscriptions_simply_imports(request: Request):
"""Dedicated page for Simply subscription import parking/staging overview."""
return templates.TemplateResponse("subscriptions/frontend/simply_imports.html", {
"request": request
})

View File

@ -9,12 +9,14 @@ import logging
import hashlib import hashlib
import json import json
import re import re
import asyncio
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.ticket.backend.ticket_service import TicketService from app.ticket.backend.ticket_service import TicketService
from app.services.simplycrm_service import SimplyCRMService from app.services.simplycrm_service import SimplyCRMService
from app.services.vtiger_service import get_vtiger_service
from app.core.config import settings from app.core.config import settings
from app.ticket.backend.economic_export import ticket_economic_service from app.ticket.backend.economic_export import ticket_economic_service
from app.ticket.backend.models import ( from app.ticket.backend.models import (
@ -125,6 +127,24 @@ def _escape_simply_value(value: str) -> str:
return value.replace("'", "''") return value.replace("'", "''")
async def _vtiger_query_with_retry(vtiger, query_string: str, retries: int = 5, base_delay: float = 1.25) -> List[dict]:
"""Run vTiger query with exponential backoff on rate-limit responses."""
for attempt in range(retries + 1):
await asyncio.sleep(0.15)
result = await vtiger.query(query_string)
status_code = getattr(vtiger, "last_query_status", None)
error = getattr(vtiger, "last_query_error", None) or {}
error_code = error.get("code") if isinstance(error, dict) else None
if status_code != 429 and error_code != "TOO_MANY_REQUESTS":
return result
if attempt < retries:
await asyncio.sleep(base_delay * (2 ** attempt))
return []
# ============================================================================ # ============================================================================
# TICKET ENDPOINTS # TICKET ENDPOINTS
# ============================================================================ # ============================================================================
@ -1810,7 +1830,7 @@ async def import_simply_archived_tickets(
""" """
One-time import of archived tickets from Simply-CRM. One-time import of archived tickets from Simply-CRM.
""" """
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0, "messages_imported": 0}
try: try:
async with SimplyCRMService() as service: async with SimplyCRMService() as service:
@ -1854,6 +1874,10 @@ async def import_simply_archived_tickets(
ticket, ticket,
["title", "subject", "ticket_title", "tickettitle", "summary"] ["title", "subject", "ticket_title", "tickettitle", "summary"]
) )
contact_name = _get_first_value(
ticket,
["contactname", "contact_name", "contact"]
)
organization_name = _get_first_value( organization_name = _get_first_value(
ticket, ticket,
["accountname", "account_name", "organization", "company"] ["accountname", "account_name", "organization", "company"]
@ -1958,50 +1982,51 @@ async def import_simply_archived_tickets(
) )
if existing: if existing:
if not force and existing.get("sync_hash") == data_hash: hash_matches = existing.get("sync_hash") == data_hash
if not force and hash_matches:
archived_ticket_id = existing["id"]
stats["skipped"] += 1 stats["skipped"] += 1
continue else:
execute_update(
execute_update( """
""" UPDATE tticket_archived_tickets
UPDATE tticket_archived_tickets SET ticket_number = %s,
SET ticket_number = %s, title = %s,
title = %s, organization_name = %s,
organization_name = %s, contact_name = %s,
contact_name = %s, email_from = %s,
email_from = %s, time_spent_hours = %s,
time_spent_hours = %s, description = %s,
description = %s, solution = %s,
solution = %s, status = %s,
status = %s, priority = %s,
priority = %s, source_created_at = %s,
source_created_at = %s, source_updated_at = %s,
source_updated_at = %s, last_synced_at = CURRENT_TIMESTAMP,
last_synced_at = CURRENT_TIMESTAMP, sync_hash = %s,
sync_hash = %s, raw_data = %s::jsonb
raw_data = %s::jsonb WHERE id = %s
WHERE id = %s """,
""", (
( ticket_number,
ticket_number, title,
title, organization_name,
organization_name, contact_name,
contact_name, email_from,
email_from, time_spent_hours,
time_spent_hours, description,
description, solution,
solution, status,
status, priority,
priority, source_created_at,
source_created_at, source_updated_at,
source_updated_at, data_hash,
data_hash, json.dumps(ticket, default=str),
json.dumps(ticket, default=str), existing["id"]
existing["id"] )
) )
) archived_ticket_id = existing["id"]
archived_ticket_id = existing["id"] stats["updated"] += 1
stats["updated"] += 1
else: else:
archived_ticket_id = execute_insert( archived_ticket_id = execute_insert(
""" """
@ -2085,6 +2110,7 @@ async def import_simply_archived_tickets(
json.dumps(comment, default=str) json.dumps(comment, default=str)
) )
) )
stats["messages_imported"] += 1
for email in emails: for email in emails:
execute_insert( execute_insert(
@ -2112,6 +2138,7 @@ async def import_simply_archived_tickets(
json.dumps(email, default=str) json.dumps(email, default=str)
) )
) )
stats["messages_imported"] += 1
except Exception as e: except Exception as e:
logger.error(f"❌ Archived ticket import failed: {e}") logger.error(f"❌ Archived ticket import failed: {e}")
@ -2125,6 +2152,347 @@ async def import_simply_archived_tickets(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/archived/vtiger/import", tags=["Archived Tickets"])
async def import_vtiger_archived_tickets(
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
include_messages: bool = Query(True, description="Include comments and emails"),
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
force: bool = Query(False, description="Update even if sync hash matches")
):
"""
One-time import of archived tickets from vTiger (Cases module).
"""
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0, "messages_imported": 0}
try:
vtiger = get_vtiger_service()
if ticket_number:
sanitized = _escape_simply_value(ticket_number)
tickets = []
for field in ("ticket_no", "ticketnumber", "ticket_number"):
query = f"SELECT * FROM Cases WHERE {field} = '{sanitized}' LIMIT 1;"
tickets = await _vtiger_query_with_retry(vtiger, query)
if tickets:
break
else:
tickets = []
offset = 0
batch_size = 200
while len(tickets) < limit:
query = f"SELECT * FROM Cases LIMIT {offset}, {batch_size};"
batch = await _vtiger_query_with_retry(vtiger, query)
if not batch:
break
tickets.extend(batch)
offset += batch_size
if len(batch) < batch_size:
break
tickets = tickets[:limit]
logger.info(f"🔍 Importing {len(tickets)} archived tickets from vTiger")
account_cache: dict[str, Optional[str]] = {}
contact_cache: dict[str, Optional[str]] = {}
contact_account_cache: dict[str, Optional[str]] = {}
for ticket in tickets:
try:
external_id = _get_first_value(ticket, ["id", "ticketid", "ticket_id"])
if not external_id:
stats["skipped"] += 1
continue
data_hash = _calculate_hash(ticket)
existing = execute_query_single(
"""SELECT id, sync_hash
FROM tticket_archived_tickets
WHERE source_system = %s AND external_id = %s""",
("vtiger", external_id)
)
ticket_no_value = _get_first_value(
ticket,
["ticket_no", "ticketnumber", "ticket_number", "case_no", "casenumber", "id"]
)
title = _get_first_value(
ticket,
["title", "subject", "ticket_title", "tickettitle", "summary"]
)
contact_name = _get_first_value(
ticket,
["contactname", "contact_name", "contact", "firstname", "lastname"]
)
organization_name = _get_first_value(
ticket,
["accountname", "account_name", "organization", "company"]
)
account_id = _get_first_value(
ticket,
["parent_id", "account_id", "accountid", "account"]
)
if not organization_name and _looks_like_external_id(account_id):
if account_id not in account_cache:
account_rows = await _vtiger_query_with_retry(
vtiger,
f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;"
)
account_cache[account_id] = _get_first_value(
account_rows[0] if account_rows else {},
["accountname", "account_name", "name"]
)
organization_name = account_cache.get(account_id)
contact_id = _get_first_value(
ticket,
["contact_id", "contactid"]
)
if _looks_like_external_id(contact_id):
if contact_id not in contact_cache or contact_id not in contact_account_cache:
contact_rows = await _vtiger_query_with_retry(
vtiger,
f"SELECT * FROM Contacts WHERE id='{contact_id}' LIMIT 1;"
)
contact_data = contact_rows[0] if contact_rows else {}
first_name = _get_first_value(contact_data, ["firstname", "first_name", "first"])
last_name = _get_first_value(contact_data, ["lastname", "last_name", "last"])
combined_name = " ".join([name for name in [first_name, last_name] if name]).strip()
contact_cache[contact_id] = combined_name or _get_first_value(
contact_data,
["contactname", "contact_name", "name"]
)
related_account_id = _get_first_value(
contact_data,
["account_id", "accountid", "account", "parent_id"]
)
contact_account_cache[contact_id] = related_account_id if _looks_like_external_id(related_account_id) else None
if not contact_name:
contact_name = contact_cache.get(contact_id)
if not organization_name:
related_account_id = contact_account_cache.get(contact_id)
if related_account_id:
if related_account_id not in account_cache:
account_rows = await _vtiger_query_with_retry(
vtiger,
f"SELECT * FROM Accounts WHERE id='{related_account_id}' LIMIT 1;"
)
account_cache[related_account_id] = _get_first_value(
account_rows[0] if account_rows else {},
["accountname", "account_name", "name"]
)
organization_name = account_cache.get(related_account_id)
email_from = _get_first_value(
ticket,
["email_from", "from_email", "from", "email", "email_from_address"]
)
time_spent_hours = _parse_hours(
_get_first_value(ticket, ["time_spent", "hours", "time_spent_hours", "spent_time", "cf_time_spent", "cf_tid_brugt"])
)
description = _get_first_value(
ticket,
["description", "ticket_description", "comments", "issue"]
)
solution = _get_first_value(
ticket,
["solution", "resolution", "solutiontext", "resolution_text", "answer"]
)
status = _get_first_value(
ticket,
["status", "casestatus", "ticketstatus", "state"]
)
priority = _get_first_value(
ticket,
["priority", "ticketpriorities", "ticketpriority"]
)
source_created_at = _parse_datetime(
_get_first_value(ticket, ["createdtime", "created_at", "createdon", "created_time"])
)
source_updated_at = _parse_datetime(
_get_first_value(ticket, ["modifiedtime", "updated_at", "modified_time", "updatedtime"])
)
if existing:
hash_matches = existing.get("sync_hash") == data_hash
if not force and hash_matches:
archived_ticket_id = existing["id"]
stats["skipped"] += 1
else:
execute_update(
"""
UPDATE tticket_archived_tickets
SET ticket_number = %s,
title = %s,
organization_name = %s,
contact_name = %s,
email_from = %s,
time_spent_hours = %s,
description = %s,
solution = %s,
status = %s,
priority = %s,
source_created_at = %s,
source_updated_at = %s,
last_synced_at = CURRENT_TIMESTAMP,
sync_hash = %s,
raw_data = %s::jsonb
WHERE id = %s
""",
(
ticket_no_value,
title,
organization_name,
contact_name,
email_from,
time_spent_hours,
description,
solution,
status,
priority,
source_created_at,
source_updated_at,
data_hash,
json.dumps(ticket, default=str),
existing["id"]
)
)
archived_ticket_id = existing["id"]
stats["updated"] += 1
else:
archived_ticket_id = execute_insert(
"""
INSERT INTO tticket_archived_tickets (
source_system,
external_id,
ticket_number,
title,
organization_name,
contact_name,
email_from,
time_spent_hours,
description,
solution,
status,
priority,
source_created_at,
source_updated_at,
last_synced_at,
sync_hash,
raw_data
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
CURRENT_TIMESTAMP, %s, %s::jsonb
)
RETURNING id
""",
(
"vtiger",
external_id,
ticket_no_value,
title,
organization_name,
contact_name,
email_from,
time_spent_hours,
description,
solution,
status,
priority,
source_created_at,
source_updated_at,
data_hash,
json.dumps(ticket, default=str)
)
)
stats["imported"] += 1
if include_messages and archived_ticket_id:
execute_update(
"DELETE FROM tticket_archived_messages WHERE archived_ticket_id = %s",
(archived_ticket_id,)
)
comments = await _vtiger_query_with_retry(
vtiger,
f"SELECT * FROM ModComments WHERE related_to = '{external_id}';"
)
emails = await _vtiger_query_with_retry(
vtiger,
f"SELECT * FROM Emails WHERE parent_id = '{external_id}';"
)
for comment in comments:
execute_insert(
"""
INSERT INTO tticket_archived_messages (
archived_ticket_id,
message_type,
subject,
body,
author_name,
author_email,
source_created_at,
raw_data
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
archived_ticket_id,
"comment",
None,
_get_first_value(comment, ["commentcontent", "comment", "content", "description"]),
_get_first_value(comment, ["author", "assigned_user_id", "created_by", "creator"]),
_get_first_value(comment, ["email", "author_email", "from_email"]),
_parse_datetime(_get_first_value(comment, ["createdtime", "created_at", "created_time"])),
json.dumps(comment, default=str)
)
)
stats["messages_imported"] += 1
for email in emails:
execute_insert(
"""
INSERT INTO tticket_archived_messages (
archived_ticket_id,
message_type,
subject,
body,
author_name,
author_email,
source_created_at,
raw_data
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
archived_ticket_id,
"email",
_get_first_value(email, ["subject", "title"]),
_get_first_value(email, ["description", "body", "email_body", "content"]),
_get_first_value(email, ["from_name", "sender", "assigned_user_id"]),
_get_first_value(email, ["from_email", "email", "sender_email"]),
_parse_datetime(_get_first_value(email, ["createdtime", "created_at", "created_time"])),
json.dumps(email, default=str)
)
)
stats["messages_imported"] += 1
except Exception as e:
logger.error(f"❌ vTiger archived ticket import failed: {e}")
stats["errors"] += 1
logger.info(f"✅ vTiger archived ticket import complete: {stats}")
return stats
except Exception as e:
logger.error(f"❌ vTiger archived ticket import failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/archived/simply/modules", tags=["Archived Tickets"]) @router.get("/archived/simply/modules", tags=["Archived Tickets"])
async def list_simply_modules(): async def list_simply_modules():
""" """

View File

@ -143,7 +143,7 @@
name="search" name="search"
id="search" id="search"
class="form-control" class="form-control"
placeholder="Ticket nr, titel eller beskrivelse..." placeholder="Ticket nr, titel, løsning eller kommentar..."
value="{{ search_query or '' }}"> value="{{ search_query or '' }}">
</div> </div>
</div> </div>
@ -209,6 +209,8 @@
<th>Organisation</th> <th>Organisation</th>
<th>Kontakt</th> <th>Kontakt</th>
<th>Email From</th> <th>Email From</th>
<th>Løsning</th>
<th>Kommentarer</th>
<th>Tid brugt</th> <th>Tid brugt</th>
<th>Status</th> <th>Status</th>
<th>Oprettet</th> <th>Oprettet</th>
@ -227,6 +229,18 @@
<td>{{ ticket.organization_name or '-' }}</td> <td>{{ ticket.organization_name or '-' }}</td>
<td>{{ ticket.contact_name or '-' }}</td> <td>{{ ticket.contact_name or '-' }}</td>
<td>{{ ticket.email_from or '-' }}</td> <td>{{ ticket.email_from or '-' }}</td>
<td>
{% if ticket.solution and ticket.solution.strip() %}
{{ ticket.solution[:120] }}{% if ticket.solution|length > 120 %}...{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<span class="badge" style="background: var(--accent-light); color: var(--accent);">
{{ ticket.message_count or 0 }}
</span>
</td>
<td> <td>
{% if ticket.time_spent_hours is not none %} {% if ticket.time_spent_hours is not none %}
{{ '%.2f'|format(ticket.time_spent_hours) }} t {{ '%.2f'|format(ticket.time_spent_hours) }} t

View File

@ -11,6 +11,9 @@
<p class="text-muted">Oversigt over alle support tickets og aktivitet</p> <p class="text-muted">Oversigt over alle support tickets og aktivitet</p>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button class="btn btn-outline-primary me-2" onclick="window.location.href='/ticket/dashboard/technician'">
<i class="bi bi-tools"></i> Tekniker Dashboard (3 forslag)
</button>
<button class="btn btn-primary" onclick="window.location.href='/ticket/tickets/new'"> <button class="btn btn-primary" onclick="window.location.href='/ticket/tickets/new'">
<i class="bi bi-plus-circle"></i> Ny Ticket <i class="bi bi-plus-circle"></i> Ny Ticket
</button> </button>

View File

@ -0,0 +1,120 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Tekniker Dashboard V1 - Overblik{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">🛠️ Tekniker Dashboard V1</h1>
<p class="text-muted mb-0">Kort overblik for {{ technician_name }} (bruger #{{ technician_user_id }})</p>
</div>
<div class="d-flex gap-2">
<a href="/ticket/dashboard/technician?technician_user_id={{ technician_user_id }}" class="btn btn-outline-secondary btn-sm">Tilbage til valg</a>
<a href="/ticket/dashboard/technician/v2?technician_user_id={{ technician_user_id }}" class="btn btn-outline-primary btn-sm">Se V2</a>
<a href="/ticket/dashboard/technician/v3?technician_user_id={{ technician_user_id }}" class="btn btn-outline-primary btn-sm">Se V3</a>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Nye sager</div><div class="h4 mb-0">{{ kpis.new_cases_count }}</div></div></div></div>
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Mine sager</div><div class="h4 mb-0">{{ kpis.my_cases_count }}</div></div></div></div>
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Dagens opgaver</div><div class="h4 mb-0">{{ kpis.today_tasks_count }}</div></div></div></div>
<div class="col-6 col-lg-3"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Haste / over SLA</div><div class="h4 mb-0 text-danger">{{ kpis.urgent_overdue_count }}</div></div></div></div>
<div class="col-6 col-lg-3"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Mine opportunities</div><div class="h4 mb-0">{{ kpis.my_opportunities_count }}</div></div></div></div>
</div>
<div class="row g-4">
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0"><h5 class="mb-0">Nye sager</h5></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Kunde</th><th>Oprettet</th></tr></thead>
<tbody>
{% for item in new_cases %}
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
<td>#{{ item.id }}</td>
<td>{{ item.titel }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-3">Ingen nye sager</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0"><h5 class="mb-0">Mine sager</h5></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Deadline</th><th>Status</th></tr></thead>
<tbody>
{% for item in my_cases %}
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
<td>#{{ item.id }}</td>
<td>{{ item.titel }}</td>
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
<td>{{ item.status }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0"><h5 class="mb-0 text-danger">Haste / over SLA</h5></div>
<div class="card-body">
{% for item in urgent_overdue %}
<div class="d-flex justify-content-between align-items-start border-bottom pb-2 mb-2">
<div>
<div class="fw-semibold">{{ item.title }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.attention_reason }}</div>
</div>
<a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-outline-danger">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen haste-emner lige nu.</p>
{% endfor %}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0"><h5 class="mb-0">Mine opportunities</h5></div>
<div class="card-body">
{% for item in my_opportunities %}
<div class="d-flex justify-content-between align-items-start border-bottom pb-2 mb-2">
<div>
<div class="fw-semibold">{{ item.titel }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.pipeline_stage or 'Uden stage' }}</div>
</div>
<div class="text-end">
<div class="small">{{ "%.0f"|format(item.pipeline_probability or 0) }}%</div>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary mt-1">Åbn</a>
</div>
</div>
{% else %}
<p class="text-muted mb-0">Ingen opportunities fundet.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,127 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Tekniker Dashboard V2 - Workboard{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">🛠️ Tekniker Dashboard V2</h1>
<p class="text-muted mb-0">Workboard-visning for {{ technician_name }} (bruger #{{ technician_user_id }})</p>
</div>
<div class="d-flex gap-2">
<a href="/ticket/dashboard/technician?technician_user_id={{ technician_user_id }}" class="btn btn-outline-secondary btn-sm">Tilbage til valg</a>
<a href="/ticket/dashboard/technician/v1?technician_user_id={{ technician_user_id }}" class="btn btn-outline-primary btn-sm">Se V1</a>
<a href="/ticket/dashboard/technician/v3?technician_user_id={{ technician_user_id }}" class="btn btn-outline-primary btn-sm">Se V3</a>
</div>
</div>
<div class="row g-4">
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0">Dagens opgaver</h5>
<span class="badge bg-primary">{{ kpis.today_tasks_count }}</span>
</div>
<div class="card-body">
{% for item in today_tasks %}
<div class="border rounded p-2 mb-2">
<div class="fw-semibold">{{ item.title }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.task_reason }}</div>
<a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-outline-primary mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen opgaver i dag.</p>
{% endfor %}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0">Mine sager</h5>
<span class="badge bg-secondary">{{ kpis.my_cases_count }}</span>
</div>
<div class="card-body">
{% for item in my_cases %}
<div class="border rounded p-2 mb-2">
<div class="fw-semibold">#{{ item.id }} · {{ item.titel }}</div>
<div class="small text-muted">{{ item.customer_name }} · Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</div>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen aktive sager.</p>
{% endfor %}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0 text-danger">Haste / over SLA</h5>
<span class="badge bg-danger">{{ kpis.urgent_overdue_count }}</span>
</div>
<div class="card-body">
{% for item in urgent_overdue %}
<div class="border border-danger rounded p-2 mb-2">
<div class="fw-semibold">{{ item.title }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.attention_reason }}</div>
<a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-danger mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen kritiske emner.</p>
{% endfor %}
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0">Nye sager</h5>
<span class="badge bg-info">{{ kpis.new_cases_count }}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Kunde</th><th>Oprettet</th></tr></thead>
<tbody>
{% for item in new_cases %}
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
<td>#{{ item.id }}</td><td>{{ item.titel }}</td><td>{{ item.customer_name }}</td><td>{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-3">Ingen nye sager</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0">Mine opportunities</h5>
<span class="badge bg-dark">{{ kpis.my_opportunities_count }}</span>
</div>
<div class="card-body">
{% for item in my_opportunities %}
<div class="border rounded p-2 mb-2">
<div class="fw-semibold">{{ item.titel }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.pipeline_stage or 'Uden stage' }}</div>
<div class="small text-muted">Sandsynlighed: {{ "%.0f"|format(item.pipeline_probability or 0) }}%</div>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen opportunities.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,124 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Tekniker Dashboard V3 - Power Table{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">🛠️ Tekniker Dashboard V3</h1>
<p class="text-muted mb-0">Power table for {{ technician_name }} (bruger #{{ technician_user_id }})</p>
</div>
<div class="d-flex gap-2">
<a href="/ticket/dashboard/technician?technician_user_id={{ technician_user_id }}" class="btn btn-outline-secondary btn-sm">Tilbage til valg</a>
<a href="/ticket/dashboard/technician/v1?technician_user_id={{ technician_user_id }}" class="btn btn-outline-primary btn-sm">Se V1</a>
<a href="/ticket/dashboard/technician/v2?technician_user_id={{ technician_user_id }}" class="btn btn-outline-primary btn-sm">Se V2</a>
</div>
</div>
<div class="row g-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0">Samlet teknikeroverblik</h5>
<div class="d-flex gap-2">
<span class="badge bg-info">Nye: {{ kpis.new_cases_count }}</span>
<span class="badge bg-secondary">Mine: {{ kpis.my_cases_count }}</span>
<span class="badge bg-danger">Haste: {{ kpis.urgent_overdue_count }}</span>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Type</th>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Status</th>
<th>Prioritet/Reason</th>
<th>Deadline</th>
<th>Handling</th>
</tr>
</thead>
<tbody>
{% for item in urgent_overdue %}
<tr>
<td><span class="badge bg-danger">Haste</span></td>
<td>#{{ item.item_id }}</td>
<td>{{ item.title }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>{{ item.attention_reason }}</td>
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-danger">Åbn</a></td>
</tr>
{% endfor %}
{% for item in today_tasks %}
<tr>
<td><span class="badge bg-primary">I dag</span></td>
<td>#{{ item.item_id }}</td>
<td>{{ item.title }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>{{ item.task_reason }}</td>
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-outline-primary">Åbn</a></td>
</tr>
{% endfor %}
{% for item in my_cases %}
<tr>
<td><span class="badge bg-secondary">Min sag</span></td>
<td>#{{ item.id }}</td>
<td>{{ item.titel }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>-</td>
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
<td><a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary">Åbn</a></td>
</tr>
{% endfor %}
{% if not urgent_overdue and not today_tasks and not my_cases %}
<tr>
<td colspan="8" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"><h6 class="mb-0">Nye sager</h6></div>
<div class="card-body">
{% for item in new_cases[:6] %}
<div class="small mb-1">#{{ item.id }} · {{ item.titel }} <span class="text-muted">({{ item.customer_name }})</span></div>
{% else %}
<div class="text-muted small">Ingen nye sager.</div>
{% endfor %}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"><h6 class="mb-0">Mine opportunities</h6></div>
<div class="card-body">
{% for item in my_opportunities[:6] %}
<div class="small mb-1">#{{ item.id }} · {{ item.titel }} <span class="text-muted">({{ item.pipeline_stage or 'Uden stage' }}, {{ "%.0f"|format(item.pipeline_probability or 0) }}%)</span></div>
{% else %}
<div class="text-muted small">Ingen opportunities.</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,107 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Tekniker Dashboard - Vælg Variant{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">🛠️ Tekniker Dashboard</h1>
<p class="text-muted mb-0">Vælg den visning der passer bedst til {{ technician_name }} (bruger #{{ technician_user_id }})</p>
</div>
<form method="get" action="/ticket/dashboard/technician" class="d-flex align-items-center gap-2">
<label for="technician_user_id" class="form-label mb-0 text-muted small">Bruger ID</label>
<input type="number" class="form-control form-control-sm" name="technician_user_id" id="technician_user_id" value="{{ technician_user_id }}" style="width: 100px;">
<button type="submit" class="btn btn-sm btn-outline-primary">Skift</button>
</form>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-md-2">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Nye sager</div>
<div class="h4 mb-0">{{ kpis.new_cases_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Mine sager</div>
<div class="h4 mb-0">{{ kpis.my_cases_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Dagens opgaver</div>
<div class="h4 mb-0">{{ kpis.today_tasks_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Haste / over SLA</div>
<div class="h4 mb-0 text-danger">{{ kpis.urgent_overdue_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Mine opportunities</div>
<div class="h4 mb-0">{{ kpis.my_opportunities_count }}</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex flex-column">
<h5 class="card-title mb-2">Version 1: Overblik</h5>
<p class="text-muted small mb-3">KPI-kort + kompakte lister. God til hurtig prioritering.</p>
<ul class="small text-muted mb-4">
<li>Fokus på status og antal</li>
<li>Hurtig scanning af nye/mine sager</li>
<li>Minimal støj</li>
</ul>
<a href="/ticket/dashboard/technician/v1?technician_user_id={{ technician_user_id }}" class="btn btn-primary mt-auto">Åbn Version 1</a>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex flex-column">
<h5 class="card-title mb-2">Version 2: Workboard</h5>
<p class="text-muted small mb-3">3 kolonner med arbejdsflow. God til daglig drift.</p>
<ul class="small text-muted mb-4">
<li>Visuel opdeling af arbejdsområder</li>
<li>Haste-emner centralt</li>
<li>God til standups</li>
</ul>
<a href="/ticket/dashboard/technician/v2?technician_user_id={{ technician_user_id }}" class="btn btn-primary mt-auto">Åbn Version 2</a>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex flex-column">
<h5 class="card-title mb-2">Version 3: Power Table</h5>
<p class="text-muted small mb-3">Tabel-fokuseret dashboard. God til hurtig sortering og detaljevisning.</p>
<ul class="small text-muted mb-4">
<li>Høj datatæthed</li>
<li>Nemt at sammenligne felter</li>
<li>Målrettet erfarne brugere</li>
</ul>
<a href="/ticket/dashboard/technician/v3?technician_user_id={{ technician_user_id }}" class="btn btn-primary mt-auto">Åbn Version 3</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -7,7 +7,7 @@ import logging
from fastapi import APIRouter, Request, HTTPException, Form from fastapi import APIRouter, Request, HTTPException, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from typing import Optional from typing import Optional, Dict, Any
from datetime import date from datetime import date
from app.core.database import execute_query, execute_update, execute_query_single from app.core.database import execute_query, execute_update, execute_query_single
@ -360,12 +360,309 @@ async def new_ticket_page(request: Request):
return templates.TemplateResponse("ticket/frontend/ticket_new.html", {"request": request}) return templates.TemplateResponse("ticket/frontend/ticket_new.html", {"request": request})
def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""Collect live data slices for technician-focused dashboard variants."""
user_query = """
SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
FROM users
WHERE user_id = %s
LIMIT 1
"""
user_result = execute_query(user_query, (technician_user_id,))
technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}"
new_cases_query = """
SELECT
s.id,
s.titel,
s.status,
s.created_at,
s.deadline,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND s.status = 'åben'
ORDER BY s.created_at DESC
LIMIT 12
"""
new_cases = execute_query(new_cases_query)
my_cases_query = """
SELECT
s.id,
s.titel,
s.status,
s.created_at,
s.deadline,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket'
ORDER BY s.created_at DESC
LIMIT 12
"""
my_cases = execute_query(my_cases_query, (technician_user_id,))
today_tasks_query = """
SELECT
'case' AS item_type,
s.id AS item_id,
s.titel AS title,
s.status,
s.deadline AS due_at,
s.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
NULL::text AS priority,
'Sag deadline i dag' AS task_reason
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket'
AND s.deadline = CURRENT_DATE
UNION ALL
SELECT
'ticket' AS item_type,
t.id AS item_id,
t.subject AS title,
t.status,
NULL::date AS due_at,
t.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
COALESCE(t.priority, 'normal') AS priority,
'Ticket oprettet i dag' AS task_reason
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
WHERE t.assigned_to_user_id = %s
AND t.status IN ('open', 'in_progress', 'pending_customer')
AND DATE(t.created_at) = CURRENT_DATE
ORDER BY created_at DESC
LIMIT 12
"""
today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id))
urgent_overdue_query = """
SELECT
'case' AS item_type,
s.id AS item_id,
s.titel AS title,
s.status,
s.deadline AS due_at,
s.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
NULL::text AS priority,
'Over deadline' AS attention_reason
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND s.status <> 'lukket'
AND s.deadline IS NOT NULL
AND s.deadline < CURRENT_DATE
UNION ALL
SELECT
'ticket' AS item_type,
t.id AS item_id,
t.subject AS title,
t.status,
NULL::date AS due_at,
t.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
COALESCE(t.priority, 'normal') AS priority,
CASE
WHEN t.priority = 'urgent' THEN 'Urgent prioritet'
ELSE 'Høj prioritet'
END AS attention_reason
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
WHERE t.status IN ('open', 'in_progress', 'pending_customer')
AND COALESCE(t.priority, '') IN ('urgent', 'high')
AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL)
ORDER BY created_at DESC
LIMIT 15
"""
urgent_overdue = execute_query(urgent_overdue_query, (technician_user_id,))
opportunities_query = """
SELECT
s.id,
s.titel,
s.status,
s.pipeline_amount,
s.pipeline_probability,
ps.name AS pipeline_stage,
s.deadline,
s.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND (
s.template_key = 'pipeline'
OR EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'case'
AND et.entity_id = s.id
AND LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
ORDER BY s.created_at DESC
LIMIT 12
"""
my_opportunities = execute_query(opportunities_query, (technician_user_id,))
return {
"technician_user_id": technician_user_id,
"technician_name": technician_name,
"new_cases": new_cases or [],
"my_cases": my_cases or [],
"today_tasks": today_tasks or [],
"urgent_overdue": urgent_overdue or [],
"my_opportunities": my_opportunities or [],
"kpis": {
"new_cases_count": len(new_cases or []),
"my_cases_count": len(my_cases or []),
"today_tasks_count": len(today_tasks or []),
"urgent_overdue_count": len(urgent_overdue or []),
"my_opportunities_count": len(my_opportunities or [])
}
}
def _is_user_in_technician_group(user_id: int) -> bool:
groups_query = """
SELECT LOWER(g.name) AS group_name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
"""
groups = execute_query(groups_query, (user_id,)) or []
return any(
"teknik" in (g.get("group_name") or "") or "technician" in (g.get("group_name") or "")
for g in groups
)
@router.get("/dashboard/technician", response_class=HTMLResponse)
async def technician_dashboard_selector(
request: Request,
technician_user_id: int = 1
):
"""Technician dashboard landing page with 3 variants to choose from."""
try:
# Always use logged-in user, ignore query param
logged_in_user_id = getattr(request.state, "user_id", 1)
context = _get_technician_dashboard_data(logged_in_user_id)
return templates.TemplateResponse(
"ticket/frontend/technician_dashboard_selector.html",
{
"request": request,
**context
}
)
except Exception as e:
logger.error(f"❌ Failed to load technician dashboard selector: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard/technician/v1", response_class=HTMLResponse)
async def technician_dashboard_v1(
request: Request,
technician_user_id: int = 1
):
"""Variant 1: KPI + card overview layout."""
try:
# Always use logged-in user, ignore query param
logged_in_user_id = getattr(request.state, "user_id", 1)
context = _get_technician_dashboard_data(logged_in_user_id)
return templates.TemplateResponse(
"ticket/frontend/mockups/tech_v1_overview.html",
{
"request": request,
**context
}
)
except Exception as e:
logger.error(f"❌ Failed to load technician dashboard v1: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard/technician/v2", response_class=HTMLResponse)
async def technician_dashboard_v2(
request: Request,
technician_user_id: int = 1
):
"""Variant 2: Workboard columns by focus areas."""
try:
# Always use logged-in user, ignore query param
logged_in_user_id = getattr(request.state, "user_id", 1)
context = _get_technician_dashboard_data(logged_in_user_id)
return templates.TemplateResponse(
"ticket/frontend/mockups/tech_v2_workboard.html",
{
"request": request,
**context
}
)
except Exception as e:
logger.error(f"❌ Failed to load technician dashboard v2: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard/technician/v3", response_class=HTMLResponse)
async def technician_dashboard_v3(
request: Request,
technician_user_id: int = 1
):
"""Variant 3: Dense table-first layout for power users."""
try:
# Always use logged-in user, ignore query param
logged_in_user_id = getattr(request.state, "user_id", 1)
context = _get_technician_dashboard_data(logged_in_user_id)
return templates.TemplateResponse(
"ticket/frontend/mockups/tech_v3_table_focus.html",
{
"request": request,
**context
}
)
except Exception as e:
logger.error(f"❌ Failed to load technician dashboard v3: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard", response_class=HTMLResponse) @router.get("/dashboard", response_class=HTMLResponse)
async def ticket_dashboard(request: Request): async def ticket_dashboard(request: Request):
""" """
Ticket system dashboard with statistics Ticket system dashboard with statistics
""" """
try: try:
current_user_id = getattr(request.state, "user_id", None)
if current_user_id and _is_user_in_technician_group(int(current_user_id)):
return RedirectResponse(
url=f"/ticket/dashboard/technician/v1?technician_user_id={int(current_user_id)}",
status_code=302
)
# Get ticket statistics # Get ticket statistics
stats_query = """ stats_query = """
SELECT SELECT
@ -530,34 +827,53 @@ async def archived_ticket_list_page(
status, status,
priority, priority,
source_created_at, source_created_at,
description description,
FROM tticket_archived_tickets solution,
(
SELECT COUNT(*)
FROM tticket_archived_messages m
WHERE m.archived_ticket_id = t.id
) AS message_count
FROM tticket_archived_tickets t
WHERE 1=1 WHERE 1=1
""" """
params = [] params = []
if search: if search:
query += " AND (ticket_number ILIKE %s OR title ILIKE %s OR description ILIKE %s)" query += """
AND (
t.ticket_number ILIKE %s
OR t.title ILIKE %s
OR t.description ILIKE %s
OR t.solution ILIKE %s
OR EXISTS (
SELECT 1
FROM tticket_archived_messages m
WHERE m.archived_ticket_id = t.id
AND m.body ILIKE %s
)
)
"""
search_pattern = f"%{search}%" search_pattern = f"%{search}%"
params.extend([search_pattern] * 3) params.extend([search_pattern] * 5)
if organization: if organization:
query += " AND organization_name ILIKE %s" query += " AND t.organization_name ILIKE %s"
params.append(f"%{organization}%") params.append(f"%{organization}%")
if contact: if contact:
query += " AND contact_name ILIKE %s" query += " AND t.contact_name ILIKE %s"
params.append(f"%{contact}%") params.append(f"%{contact}%")
if date_from: if date_from:
query += " AND source_created_at >= %s" query += " AND t.source_created_at >= %s"
params.append(date_from) params.append(date_from)
if date_to: if date_to:
query += " AND source_created_at <= %s" query += " AND t.source_created_at <= %s"
params.append(date_to) params.append(date_to)
query += " ORDER BY source_created_at DESC NULLS LAST, imported_at DESC LIMIT 200" query += " ORDER BY t.source_created_at DESC NULLS LAST, t.imported_at DESC LIMIT 200"
tickets = execute_query(query, tuple(params)) if params else execute_query(query) tickets = execute_query(query, tuple(params)) if params else execute_query(query)

View File

@ -45,6 +45,7 @@ from app.services.customer_consistency import CustomerConsistencyService
from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService
from app.services.vtiger_service import get_vtiger_service from app.services.vtiger_service import get_vtiger_service
from app.ticket.backend.klippekort_service import KlippekortService from app.ticket.backend.klippekort_service import KlippekortService
from app.core.auth_dependencies import get_optional_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1773,7 +1774,10 @@ async def get_time_entries_for_sag(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to fetch time entries") raise HTTPException(status_code=500, detail="Failed to fetch time entries")
@router.post("/entries/internal", tags=["Internal"]) @router.post("/entries/internal", tags=["Internal"])
async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)): async def create_internal_time_entry(
entry: Dict[str, Any] = Body(...),
current_user: Optional[dict] = Depends(get_optional_user)
):
""" """
Create a time entry manually (Internal/Hub). Create a time entry manually (Internal/Hub).
Requires: sag_id, original_hours Requires: sag_id, original_hours
@ -1786,7 +1790,12 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
description = entry.get("description") description = entry.get("description")
hours = entry.get("original_hours") hours = entry.get("original_hours")
worked_date = entry.get("worked_date") or datetime.now().date() worked_date = entry.get("worked_date") or datetime.now().date()
user_name = entry.get("user_name", "Hub User") default_user_name = (
(current_user or {}).get("username")
or (current_user or {}).get("full_name")
or "Hub User"
)
user_name = entry.get("user_name") or default_user_name
prepaid_card_id = entry.get("prepaid_card_id") prepaid_card_id = entry.get("prepaid_card_id")
fixed_price_agreement_id = entry.get("fixed_price_agreement_id") fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
work_type = entry.get("work_type", "support") work_type = entry.get("work_type", "support")

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Compare local dev database schema with production to find missing columns Compare local dev database schema with production to find missing columns
""" """

18
main.py
View File

@ -90,6 +90,8 @@ from app.modules.telefoni.backend import router as telefoni_api
from app.modules.telefoni.frontend import views as telefoni_views from app.modules.telefoni.frontend import views as telefoni_views
from app.modules.calendar.backend import router as calendar_api from app.modules.calendar.backend import router as calendar_api
from app.modules.calendar.frontend import views as calendar_views from app.modules.calendar.frontend import views as calendar_views
from app.modules.orders.backend import router as orders_api
from app.modules.orders.frontend import views as orders_views
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -118,6 +120,7 @@ async def lifespan(app: FastAPI):
# Register reminder scheduler job # Register reminder scheduler job
from app.jobs.check_reminders import check_reminders from app.jobs.check_reminders import check_reminders
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
backup_scheduler.scheduler.add_job( backup_scheduler.scheduler.add_job(
func=check_reminders, func=check_reminders,
@ -129,6 +132,19 @@ async def lifespan(app: FastAPI):
) )
logger.info("✅ Reminder job scheduled (every 5 minutes)") logger.info("✅ Reminder job scheduled (every 5 minutes)")
# Register subscription invoice processing job
from app.jobs.process_subscriptions import process_subscriptions
backup_scheduler.scheduler.add_job(
func=process_subscriptions,
trigger=CronTrigger(hour=4, minute=0),
id='process_subscriptions',
name='Process Subscription Invoices',
max_instances=1,
replace_existing=True
)
logger.info("✅ Subscription invoice job scheduled (daily at 04:00)")
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED: if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
from app.jobs.eset_sync import run_eset_sync from app.jobs.eset_sync import run_eset_sync
@ -293,6 +309,7 @@ app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"])
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"]) app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"]) app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"]) app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
# Frontend Routers # Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"]) app.include_router(dashboard_views.router, tags=["Frontend"])
@ -320,6 +337,7 @@ app.include_router(locations_views.router, tags=["Frontend"])
app.include_router(devportal_views.router, tags=["Frontend"]) app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(telefoni_views.router, tags=["Frontend"]) app.include_router(telefoni_views.router, tags=["Frontend"])
app.include_router(calendar_views.router, tags=["Frontend"]) app.include_router(calendar_views.router, tags=["Frontend"])
app.include_router(orders_views.router, tags=["Frontend"])
# Serve static files (UI) # Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static") app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -0,0 +1,11 @@
-- Migration 130: User dashboard preferences
-- Stores per-user default dashboard path
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
default_dashboard_path VARCHAR(255) NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_dashboard_preferences_path
ON user_dashboard_preferences(default_dashboard_path);

View File

@ -0,0 +1,7 @@
-- Migration 131: Add group assignment to sager
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS assigned_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_sag_sager_assigned_group_id
ON sag_sager(assigned_group_id);

View File

@ -0,0 +1,17 @@
-- Migration 132: Add cancellation rules to subscriptions
-- Adds fields for notice period, cancellation date, and termination order tracking
ALTER TABLE sag_subscriptions
ADD COLUMN IF NOT EXISTS notice_period_days INTEGER DEFAULT 30 CHECK (notice_period_days >= 0),
ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS cancelled_by_user_id INTEGER REFERENCES users(user_id),
ADD COLUMN IF NOT EXISTS cancellation_sag_id INTEGER REFERENCES sag_sager(id),
ADD COLUMN IF NOT EXISTS cancellation_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_cancelled_at ON sag_subscriptions(cancelled_at);
COMMENT ON COLUMN sag_subscriptions.notice_period_days IS 'Number of days notice required for cancellation (default 30)';
COMMENT ON COLUMN sag_subscriptions.cancelled_at IS 'When the cancellation was requested';
COMMENT ON COLUMN sag_subscriptions.cancelled_by_user_id IS 'User who requested cancellation';
COMMENT ON COLUMN sag_subscriptions.cancellation_sag_id IS 'Case created for the cancellation process';
COMMENT ON COLUMN sag_subscriptions.cancellation_reason IS 'Reason for cancellation';

View File

@ -0,0 +1,24 @@
-- Migration 133: Ordre drafts persistence for global /ordre page
CREATE TABLE IF NOT EXISTS ordre_drafts (
id SERIAL PRIMARY KEY,
title VARCHAR(120) NOT NULL,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
lines_json JSONB NOT NULL DEFAULT '[]'::jsonb,
notes TEXT,
layout_number INTEGER,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
export_status_json JSONB NOT NULL DEFAULT '{}'::jsonb,
last_exported_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ordre_drafts_user ON ordre_drafts(created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_ordre_drafts_updated_at ON ordre_drafts(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_ordre_drafts_customer ON ordre_drafts(customer_id);
CREATE TRIGGER trigger_ordre_drafts_updated_at
BEFORE UPDATE ON ordre_drafts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@ -0,0 +1,22 @@
-- Migration 134: Add billing period tracking to subscriptions
-- Adds next_invoice_date and period_start fields for tracking billing cycles
ALTER TABLE sag_subscriptions
ADD COLUMN IF NOT EXISTS next_invoice_date DATE,
ADD COLUMN IF NOT EXISTS period_start DATE;
-- Set default values for existing subscriptions
UPDATE sag_subscriptions
SET
next_invoice_date = start_date + INTERVAL '1 month',
period_start = start_date
WHERE next_invoice_date IS NULL AND status = 'active';
-- Create index for efficient querying of subscriptions due for invoicing
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_next_invoice
ON sag_subscriptions(next_invoice_date)
WHERE status = 'active';
COMMENT ON COLUMN sag_subscriptions.next_invoice_date IS 'Next date when an invoice should be generated for this subscription';
COMMENT ON COLUMN sag_subscriptions.period_start IS 'Start date of the current billing period - shifts to next period when invoice is generated';

View File

@ -0,0 +1,17 @@
-- Migration 135: Add daily and biweekly billing intervals
-- Extends subscription billing intervals to support more frequent billing
-- Drop the old constraint
ALTER TABLE sag_subscriptions
DROP CONSTRAINT IF EXISTS sag_subscriptions_billing_interval_check;
-- Add new constraint with extended options
ALTER TABLE sag_subscriptions
ADD CONSTRAINT sag_subscriptions_billing_interval_check
CHECK (billing_interval IN ('daily', 'biweekly', 'monthly', 'quarterly', 'yearly'));
-- Update default if needed (keep monthly as default)
ALTER TABLE sag_subscriptions
ALTER COLUMN billing_interval SET DEFAULT 'monthly';
COMMENT ON COLUMN sag_subscriptions.billing_interval IS 'Billing frequency: daily, biweekly (every 14 days), monthly, quarterly, yearly';

View File

@ -0,0 +1,56 @@
-- Migration 136: Simply CRM subscription staging (parking area)
-- Import recurring SalesOrders into staging, then approve per customer.
CREATE TABLE IF NOT EXISTS simply_subscription_staging (
id SERIAL PRIMARY KEY,
source_system VARCHAR(50) NOT NULL DEFAULT 'simplycrm',
source_record_id VARCHAR(64) NOT NULL,
source_account_id VARCHAR(64),
source_customer_name VARCHAR(255),
source_customer_cvr VARCHAR(32),
source_salesorder_no VARCHAR(100),
source_subject TEXT,
source_status VARCHAR(50),
source_start_date DATE,
source_end_date DATE,
source_binding_end_date DATE,
source_billing_frequency VARCHAR(50),
source_total_amount NUMERIC(12,2) DEFAULT 0,
source_currency VARCHAR(10) DEFAULT 'DKK',
source_raw JSONB NOT NULL,
sync_hash VARCHAR(64),
hub_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
hub_sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
approval_status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (approval_status IN ('pending', 'mapped', 'approved', 'error')),
approval_error TEXT,
approved_at TIMESTAMP,
approved_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
import_batch_id UUID,
imported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_simply_subscription_staging_source UNIQUE (source_system, source_record_id)
);
CREATE INDEX IF NOT EXISTS idx_simply_sub_staging_status
ON simply_subscription_staging(approval_status);
CREATE INDEX IF NOT EXISTS idx_simply_sub_staging_account
ON simply_subscription_staging(source_account_id);
CREATE INDEX IF NOT EXISTS idx_simply_sub_staging_hub_customer
ON simply_subscription_staging(hub_customer_id);
CREATE INDEX IF NOT EXISTS idx_simply_sub_staging_batch
ON simply_subscription_staging(import_batch_id);
DROP TRIGGER IF EXISTS trigger_simply_subscription_staging_updated_at ON simply_subscription_staging;
CREATE TRIGGER trigger_simply_subscription_staging_updated_at
BEFORE UPDATE ON simply_subscription_staging
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

213
move_time_section.py Normal file
View File

@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
Move time tracking section from right column to left column with inline quick-add
"""
# Read the file
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
lines = f.readlines()
# Find the insertion point in left column (before </div> that closes left column around line 2665)
# and the section to remove in right column (around lines 2813-2865)
# First, find where to insert (before the </div> that closes left column)
insert_index = None
for i, line in enumerate(lines):
if i >= 2660 and i <= 2670:
if '</div>' in line and 'col-lg-4' in lines[i+1]:
insert_index = i
break
print(f"Found insert point at line {insert_index + 1}")
# Find where to remove (the time card in right column)
remove_start = None
remove_end = None
for i, line in enumerate(lines):
if i >= 2810 and i <= 2820:
if 'data-module="time"' in line:
remove_start = i - 1 # Start from blank line before
break
if remove_start:
# Find the end of this card
for i in range(remove_start, min(remove_start + 100, len(lines))):
if '</div>' in lines[i] and i > remove_start + 50: # Make sure we've gone past the card content
remove_end = i + 1 # Include the closing div
break
print(f"Found remove section from line {remove_start + 1} to {remove_end + 1}")
# Create the new time tracking section with inline quick-add
new_time_section = '''
<!-- Tidsregistrering & Fakturering (Now in left column)-->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h6>
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
<i class="bi bi-plus-lg me-1"></i>Åbn Fuld Formular
</button>
</div>
<div class="card-body">
<!-- Inline Quick Add -->
<div class="border rounded p-3 mb-3 bg-light">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small mb-1">Dato</label>
<input type="date" class="form-control form-control-sm" id="quickTimeDate" value="">
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Timer</label>
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="quickTimeHours" min="0" max="23" placeholder="0" step="1">
<span class="input-group-text">:</span>
<input type="number" class="form-control" id="quickTimeMinutes" min="0" max="59" placeholder="00" step="1">
</div>
</div>
<div class="col-md-5">
<label class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="quickTimeDesc" placeholder="Hvad blev der arbejdet på?">
</div>
<div class="col-md-3">
<button class="btn btn-sm btn-success w-100" onclick="quickAddTime()">
<i class="bi bi-plus-circle me-1"></i>Tilføj Tid
</button>
</div>
</div>
</div>
<!-- Time Entries Table -->
<div class="table-responsive">
<table class="table table-hover mb-0" style="vertical-align: middle;">
<thead class="bg-light">
<tr>
<th class="ps-3">Dato</th>
<th>Beskrivelse</th>
<th>Bruger</th>
<th>Timer</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr>
<td class="ps-3">{{ entry.worked_date }}</td>
<td>{{ entry.description or '-' }}</td>
<td>{{ entry.user_name }}</td>
<td class="fw-bold">{{ entry.original_hours }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center py-3 text-muted">Ingen tid registreret</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Prepaid Cards Info -->
{% if prepaid_cards %}
<div class="border-top mt-3 pt-3">
<div class="fw-semibold text-primary mb-2"><i class="bi bi-credit-card me-1"></i>Klippekort</div>
<div class="row g-2">
{% for card in prepaid_cards %}
<div class="col-md-6">
<div class="d-flex justify-content-between small">
<span>#{{ card.card_number or card.id }}</span>
<span class="badge bg-success">{{ '%.2f' % card.remaining_hours }}t tilbage</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif%}
</div>
</div>
<script>
// Set today's date in quick add on page load
document.addEventListener('DOMContentLoaded', () => {
const quickDateInput = document.getElementById('quickTimeDate');
if (quickDateInput) {
const today = new Date().toISOString().split('T')[0];
quickDateInput.value = today;
}
});
async function quickAddTime() {
const hours = parseInt(document.getElementById('quickTimeHours').value) || 0;
const minutes = parseInt(document.getElementById('quickTimeMinutes').value) || 0;
const desc = document.getElementById('quickTimeDesc').value.trim();
const date = document.getElementById('quickTimeDate').value;
if (hours === 0 && minutes === 0) {
alert('Angiv timer eller minutter');
return;
}
if (!desc) {
alert('Angiv beskrivelse');
return;
}
const totalHours = hours + (minutes / 60);
const data = {
sag_id: {{ case.id }},
original_hours: totalHours,
description: desc,
worked_date: date,
work_type: 'support',
billing_method: 'invoice',
is_internal: false
};
try {
const res = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette tidsregistrering');
}
// Reset form
document.getElementById('quickTimeHours').value = '';
document.getElementById('quickTimeMinutes').value = '';
document.getElementById('quickTimeDesc').value = '';
// Reload page to show new entry
window.location.reload();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
</script>
'''
# Build the new file
new_lines = []
# Copy lines up to insert point
new_lines.extend(lines[:insert_index])
# Insert new time section
new_lines.append(new_time_section)
# Copy lines from insert point to remove start
new_lines.extend(lines[insert_index:remove_start])
# Skip the remove section, copy from remove_end onwards
new_lines.extend(lines[remove_end:])
# Write the new file
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
f.writelines(new_lines)
print(f"✅ File updated successfully!")
print(f" - Inserted new time section at line {insert_index + 1}")
print(f" - Removed old time section (lines {remove_start + 1} to {remove_end + 1})")
print(f" - New file has {len(new_lines)} lines (was {len(lines)} lines)")

View File

@ -65,6 +65,8 @@
const number = data.number || ''; const number = data.number || '';
const title = contact?.name ? contact.name : 'Ukendt nummer'; const title = contact?.name ? contact.name : 'Ukendt nummer';
const company = contact?.company ? contact.company : ''; const company = contact?.company ? contact.company : '';
const recentCases = data.recent_cases || [];
const lastCall = data.last_call;
const callId = data.call_id; const callId = data.call_id;
@ -78,6 +80,54 @@
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>` ? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
: ''; : '';
// Build recent cases HTML
let casesHtml = '';
if (recentCases.length > 0) {
casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>';
recentCases.forEach(c => {
casesHtml += `<div class="small"><a href="/sag/${c.id}" class="text-decoration-none" target="_blank">${escapeHtml(c.titel)}</a></div>`;
});
casesHtml += '</div>';
}
// Build last call HTML
let lastCallHtml = '';
if (lastCall) {
// lastCall can be either a date string (legacy) or an object with started_at and bruger_navn
const callDate = lastCall.started_at ? lastCall.started_at : lastCall;
const lastCallDate = new Date(callDate);
const now = new Date();
const diffMs = now - lastCallDate;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
let timeAgo = '';
if (diffDays === 0) {
timeAgo = 'I dag';
} else if (diffDays === 1) {
timeAgo = 'I går';
} else if (diffDays < 7) {
timeAgo = `${diffDays} dage siden`;
} else {
timeAgo = lastCallDate.toLocaleDateString('da-DK');
}
const brugerInfo = lastCall.bruger_navn ? ` (${escapeHtml(lastCall.bruger_navn)})` : '';
// Format duration
let durationInfo = '';
if (lastCall.duration_sec) {
const mins = Math.floor(lastCall.duration_sec / 60);
const secs = lastCall.duration_sec % 60;
if (mins > 0) {
durationInfo = ` - ${mins}m ${secs}s`;
} else {
durationInfo = ` - ${secs}s`;
}
}
lastCallHtml = `<div class="small text-muted mt-2"><i class="bi bi-clock-history me-1"></i>Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}</div>`;
}
toastEl.innerHTML = ` toastEl.innerHTML = `
<div class="toast-header"> <div class="toast-header">
<strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong> <strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong>
@ -88,6 +138,8 @@
<div class="fw-bold">${escapeHtml(number)}</div> <div class="fw-bold">${escapeHtml(number)}</div>
<div>${escapeHtml(title)}</div> <div>${escapeHtml(title)}</div>
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''} ${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
${lastCallHtml}
${casesHtml}
<div class="d-flex gap-2 mt-3"> <div class="d-flex gap-2 mt-3">
${openContactBtn} ${openContactBtn}
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button> <button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>

View File

@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""
Test script for subscription invoice processing
Run this manually to test the subscription processing job
"""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.core.database import init_db
from app.jobs.process_subscriptions import process_subscriptions
async def main():
"""Run subscription processing test"""
print("🧪 Testing subscription invoice processing...")
print("=" * 60)
# Initialize database connection pool
init_db()
print("✅ Database initialized")
await process_subscriptions()
print("=" * 60)
print("✅ Test complete")
if __name__ == "__main__":
asyncio.run(main())