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:
parent
891180f3f0
commit
3cddb71cec
@ -137,6 +137,12 @@ class Settings(BaseSettings):
|
||||
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)
|
||||
|
||||
# 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)
|
||||
OLD_VTIGER_URL: str = ""
|
||||
OLD_VTIGER_USERNAME: str = ""
|
||||
|
||||
@ -136,7 +136,7 @@ async function loadStages() {
|
||||
}
|
||||
|
||||
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();
|
||||
customers = Array.isArray(data) ? data : (data.customers || []);
|
||||
|
||||
@ -158,20 +158,20 @@ function renderBoard() {
|
||||
return;
|
||||
}
|
||||
|
||||
board.innerHTML = stages.map(stage => {
|
||||
const items = opportunities.filter(o => o.stage_id === stage.id);
|
||||
const cards = items.map(o => `
|
||||
const renderCards = (items, stage) => {
|
||||
return items.map(o => `
|
||||
<div class="pipeline-card">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h6>${escapeHtml(o.title)}</h6>
|
||||
<span class="badge" style="background:${stage.color}; color: white;">${o.probability || 0}%</span>
|
||||
<h6>${escapeHtml(o.titel || '')}</h6>
|
||||
<span class="badge" style="background:${(stage && stage.color) || '#6c757d'}; color: white;">${o.pipeline_probability || 0}%</span>
|
||||
</div>
|
||||
<div class="pipeline-meta">${escapeHtml(o.customer_name || '-')}
|
||||
· ${formatCurrency(o.amount, o.currency)}
|
||||
· ${formatCurrency(o.pipeline_amount, 'DKK')}
|
||||
</div>
|
||||
<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)">
|
||||
${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>
|
||||
<button class="btn btn-sm btn-outline-primary ms-2" onclick="goToDetail(${o.id})">
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
@ -179,24 +179,52 @@ function renderBoard() {
|
||||
</div>
|
||||
</div>
|
||||
`).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="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>${stage.name}</strong>
|
||||
<span class="small text-muted">${items.length}</span>
|
||||
</div>
|
||||
${cards || '<div class="text-muted small">Ingen muligheder</div>'}
|
||||
${renderCards(items, stage)}
|
||||
</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) {
|
||||
const response = await fetch(`/api/v1/opportunities/${opportunityId}/stage`, {
|
||||
const response = await fetch(`/api/v1/sag/${opportunityId}/pipeline`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
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) {
|
||||
@ -231,6 +259,7 @@ async function createOpportunity() {
|
||||
|
||||
const response = await fetch('/api/v1/opportunities', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
@ -240,12 +269,27 @@ async function createOpportunity() {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdCase = await response.json();
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
|
||||
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) {
|
||||
window.location.href = `/opportunities/${id}`;
|
||||
window.location.href = `/sag/${id}`;
|
||||
}
|
||||
|
||||
function formatCurrency(value, currency) {
|
||||
|
||||
@ -1,16 +1,106 @@
|
||||
from fastapi import APIRouter, Request
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from app.core.database import execute_query_single
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
|
||||
router = APIRouter()
|
||||
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)
|
||||
async def dashboard(request: Request):
|
||||
"""
|
||||
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
|
||||
unknown_query = """
|
||||
SELECT COUNT(*) as count
|
||||
@ -60,3 +150,197 @@ async def dashboard(request: Request):
|
||||
"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)
|
||||
|
||||
|
||||
115
app/dashboard/frontend/sales.html
Normal file
115
app/dashboard/frontend/sales.html
Normal 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 %}
|
||||
216
app/jobs/process_subscriptions.py
Normal file
216
app/jobs/process_subscriptions.py
Normal 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
|
||||
@ -759,17 +759,21 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
{% 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>
|
||||
<div id="ownerContactHelp" class="form-text">Viser kun kontakter for valgt virksomhed.</div>
|
||||
</div>
|
||||
@ -1038,9 +1042,86 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
|
||||
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
|
||||
const ownerContactSearch = document.getElementById('ownerContactSearch');
|
||||
const ownerContactSelect = document.getElementById('ownerContactSelect');
|
||||
const ownerCustomerHelp = document.getElementById('ownerCustomerHelp');
|
||||
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() {
|
||||
if (!ownerCustomerSearch || !ownerCustomerSelect) {
|
||||
@ -1048,6 +1129,16 @@
|
||||
}
|
||||
|
||||
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);
|
||||
let visibleCount = 0;
|
||||
|
||||
@ -1079,56 +1170,104 @@
|
||||
}
|
||||
}
|
||||
|
||||
function filterOwnerContacts() {
|
||||
if (!ownerCustomerSelect || !ownerContactSelect) {
|
||||
async function loadOwnerContactsForCustomer(customerId) {
|
||||
if (!ownerContactSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCustomerId = ownerCustomerSelect.value;
|
||||
const options = Array.from(ownerContactSelect.options);
|
||||
let visibleCount = 0;
|
||||
ownerContactsCache = [];
|
||||
ownerContactSelect.innerHTML = '<option value="">-- Vælg kontaktperson --</option>';
|
||||
|
||||
options.forEach((option, index) => {
|
||||
if (index === 0) {
|
||||
option.hidden = false;
|
||||
return;
|
||||
if (!customerId) {
|
||||
ownerContactSelect.disabled = true;
|
||||
if (ownerContactHelp) {
|
||||
ownerContactHelp.textContent = 'Vælg først virksomhed.';
|
||||
}
|
||||
|
||||
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 = '';
|
||||
return;
|
||||
}
|
||||
|
||||
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 (!selectedCustomerId) {
|
||||
if (ownerContactSelect.disabled) {
|
||||
ownerContactHelp.textContent = 'Vælg først virksomhed.';
|
||||
} else if (visibleCount === 0) {
|
||||
ownerContactHelp.textContent = 'Ingen kontaktpersoner fundet for valgt virksomhed.';
|
||||
} else if (filteredContacts.length === 0) {
|
||||
ownerContactHelp.textContent = 'Ingen kontaktpersoner matcher søgningen.';
|
||||
} else {
|
||||
ownerContactHelp.textContent = 'Viser kun kontakter for valgt virksomhed.';
|
||||
ownerContactHelp.textContent = `Viser ${filteredContacts.length} kontaktperson(er) for valgt virksomhed.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerCustomerSelect && ownerContactSelect) {
|
||||
ownerCustomerSelect.addEventListener('change', filterOwnerContacts);
|
||||
ownerCustomerSelect.addEventListener('change', function() {
|
||||
ownerContactSelect.setAttribute('data-current-owner-contact-id', '');
|
||||
loadOwnerContactsForCustomer(ownerCustomerSelect.value);
|
||||
});
|
||||
if (ownerCustomerSearch) {
|
||||
ownerCustomerSearch.addEventListener('input', function() {
|
||||
filterOwnerCustomers();
|
||||
filterOwnerContacts();
|
||||
});
|
||||
}
|
||||
if (ownerContactSearch) {
|
||||
ownerContactSearch.addEventListener('input', filterOwnerContactsSearch);
|
||||
}
|
||||
filterOwnerCustomers();
|
||||
filterOwnerContacts();
|
||||
loadOwnerContactsForCustomer(ownerCustomerSelect.value);
|
||||
}
|
||||
|
||||
if (window.renderEntityTags) {
|
||||
|
||||
@ -147,7 +147,7 @@ def list_locations_view(
|
||||
is_active_bool = False
|
||||
|
||||
# Query locations directly from database
|
||||
where_clauses = []
|
||||
where_clauses = ["deleted_at IS NULL"]
|
||||
query_params = []
|
||||
|
||||
if location_type:
|
||||
@ -272,7 +272,7 @@ def create_location_view():
|
||||
parent_locations = execute_query("""
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE is_active = true
|
||||
WHERE deleted_at IS NULL AND is_active = true
|
||||
ORDER BY name
|
||||
LIMIT 1000
|
||||
""")
|
||||
@ -322,12 +322,12 @@ def location_wizard_view():
|
||||
logger.info("🧭 Rendering location wizard")
|
||||
|
||||
parent_locations = execute_query("""
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE is_active = true
|
||||
ORDER BY name
|
||||
LIMIT 1000
|
||||
""")
|
||||
SELECT id, name, location_type
|
||||
FROM locations_locations
|
||||
WHERE deleted_at IS NULL AND is_active = true
|
||||
ORDER BY name
|
||||
LIMIT 1000
|
||||
""")
|
||||
|
||||
customers = execute_query("""
|
||||
SELECT id, name, email, phone
|
||||
|
||||
@ -508,12 +508,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
.then(async response => {
|
||||
if (response.ok) {
|
||||
deleteModal.hide();
|
||||
setTimeout(() => location.reload(), 300);
|
||||
} 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 => {
|
||||
|
||||
197
app/modules/orders/backend/economic_export.py
Normal file
197
app/modules/orders/backend/economic_export.py
Normal 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()
|
||||
280
app/modules/orders/backend/router.py
Normal file
280
app/modules/orders/backend/router.py
Normal 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")
|
||||
286
app/modules/orders/backend/service.py
Normal file
286
app/modules/orders/backend/service.py
Normal 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",
|
||||
},
|
||||
}
|
||||
30
app/modules/orders/frontend/views.py
Normal file
30
app/modules/orders/frontend/views.py
Normal 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})
|
||||
726
app/modules/orders/templates/create.html
Normal file
726
app/modules/orders/templates/create.html
Normal 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 %}
|
||||
416
app/modules/orders/templates/detail.html
Normal file
416
app/modules/orders/templates/detail.html
Normal 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 %}
|
||||
224
app/modules/orders/templates/list.html
Normal file
224
app/modules/orders/templates/list.html
Normal 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 %}
|
||||
@ -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")
|
||||
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
@ -52,6 +109,7 @@ async def list_sager(
|
||||
tag: Optional[str] = Query(None),
|
||||
customer_id: Optional[int] = Query(None),
|
||||
ansvarlig_bruger_id: Optional[int] = Query(None),
|
||||
assigned_group_id: Optional[int] = Query(None),
|
||||
include_deferred: bool = Query(False),
|
||||
q: Optional[str] = Query(None),
|
||||
limit: Optional[int] = Query(None, ge=1, le=200),
|
||||
@ -59,28 +117,39 @@ async def list_sager(
|
||||
):
|
||||
"""List all cases with optional filtering."""
|
||||
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 = []
|
||||
|
||||
if not include_deferred:
|
||||
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
|
||||
|
||||
if status:
|
||||
query += " AND status = %s"
|
||||
query += " AND s.status = %s"
|
||||
params.append(status)
|
||||
if customer_id:
|
||||
query += " AND customer_id = %s"
|
||||
query += " AND s.customer_id = %s"
|
||||
params.append(customer_id)
|
||||
if ansvarlig_bruger_id:
|
||||
query += " AND ansvarlig_bruger_id = %s"
|
||||
query += " AND s.ansvarlig_bruger_id = %s"
|
||||
params.append(ansvarlig_bruger_id)
|
||||
if assigned_group_id:
|
||||
query += " AND s.assigned_group_id = %s"
|
||||
params.append(assigned_group_id)
|
||||
|
||||
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()}%"
|
||||
params.extend([q_like, q_like])
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
query += " ORDER BY s.created_at DESC"
|
||||
|
||||
if limit is not None:
|
||||
query += " LIMIT %s OFFSET %s"
|
||||
@ -162,14 +231,19 @@ async def create_sag(data: dict):
|
||||
if not data.get("customer_id"):
|
||||
raise HTTPException(status_code=400, detail="customer_id is required")
|
||||
|
||||
status = data.get("status", "åben")
|
||||
if status not in {"åben", "lukket"}:
|
||||
status = "åben"
|
||||
status = _normalize_case_status(data.get("status"))
|
||||
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
||||
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 = """
|
||||
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)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(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, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
params = (
|
||||
@ -178,10 +252,11 @@ async def create_sag(data: dict):
|
||||
data.get("template_key") or data.get("type", "ticket"),
|
||||
status,
|
||||
data.get("customer_id"),
|
||||
data.get("ansvarlig_bruger_id"),
|
||||
ansvarlig_bruger_id,
|
||||
assigned_group_id,
|
||||
data.get("created_by_user_id", 1),
|
||||
data.get("deadline"),
|
||||
data.get("deferred_until"),
|
||||
deadline,
|
||||
deferred_until,
|
||||
data.get("deferred_until_case_id"),
|
||||
data.get("deferred_until_status"),
|
||||
)
|
||||
@ -199,7 +274,15 @@ async def create_sag(data: dict):
|
||||
async def get_sag(sag_id: int):
|
||||
"""Get a specific case."""
|
||||
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,))
|
||||
if not result:
|
||||
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:
|
||||
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
|
||||
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 = []
|
||||
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):
|
||||
"""Remove location from case."""
|
||||
try:
|
||||
query = "UPDATE sag_lokationer SET deleted_at = NOW() WHERE sag_id = %s AND location_id = %s RETURNING id"
|
||||
result = execute_query(query, (sag_id, location_id))
|
||||
query = """
|
||||
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:
|
||||
logger.info("✅ Location %s removed from case %s", location_id, sag_id)
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@ -8,25 +10,78 @@ from app.core.database import execute_query
|
||||
logger = logging.getLogger(__name__)
|
||||
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
|
||||
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)
|
||||
async def sager_liste(
|
||||
request: Request,
|
||||
status: 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),
|
||||
):
|
||||
"""Display list of all cases."""
|
||||
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 = """
|
||||
SELECT s.*,
|
||||
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
|
||||
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 (
|
||||
SELECT cc.contact_id
|
||||
FROM contact_companies cc
|
||||
@ -50,9 +105,15 @@ async def sager_liste(
|
||||
if status:
|
||||
query += " AND s.status = %s"
|
||||
params.append(status)
|
||||
if customer_id:
|
||||
if customer_id_int:
|
||||
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"
|
||||
sager = execute_query(query, tuple(params))
|
||||
@ -119,6 +180,10 @@ async def sager_liste(
|
||||
"current_tag": tag,
|
||||
"include_deferred": include_deferred,
|
||||
"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:
|
||||
logger.error("❌ Error displaying case list: %s", e)
|
||||
@ -127,7 +192,11 @@ async def sager_liste(
|
||||
@router.get("/sag/new", response_class=HTMLResponse)
|
||||
async def opret_sag_side(request: Request):
|
||||
"""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)
|
||||
async def sag_varekob_salg(request: Request):
|
||||
@ -141,7 +210,15 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
"""Display case details."""
|
||||
try:
|
||||
# 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,))
|
||||
|
||||
if not sag_result:
|
||||
@ -375,6 +452,7 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
pipeline_stages = []
|
||||
|
||||
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", {
|
||||
"request": request,
|
||||
@ -398,6 +476,9 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
"related_case_options": related_case_options,
|
||||
"pipeline_stages": pipeline_stages,
|
||||
"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:
|
||||
raise
|
||||
@ -419,6 +500,8 @@ async def sag_rediger(request: Request, sag_id: int):
|
||||
return templates.TemplateResponse("modules/sag/templates/edit.html", {
|
||||
"request": request,
|
||||
"case": sag_result[0],
|
||||
"assignment_users": _fetch_assignment_users(),
|
||||
"assignment_groups": _fetch_assignment_groups(),
|
||||
})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@ -238,7 +238,7 @@
|
||||
<!-- Section: Metadata -->
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
||||
<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>
|
||||
<select class="form-select" id="type" required>
|
||||
<option value="ticket" selected>🎫 Ticket</option>
|
||||
@ -248,7 +248,7 @@
|
||||
<option value="service">🛠️ Service</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="status" required>
|
||||
<option value="åben" selected>🟢 Åben</option>
|
||||
@ -256,15 +256,28 @@
|
||||
<option value="lukket">🔴 Lukket</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig (ID)</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0"><i class="bi bi-person-badge"></i></span>
|
||||
<input type="number" class="form-control border-start-0 ps-0" id="ansvarlig_bruger_id" placeholder="Bruger ID">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
||||
<select class="form-select" id="ansvarlig_bruger_id">
|
||||
<option value="">Ingen</option>
|
||||
{% for user in assignment_users or [] %}
|
||||
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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">
|
||||
<label for="deadline" class="form-label">Deadline</label>
|
||||
<div class="input-group">
|
||||
@ -839,6 +852,7 @@
|
||||
status: status,
|
||||
customer_id: selectedCustomer ? selectedCustomer.id : 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
|
||||
deadline: document.getElementById('deadline').value || null
|
||||
};
|
||||
|
||||
@ -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 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 mx-2">|</span>
|
||||
<span class="text-muted" style="margin-right: 0.3rem;">Deadline:</span>
|
||||
<strong class="{{ 'text-danger' if case.deadline and case.deadline < now else '' }}">
|
||||
{{ case.deadline.strftime('%d/%m-%y') if case.deadline else 'Ingen' }}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center ps-3 border-start">
|
||||
<strong style="color: var(--accent); margin-right: 0.4rem;">Deadline:</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>
|
||||
|
||||
<!-- Deferred Logic integrated -->
|
||||
@ -741,6 +750,36 @@
|
||||
</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 -->
|
||||
<ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
@ -925,6 +964,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<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) %}
|
||||
<ul class="relation-tree">
|
||||
{% for node in nodes %}
|
||||
@ -936,16 +982,23 @@
|
||||
{% if node.relation_type %}
|
||||
{% set rel_icon = 'bi-link-45deg' %}
|
||||
{% set rel_color = 'text-muted' %}
|
||||
{% set rel_help = 'Faglig kobling uden direkte afhængighed' %}
|
||||
|
||||
{% if node.relation_type == 'Afledt af' %}
|
||||
{% set rel_icon = 'bi-arrow-return-right' %}
|
||||
{% 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' %}
|
||||
{% set rel_icon = 'bi-slash-circle' %}
|
||||
{% set rel_color = 'text-danger' %}
|
||||
{% set rel_help = 'Arbejdet i denne sag blokerer den anden' %}
|
||||
{% 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>
|
||||
<span class="d-none d-md-inline ms-1" style="font-size: 0.7rem;">{{ node.relation_type }}</span>
|
||||
</span>
|
||||
@ -1244,17 +1297,25 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<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="relateret">🔗 Relateret - Generel relation</option>
|
||||
<option value="afhænger af">⏳ Afhænger af - Denne sag venter på den anden</option>
|
||||
<option value="blokkerer">🚫 Blokkerer - Denne sag blokerer den anden</option>
|
||||
<option value="duplikat">📋 Duplikat - Sagerne er den samme</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>
|
||||
<option value="Relateret til">🔗 Relateret til - Faglig kobling uden direkte afhængighed</option>
|
||||
<option value="Afledt af">↪ Afledt af - Denne sag er opstået på baggrund af den anden</option>
|
||||
<option value="Årsag til">➡ Årsag til - Denne sag er årsagen til den anden</option>
|
||||
<option value="Blokkerer">⛔ Blokkerer - Denne sag stopper fremdrift i den anden</option>
|
||||
</select>
|
||||
</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;">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<div>
|
||||
@ -1350,6 +1411,37 @@
|
||||
</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 -->
|
||||
<div class="modal fade" id="deferredModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
@ -1465,6 +1557,8 @@
|
||||
setupContactSearch();
|
||||
setupCustomerSearch();
|
||||
setupRelationSearch();
|
||||
updateRelationTypeHint();
|
||||
updateNewCaseRelationTypeHint();
|
||||
|
||||
// Render Global Tags
|
||||
if (window.renderEntityTags) {
|
||||
@ -1521,6 +1615,7 @@
|
||||
|
||||
function showRelationModal() {
|
||||
relationModal.show();
|
||||
updateRelationTypeHint();
|
||||
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
|
||||
}
|
||||
|
||||
@ -1585,6 +1680,67 @@
|
||||
|
||||
function showCreateRelatedModal() {
|
||||
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() {
|
||||
@ -2118,7 +2274,7 @@
|
||||
${l.name}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
@ -2459,10 +2615,14 @@
|
||||
async function unlinkLocation(locId) {
|
||||
if(!confirm("Fjern link til denne lokation?")) return;
|
||||
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();
|
||||
} catch (e) {
|
||||
alert("Fejl ved sletning");
|
||||
alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2506,6 +2666,121 @@
|
||||
}
|
||||
</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 class="col-lg-4" id="case-right-column">
|
||||
<div class="right-modules-grid">
|
||||
@ -2655,57 +2930,6 @@
|
||||
</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>
|
||||
@ -2967,29 +3191,37 @@
|
||||
|
||||
<div id="subscriptionDetails" class="d-none">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Abonnement</label>
|
||||
<div class="fw-semibold" id="subscriptionNumber">-</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Produkt</label>
|
||||
<div class="fw-semibold" id="subscriptionProduct">-</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>
|
||||
<div class="fw-semibold" id="subscriptionInterval">-</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Pris</label>
|
||||
<div class="fw-semibold" id="subscriptionPrice">-</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="small text-muted">Startdato</label>
|
||||
<div class="fw-semibold" id="subscriptionStartDate">-</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="small text-muted">Status</label>
|
||||
<div class="fw-semibold" id="subscriptionStatusText">-</div>
|
||||
<label class="small text-muted">Periode start <i class="bi bi-info-circle" title="Nuværende faktureringsperiode"></i></label>
|
||||
<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 class="table-responsive mb-3">
|
||||
@ -3020,6 +3252,8 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval *</label>
|
||||
<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="quarterly">Kvartal</option>
|
||||
<option value="yearly">Aar</option>
|
||||
@ -3125,6 +3359,8 @@
|
||||
<label class="form-label">Faktureringsinterval</label>
|
||||
<select class="form-select" id="subscriptionProductBillingPeriod">
|
||||
<option value="">-</option>
|
||||
<option value="daily">Daglig</option>
|
||||
<option value="biweekly">Hver 14. dag</option>
|
||||
<option value="monthly">Maaned</option>
|
||||
<option value="quarterly">Kvartal</option>
|
||||
<option value="yearly">Aar</option>
|
||||
@ -4660,13 +4896,21 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Relationstype *</label>
|
||||
<select class="form-select" id="newCaseRelationType">
|
||||
<option value="Relateret til">Relateret til (Generel kobling)</option>
|
||||
<option value="Afledt af">Afledt af (Denne sag afventer den nye)</option>
|
||||
<option value="Årsag til">Årsag til (Den nye sag afventer denne)</option>
|
||||
<option value="Blokkerer">Blokkerer (Denne sag blokkerer den nye)</option>
|
||||
<select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
|
||||
<option value="Relateret til">Relateret til (Ingen direkte afhængighed)</option>
|
||||
<option value="Afledt af">Afledt af (Nuværende sag er afledt af den nye)</option>
|
||||
<option value="Årsag til">Årsag til (Nuværende sag er årsag til den nye)</option>
|
||||
<option value="Blokkerer">Blokkerer (Nuværende sag blokerer den nye)</option>
|
||||
</select>
|
||||
</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">
|
||||
<label class="form-label">Beskrivelse</label>
|
||||
<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() {
|
||||
const input = document.getElementById('deferredUntilInput');
|
||||
updateDeferredUntil(input.value || null);
|
||||
@ -4986,6 +5279,80 @@
|
||||
view.classList.remove('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() {
|
||||
@ -5010,8 +5377,15 @@
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || 'Kunne ikke opdatere pipeline');
|
||||
let message = '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();
|
||||
@ -5065,10 +5439,18 @@
|
||||
document.querySelectorAll('[data-module]').forEach((el) => {
|
||||
const moduleName = el.getAttribute('data-module');
|
||||
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 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) {
|
||||
el.classList.remove('d-none');
|
||||
el.classList.remove('module-empty-compact');
|
||||
@ -5150,6 +5532,7 @@
|
||||
acc[p.module_key] = p.is_enabled;
|
||||
return acc;
|
||||
}, {});
|
||||
modulePrefs.time = true;
|
||||
} catch (e) {
|
||||
console.error('Module prefs load failed', e);
|
||||
}
|
||||
@ -5185,12 +5568,14 @@
|
||||
});
|
||||
|
||||
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 `
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="module_${m.key}" ${checked ? 'checked' : ''}
|
||||
${isTimeModule ? 'disabled' : ''}
|
||||
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>
|
||||
`;
|
||||
}).join('');
|
||||
@ -5200,6 +5585,11 @@
|
||||
}
|
||||
|
||||
async function toggleModulePref(moduleKey, isEnabled) {
|
||||
if (moduleKey === 'time') {
|
||||
modulePrefs.time = true;
|
||||
applyViewFromTags();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/v1/sag/${caseIds}/modules`, {
|
||||
method: 'POST',
|
||||
@ -5579,6 +5969,8 @@
|
||||
|
||||
function formatSubscriptionInterval(interval) {
|
||||
const map = {
|
||||
'daily': 'Daglig',
|
||||
'biweekly': '14-dage',
|
||||
'monthly': 'Maaned',
|
||||
'quarterly': 'Kvartal',
|
||||
'yearly': 'Aar'
|
||||
@ -5856,6 +6248,24 @@
|
||||
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
|
||||
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);
|
||||
|
||||
const itemsBody = document.getElementById('subscriptionItemsBody');
|
||||
@ -5898,7 +6308,7 @@
|
||||
|
||||
async function loadSubscriptionForCase() {
|
||||
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) {
|
||||
showSubscriptionCreateForm();
|
||||
setModuleContentState('subscription', false);
|
||||
@ -5935,7 +6345,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/subscriptions', {
|
||||
const res = await fetch('/api/v1/sag-subscriptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@ -5967,7 +6377,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/subscriptions/${currentSubscription.id}/status`, {
|
||||
const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
@ -5989,6 +6399,99 @@
|
||||
loadSubscriptionProducts();
|
||||
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>
|
||||
|
||||
</div>
|
||||
|
||||
@ -212,13 +212,28 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ansvarlig_bruger_id">Ansvarlig Bruger (valgfrit)</label>
|
||||
<input type="number" class="form-control" id="ansvarlig_bruger_id" placeholder="Brugers ID" value="{{ case.ansvarlig_bruger_id or '' }}">
|
||||
<label for="ansvarlig_bruger_id">Ansvarlig medarbejder</label>
|
||||
<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 class="form-group">
|
||||
<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 class="button-group">
|
||||
@ -278,6 +293,7 @@
|
||||
type: type,
|
||||
status: status,
|
||||
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
|
||||
};
|
||||
|
||||
|
||||
@ -298,6 +298,27 @@
|
||||
<option value="all">Alle typer</option>
|
||||
</select>
|
||||
</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 }}">
|
||||
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
|
||||
</a>
|
||||
@ -314,6 +335,8 @@
|
||||
<th style="width: 120px;">Type</th>
|
||||
<th style="width: 180px;">Kunde</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: 120px;">Udsat start</th>
|
||||
<th style="width: 120px;">Oprettet</th>
|
||||
@ -327,7 +350,7 @@
|
||||
<tr class="tree-row {% if has_relations %}has-children{% endif %}"
|
||||
data-sag-id="{{ sag.id }}"
|
||||
data-status="{{ sag.status }}"
|
||||
data-type="{{ sag.type or 'ticket' }}">
|
||||
data-type="{{ sag.template_key or sag.type or 'ticket' }}">
|
||||
<td>
|
||||
{% if has_relations %}
|
||||
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
||||
@ -341,7 +364,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<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 onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ 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;">
|
||||
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
|
||||
</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 }}'">
|
||||
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
|
||||
</td>
|
||||
@ -369,7 +398,7 @@
|
||||
{% if related_sag and rel.target_id not in seen_targets %}
|
||||
{% 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 %}
|
||||
<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>
|
||||
<span class="sag-id">#{{ related_sag.id }}</span>
|
||||
</td>
|
||||
@ -383,7 +412,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<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 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 '-' }}
|
||||
@ -391,6 +420,12 @@
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
|
||||
</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 }}'">
|
||||
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
|
||||
</td>
|
||||
@ -449,6 +484,17 @@
|
||||
let currentFilter = '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() {
|
||||
const search = currentSearch;
|
||||
|
||||
@ -512,14 +558,26 @@
|
||||
async function loadTypeFilters() {
|
||||
if (!typeFilter) return;
|
||||
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');
|
||||
if (!res.ok) return;
|
||||
const setting = await res.json();
|
||||
const types = JSON.parse(setting.value || '[]');
|
||||
if (!Array.isArray(types) || types.length === 0) return;
|
||||
let configuredTypes = [];
|
||||
if (res.ok) {
|
||||
const setting = await res.json();
|
||||
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>` +
|
||||
types.map(type => `<option value="${type}">${type}</option>`).join('');
|
||||
mergedTypes.map(type => `<option value="${type}">${type}</option>`).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load case types', err);
|
||||
}
|
||||
|
||||
@ -47,6 +47,29 @@
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -142,9 +165,9 @@
|
||||
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30px;"></th>
|
||||
<th>Dato</th>
|
||||
<th>Beskrivelse</th>
|
||||
<th>Sag</th>
|
||||
<th>Kunde</th>
|
||||
<th>Antal</th>
|
||||
<th>Enhed</th>
|
||||
@ -172,9 +195,9 @@
|
||||
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30px;"></th>
|
||||
<th>Dato</th>
|
||||
<th>Beskrivelse</th>
|
||||
<th>Sag</th>
|
||||
<th>Kunde</th>
|
||||
<th>Antal</th>
|
||||
<th>Enhed</th>
|
||||
@ -216,23 +239,83 @@
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = items.map(item => {
|
||||
const statusLabel = item.status || 'draft';
|
||||
const caseLink = item.sag_id ? `<a href="/sag/${item.sag_id}" class="text-decoration-none">${item.sag_titel || 'Sag ' + item.sag_id}</a>` : '-';
|
||||
return `
|
||||
<tr>
|
||||
<td>${item.line_date || '-'}</td>
|
||||
<td>${item.description || '-'}</td>
|
||||
<td>${caseLink}</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>
|
||||
// Group items by case (sag_id)
|
||||
const grouped = {};
|
||||
items.forEach((item, originalIndex) => {
|
||||
const caseKey = item.sag_id || 'ingen-sag';
|
||||
if (!grouped[caseKey]) {
|
||||
grouped[caseKey] = {
|
||||
sag_id: item.sag_id || null,
|
||||
sag_titel: item.sag_titel || 'Ingen sag',
|
||||
items: []
|
||||
};
|
||||
}
|
||||
grouped[caseKey].items.push({ ...item, originalIndex });
|
||||
});
|
||||
|
||||
// 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>
|
||||
`;
|
||||
}).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() {
|
||||
|
||||
@ -217,6 +217,11 @@ async def yealink_established(
|
||||
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
|
||||
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 = {
|
||||
"callid": resolved_callid,
|
||||
"call_id": call_id,
|
||||
@ -252,6 +257,8 @@ async def yealink_established(
|
||||
"number": ekstern_e164 or (ekstern_raw or ""),
|
||||
"direction": direction,
|
||||
"contact": kontakt,
|
||||
"recent_cases": contact_details.get("recent_cases", []),
|
||||
"last_call": contact_details.get("last_call"),
|
||||
},
|
||||
)
|
||||
else:
|
||||
@ -395,6 +402,15 @@ async def telefoni_test_popup(
|
||||
"name": "Test popup",
|
||||
"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 {
|
||||
|
||||
@ -109,3 +109,67 @@ class TelefoniService:
|
||||
(duration_sec, callid),
|
||||
)
|
||||
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,
|
||||
}
|
||||
|
||||
@ -39,18 +39,28 @@ async def list_opportunities(
|
||||
s.customer_id,
|
||||
COALESCE(c.name, 'Ukendt kunde') as customer_name,
|
||||
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
|
||||
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
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND (
|
||||
s.template_key = 'pipeline'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM sag_tags st
|
||||
JOIN tags t ON st.tag_id = t.id
|
||||
WHERE st.sag_id = s.id AND t.name = 'pipeline'
|
||||
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'
|
||||
)
|
||||
)
|
||||
"""
|
||||
@ -136,12 +146,26 @@ async def create_opportunity(
|
||||
|
||||
@router.get("/pipeline/stages", tags=["Opportunities"])
|
||||
async def list_pipeline_stages():
|
||||
"""
|
||||
Legacy endpoint for stages.
|
||||
Returns static stages mapped to Case statuses for compatibility.
|
||||
"""
|
||||
"""List available pipeline stages from DB with a safe static fallback."""
|
||||
try:
|
||||
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 [
|
||||
{"id": "open", "name": "Åben"},
|
||||
{"id": "won", "name": "Vundet"},
|
||||
{"id": "lost", "name": "Tabt"}
|
||||
{"id": 1, "name": "Lead", "color": "#6c757d", "sort_order": 10},
|
||||
{"id": 2, "name": "Kontakt", "color": "#17a2b8", "sort_order": 20},
|
||||
{"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},
|
||||
]
|
||||
|
||||
@ -10,6 +10,7 @@ import logging
|
||||
import os
|
||||
import aiohttp
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -22,6 +23,57 @@ def _apigw_headers() -> Dict[str, str]:
|
||||
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]:
|
||||
supplier_name = payload.get("supplier_name")
|
||||
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 "")
|
||||
|
||||
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)
|
||||
if value
|
||||
)
|
||||
@ -220,8 +272,7 @@ async def search_apigw_products(
|
||||
if not params:
|
||||
raise HTTPException(status_code=400, detail="Provide at least one search parameter")
|
||||
|
||||
base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL
|
||||
url = f"{base_url.rstrip('/')}/api/v1/products/search"
|
||||
url = f"{_apigw_base_url()}/api/v1/products/search"
|
||||
logger.info("🔍 APIGW product search: %s", params)
|
||||
|
||||
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 session.get(url, headers=_apigw_headers(), params=params) as response:
|
||||
if response.status >= 400:
|
||||
detail = await response.text()
|
||||
raise HTTPException(status_code=response.status, detail=detail)
|
||||
detail = await _read_apigw_error(response)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"API Gateway product search failed ({response.status}): {detail}",
|
||||
)
|
||||
data = await response.json()
|
||||
|
||||
if q and isinstance(data, dict) and isinstance(data.get("products"), list):
|
||||
@ -243,6 +297,12 @@ async def search_apigw_products(
|
||||
return data
|
||||
except HTTPException:
|
||||
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:
|
||||
logger.error("❌ Error searching APIGW products: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@ -348,7 +348,14 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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 class="col-md-6">
|
||||
<label class="form-label">Status</label>
|
||||
@ -359,15 +366,25 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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 class="col-md-6">
|
||||
<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 class="col-md-6">
|
||||
<label class="form-label">Kostpris</label>
|
||||
<input type="number" class="form-control" id="productCostPrice" step="0.01" min="0">
|
||||
<label class="form-label">Købspris</label>
|
||||
<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 class="col-md-6">
|
||||
<label class="form-label">Moms (%)</label>
|
||||
@ -685,6 +702,7 @@ async function createProduct() {
|
||||
type: document.getElementById('productType').value.trim() || null,
|
||||
status: document.getElementById('productStatus').value,
|
||||
sku_internal: document.getElementById('productSku').value.trim() || null,
|
||||
ean: document.getElementById('productEan').value.trim() || null,
|
||||
sales_price: document.getElementById('productSalesPrice').value || null,
|
||||
cost_price: document.getElementById('productCostPrice').value || null,
|
||||
vat_rate: document.getElementById('productVatRate').value || null,
|
||||
|
||||
@ -29,6 +29,8 @@ class SimplyCRMService:
|
||||
|
||||
self.session_name: Optional[str] = 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]):
|
||||
logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)")
|
||||
@ -169,14 +171,20 @@ class SimplyCRMService:
|
||||
data = await response.json()
|
||||
if not data.get("success"):
|
||||
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 []
|
||||
|
||||
result = data.get("result", [])
|
||||
self.last_query_error = None
|
||||
logger.debug(f"✅ Simply-CRM query returned {len(result)} records")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.last_query_error = {"message": str(e)}
|
||||
logger.error(f"❌ Simply-CRM query error: {e}")
|
||||
return []
|
||||
|
||||
@ -224,8 +232,26 @@ class SimplyCRMService:
|
||||
"""
|
||||
module_name = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_MODULE", "ModComments")
|
||||
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;"
|
||||
return await self.query(query)
|
||||
relation_candidates: List[str] = []
|
||||
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]:
|
||||
"""
|
||||
@ -242,12 +268,26 @@ class SimplyCRMService:
|
||||
fallback_relation_field = getattr(settings, "SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD", "related_to")
|
||||
|
||||
records: List[Dict] = []
|
||||
query = f"SELECT * FROM {module_name} WHERE {relation_field} = '{ticket_id}';"
|
||||
records.extend(await self.query(query))
|
||||
relation_candidates: List[str] = []
|
||||
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:
|
||||
query = f"SELECT * FROM {module_name} WHERE {fallback_relation_field} = '{ticket_id}';"
|
||||
records.extend(await self.query(query))
|
||||
for candidate in relation_candidates:
|
||||
if candidate in self._denied_relation_fields:
|
||||
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
|
||||
seen_ids = set()
|
||||
|
||||
@ -28,6 +28,9 @@ class VTigerService:
|
||||
if not all([self.base_url, self.username, self.api_key]):
|
||||
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):
|
||||
"""Get HTTP Basic Auth credentials"""
|
||||
if not self.api_key:
|
||||
@ -49,6 +52,8 @@ class VTigerService:
|
||||
|
||||
try:
|
||||
auth = self._get_auth()
|
||||
self.last_query_status = None
|
||||
self.last_query_error = None
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{self.rest_endpoint}/query",
|
||||
@ -56,6 +61,7 @@ class VTigerService:
|
||||
auth=auth
|
||||
) as response:
|
||||
text = await response.text()
|
||||
self.last_query_status = response.status
|
||||
|
||||
if response.status == 200:
|
||||
# vTiger returns text/json instead of application/json
|
||||
@ -69,16 +75,28 @@ class VTigerService:
|
||||
if data.get('success'):
|
||||
result = data.get('result', [])
|
||||
logger.info(f"✅ Query returned {len(result)} records")
|
||||
self.last_query_error = None
|
||||
return result
|
||||
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 []
|
||||
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"Response: {text[:500]}")
|
||||
return []
|
||||
except Exception as e:
|
||||
self.last_query_status = None
|
||||
self.last_query_error = {"message": str(e)}
|
||||
logger.error(f"❌ vTiger query error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
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()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
@ -19,9 +19,27 @@ templates = Jinja2Templates(directory="app")
|
||||
@router.get("/settings", response_class=HTMLResponse, tags=["Frontend"])
|
||||
async def settings_page(request: Request):
|
||||
"""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", {
|
||||
"request": request,
|
||||
"title": "Indstillinger"
|
||||
"title": "Indstillinger",
|
||||
"default_dashboard_path": default_dashboard_path
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -1020,6 +1020,40 @@ async def scan_document(file_path: str):
|
||||
|
||||
<!-- System Settings -->
|
||||
<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">
|
||||
<h5 class="mb-4 fw-bold">System Indstillinger</h5>
|
||||
<div id="systemSettings">
|
||||
|
||||
@ -261,7 +261,7 @@
|
||||
</a>
|
||||
<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="#">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><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>
|
||||
@ -523,7 +523,7 @@
|
||||
<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/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>
|
||||
// Dark Mode Toggle Logic
|
||||
|
||||
@ -3,19 +3,104 @@ Subscriptions API
|
||||
Sag-based subscriptions listing and stats
|
||||
"""
|
||||
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 psycopg2.extras import RealDictCursor
|
||||
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__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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):
|
||||
"""Get latest subscription for a case."""
|
||||
try:
|
||||
@ -72,7 +157,7 @@ async def get_subscription_by_sag(sag_id: int):
|
||||
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]):
|
||||
"""Create a new subscription tied to a case (status = draft)."""
|
||||
try:
|
||||
@ -158,6 +243,25 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
if 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()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
@ -171,9 +275,11 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
billing_day,
|
||||
price,
|
||||
start_date,
|
||||
period_start,
|
||||
next_invoice_date,
|
||||
status,
|
||||
notes
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'draft', %s)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
@ -184,6 +290,8 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
billing_day,
|
||||
total_price,
|
||||
start_date,
|
||||
period_start,
|
||||
next_invoice_date,
|
||||
notes,
|
||||
)
|
||||
)
|
||||
@ -226,7 +334,165 @@ async def create_subscription(payload: Dict[str, Any]):
|
||||
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]):
|
||||
"""Update subscription status."""
|
||||
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))
|
||||
|
||||
|
||||
@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")):
|
||||
"""List subscriptions by status (default: all)."""
|
||||
"""List subscriptions by status (default: all) with line item counts."""
|
||||
try:
|
||||
where_clause = ""
|
||||
params: List[Any] = []
|
||||
@ -275,20 +541,28 @@ async def list_subscriptions(status: str = Query("all")):
|
||||
s.price,
|
||||
s.start_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
|
||||
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
{where_clause}
|
||||
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:
|
||||
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
|
||||
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")):
|
||||
"""Summary stats for subscriptions by status (default: all)."""
|
||||
try:
|
||||
@ -314,3 +588,517 @@ async def subscription_stats(status: str = Query("all")):
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading subscription stats: {e}", exc_info=True)
|
||||
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")
|
||||
|
||||
@ -9,7 +9,10 @@
|
||||
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
|
||||
<p class="text-muted">Alle solgte, aktive abonnementer</p>
|
||||
</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;">
|
||||
<option value="all" selected>Alle statuser</option>
|
||||
<option value="active">Aktiv</option>
|
||||
@ -64,11 +67,12 @@
|
||||
<th>Pris</th>
|
||||
<th>Start</th>
|
||||
<th>Status</th>
|
||||
<th width="150">Handlinger</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subscriptionsBody">
|
||||
<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...
|
||||
</td>
|
||||
</tr>
|
||||
@ -79,16 +83,444 @@
|
||||
</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>
|
||||
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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
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('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());
|
||||
const subscriptions = await fetch(`/api/v1/sag-subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
|
||||
currentSubscriptions = subscriptions;
|
||||
renderSubscriptions(subscriptions);
|
||||
|
||||
const title = document.getElementById('subscriptionsTitle');
|
||||
@ -105,7 +537,7 @@ async function loadSubscriptions() {
|
||||
} catch (e) {
|
||||
console.error('Error loading subscriptions:', e);
|
||||
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>
|
||||
<p>Fejl ved indlaesning</p>
|
||||
</td></tr>
|
||||
@ -118,7 +550,7 @@ function renderSubscriptions(subscriptions) {
|
||||
|
||||
if (!subscriptions || subscriptions.length === 0) {
|
||||
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>
|
||||
<p>Ingen aktive abonnementer</p>
|
||||
</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 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 `
|
||||
<tr>
|
||||
<td><strong>${subNumber}</strong></td>
|
||||
<td>${sub.customer_name || '-'}</td>
|
||||
<td>${sagLink}</td>
|
||||
<td>${sub.product_name || '-'}</td>
|
||||
<td>${productDisplay}</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>
|
||||
<td>${actions}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).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) {
|
||||
const map = {
|
||||
'daily': 'Daglig',
|
||||
'biweekly': '14-dage',
|
||||
'monthly': 'Maaned',
|
||||
'quarterly': 'Kvartal',
|
||||
'yearly': 'Aar'
|
||||
|
||||
192
app/subscriptions/frontend/list_backup.html
Normal file
192
app/subscriptions/frontend/list_backup.html
Normal 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 %}
|
||||
397
app/subscriptions/frontend/simply_imports.html
Normal file
397
app/subscriptions/frontend/simply_imports.html
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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 %}
|
||||
@ -17,3 +17,11 @@ async def subscriptions_list(request: Request):
|
||||
return templates.TemplateResponse("subscriptions/frontend/list.html", {
|
||||
"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
|
||||
})
|
||||
|
||||
@ -9,12 +9,14 @@ import logging
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.ticket.backend.ticket_service import TicketService
|
||||
from app.services.simplycrm_service import SimplyCRMService
|
||||
from app.services.vtiger_service import get_vtiger_service
|
||||
from app.core.config import settings
|
||||
from app.ticket.backend.economic_export import ticket_economic_service
|
||||
from app.ticket.backend.models import (
|
||||
@ -125,6 +127,24 @@ def _escape_simply_value(value: str) -> str:
|
||||
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
|
||||
# ============================================================================
|
||||
@ -1810,7 +1830,7 @@ async def import_simply_archived_tickets(
|
||||
"""
|
||||
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:
|
||||
async with SimplyCRMService() as service:
|
||||
@ -1854,6 +1874,10 @@ async def import_simply_archived_tickets(
|
||||
ticket,
|
||||
["title", "subject", "ticket_title", "tickettitle", "summary"]
|
||||
)
|
||||
contact_name = _get_first_value(
|
||||
ticket,
|
||||
["contactname", "contact_name", "contact"]
|
||||
)
|
||||
organization_name = _get_first_value(
|
||||
ticket,
|
||||
["accountname", "account_name", "organization", "company"]
|
||||
@ -1958,50 +1982,51 @@ async def import_simply_archived_tickets(
|
||||
)
|
||||
|
||||
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
|
||||
continue
|
||||
|
||||
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_number,
|
||||
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"]
|
||||
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_number,
|
||||
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
|
||||
archived_ticket_id = existing["id"]
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
archived_ticket_id = execute_insert(
|
||||
"""
|
||||
@ -2085,6 +2110,7 @@ async def import_simply_archived_tickets(
|
||||
json.dumps(comment, default=str)
|
||||
)
|
||||
)
|
||||
stats["messages_imported"] += 1
|
||||
|
||||
for email in emails:
|
||||
execute_insert(
|
||||
@ -2112,6 +2138,7 @@ async def import_simply_archived_tickets(
|
||||
json.dumps(email, default=str)
|
||||
)
|
||||
)
|
||||
stats["messages_imported"] += 1
|
||||
|
||||
except Exception as 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))
|
||||
|
||||
|
||||
@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"])
|
||||
async def list_simply_modules():
|
||||
"""
|
||||
|
||||
@ -143,7 +143,7 @@
|
||||
name="search"
|
||||
id="search"
|
||||
class="form-control"
|
||||
placeholder="Ticket nr, titel eller beskrivelse..."
|
||||
placeholder="Ticket nr, titel, løsning eller kommentar..."
|
||||
value="{{ search_query or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
@ -209,6 +209,8 @@
|
||||
<th>Organisation</th>
|
||||
<th>Kontakt</th>
|
||||
<th>Email From</th>
|
||||
<th>Løsning</th>
|
||||
<th>Kommentarer</th>
|
||||
<th>Tid brugt</th>
|
||||
<th>Status</th>
|
||||
<th>Oprettet</th>
|
||||
@ -227,6 +229,18 @@
|
||||
<td>{{ ticket.organization_name or '-' }}</td>
|
||||
<td>{{ ticket.contact_name 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>
|
||||
{% if ticket.time_spent_hours is not none %}
|
||||
{{ '%.2f'|format(ticket.time_spent_hours) }} t
|
||||
|
||||
@ -11,6 +11,9 @@
|
||||
<p class="text-muted">Oversigt over alle support tickets og aktivitet</p>
|
||||
</div>
|
||||
<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'">
|
||||
<i class="bi bi-plus-circle"></i> Ny Ticket
|
||||
</button>
|
||||
|
||||
120
app/ticket/frontend/mockups/tech_v1_overview.html
Normal file
120
app/ticket/frontend/mockups/tech_v1_overview.html
Normal 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 %}
|
||||
127
app/ticket/frontend/mockups/tech_v2_workboard.html
Normal file
127
app/ticket/frontend/mockups/tech_v2_workboard.html
Normal 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 %}
|
||||
124
app/ticket/frontend/mockups/tech_v3_table_focus.html
Normal file
124
app/ticket/frontend/mockups/tech_v3_table_focus.html
Normal 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 %}
|
||||
107
app/ticket/frontend/technician_dashboard_selector.html
Normal file
107
app/ticket/frontend/technician_dashboard_selector.html
Normal 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 %}
|
||||
@ -7,7 +7,7 @@ import logging
|
||||
from fastapi import APIRouter, Request, HTTPException, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from typing import Optional
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import date
|
||||
|
||||
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})
|
||||
|
||||
|
||||
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)
|
||||
async def ticket_dashboard(request: Request):
|
||||
"""
|
||||
Ticket system dashboard with statistics
|
||||
"""
|
||||
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
|
||||
stats_query = """
|
||||
SELECT
|
||||
@ -530,34 +827,53 @@ async def archived_ticket_list_page(
|
||||
status,
|
||||
priority,
|
||||
source_created_at,
|
||||
description
|
||||
FROM tticket_archived_tickets
|
||||
description,
|
||||
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
|
||||
"""
|
||||
params = []
|
||||
|
||||
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}%"
|
||||
params.extend([search_pattern] * 3)
|
||||
params.extend([search_pattern] * 5)
|
||||
|
||||
if organization:
|
||||
query += " AND organization_name ILIKE %s"
|
||||
query += " AND t.organization_name ILIKE %s"
|
||||
params.append(f"%{organization}%")
|
||||
|
||||
if contact:
|
||||
query += " AND contact_name ILIKE %s"
|
||||
query += " AND t.contact_name ILIKE %s"
|
||||
params.append(f"%{contact}%")
|
||||
|
||||
if date_from:
|
||||
query += " AND source_created_at >= %s"
|
||||
query += " AND t.source_created_at >= %s"
|
||||
params.append(date_from)
|
||||
|
||||
if date_to:
|
||||
query += " AND source_created_at <= %s"
|
||||
query += " AND t.source_created_at <= %s"
|
||||
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)
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ from app.services.customer_consistency import CustomerConsistencyService
|
||||
from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService
|
||||
from app.services.vtiger_service import get_vtiger_service
|
||||
from app.ticket.backend.klippekort_service import KlippekortService
|
||||
from app.core.auth_dependencies import get_optional_user
|
||||
|
||||
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")
|
||||
|
||||
@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).
|
||||
Requires: sag_id, original_hours
|
||||
@ -1786,7 +1790,12 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
|
||||
description = entry.get("description")
|
||||
hours = entry.get("original_hours")
|
||||
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")
|
||||
fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
|
||||
work_type = entry.get("work_type", "support")
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compare local dev database schema with production to find missing columns
|
||||
"""
|
||||
|
||||
18
main.py
18
main.py
@ -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.calendar.backend import router as calendar_api
|
||||
from app.modules.calendar.frontend import views as calendar_views
|
||||
from app.modules.orders.backend import router as orders_api
|
||||
from app.modules.orders.frontend import views as orders_views
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -118,6 +120,7 @@ async def lifespan(app: FastAPI):
|
||||
# Register reminder scheduler job
|
||||
from app.jobs.check_reminders import check_reminders
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
backup_scheduler.scheduler.add_job(
|
||||
func=check_reminders,
|
||||
@ -129,6 +132,19 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
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:
|
||||
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(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
||||
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
||||
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
|
||||
|
||||
# Frontend Routers
|
||||
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(telefoni_views.router, tags=["Frontend"])
|
||||
app.include_router(calendar_views.router, tags=["Frontend"])
|
||||
app.include_router(orders_views.router, tags=["Frontend"])
|
||||
|
||||
# Serve static files (UI)
|
||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||
|
||||
11
migrations/130_user_dashboard_preferences.sql
Normal file
11
migrations/130_user_dashboard_preferences.sql
Normal 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);
|
||||
7
migrations/131_sag_assignment_group.sql
Normal file
7
migrations/131_sag_assignment_group.sql
Normal 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);
|
||||
17
migrations/132_subscription_cancellation.sql
Normal file
17
migrations/132_subscription_cancellation.sql
Normal 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';
|
||||
24
migrations/133_ordre_drafts.sql
Normal file
24
migrations/133_ordre_drafts.sql
Normal 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();
|
||||
22
migrations/134_subscription_billing_dates.sql
Normal file
22
migrations/134_subscription_billing_dates.sql
Normal 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';
|
||||
|
||||
17
migrations/135_subscription_extended_intervals.sql
Normal file
17
migrations/135_subscription_extended_intervals.sql
Normal 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';
|
||||
56
migrations/136_simply_subscription_staging.sql
Normal file
56
migrations/136_simply_subscription_staging.sql
Normal 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
213
move_time_section.py
Normal 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)")
|
||||
@ -65,6 +65,8 @@
|
||||
const number = data.number || '';
|
||||
const title = contact?.name ? contact.name : 'Ukendt nummer';
|
||||
const company = contact?.company ? contact.company : '';
|
||||
const recentCases = data.recent_cases || [];
|
||||
const lastCall = data.last_call;
|
||||
|
||||
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>`
|
||||
: '';
|
||||
|
||||
// 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 = `
|
||||
<div class="toast-header">
|
||||
<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>${escapeHtml(title)}</div>
|
||||
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
|
||||
${lastCallHtml}
|
||||
${casesHtml}
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
${openContactBtn}
|
||||
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
||||
|
||||
31
test_subscription_processing.py
Normal file
31
test_subscription_processing.py
Normal 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())
|
||||
Loading…
Reference in New Issue
Block a user