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_LAYOUT: int = 19 # e-conomic invoice layout number (default: 19 = Danish standard)
|
||||||
TIMETRACKING_ECONOMIC_PRODUCT: str = "1000" # e-conomic product number for time entries (default: 1000)
|
TIMETRACKING_ECONOMIC_PRODUCT: str = "1000" # e-conomic product number for time entries (default: 1000)
|
||||||
|
|
||||||
|
# Global Ordre Module Safety Flags
|
||||||
|
ORDRE_ECONOMIC_READ_ONLY: bool = True
|
||||||
|
ORDRE_ECONOMIC_DRY_RUN: bool = True
|
||||||
|
ORDRE_ECONOMIC_LAYOUT: int = 19
|
||||||
|
ORDRE_ECONOMIC_PRODUCT: str = "1000"
|
||||||
|
|
||||||
# Simply-CRM (Old vTiger On-Premise)
|
# Simply-CRM (Old vTiger On-Premise)
|
||||||
OLD_VTIGER_URL: str = ""
|
OLD_VTIGER_URL: str = ""
|
||||||
OLD_VTIGER_USERNAME: str = ""
|
OLD_VTIGER_USERNAME: str = ""
|
||||||
|
|||||||
@ -136,7 +136,7 @@ async function loadStages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCustomers() {
|
async function loadCustomers() {
|
||||||
const response = await fetch('/api/v1/customers?limit=10000');
|
const response = await fetch('/api/v1/customers?limit=1000');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
customers = Array.isArray(data) ? data : (data.customers || []);
|
customers = Array.isArray(data) ? data : (data.customers || []);
|
||||||
|
|
||||||
@ -158,20 +158,20 @@ function renderBoard() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
board.innerHTML = stages.map(stage => {
|
const renderCards = (items, stage) => {
|
||||||
const items = opportunities.filter(o => o.stage_id === stage.id);
|
return items.map(o => `
|
||||||
const cards = items.map(o => `
|
|
||||||
<div class="pipeline-card">
|
<div class="pipeline-card">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<h6>${escapeHtml(o.title)}</h6>
|
<h6>${escapeHtml(o.titel || '')}</h6>
|
||||||
<span class="badge" style="background:${stage.color}; color: white;">${o.probability || 0}%</span>
|
<span class="badge" style="background:${(stage && stage.color) || '#6c757d'}; color: white;">${o.pipeline_probability || 0}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pipeline-meta">${escapeHtml(o.customer_name || '-')}
|
<div class="pipeline-meta">${escapeHtml(o.customer_name || '-')}
|
||||||
· ${formatCurrency(o.amount, o.currency)}
|
· ${formatCurrency(o.pipeline_amount, 'DKK')}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center mt-2">
|
<div class="d-flex justify-content-between align-items-center mt-2">
|
||||||
<select class="form-select form-select-sm" onchange="changeStage(${o.id}, this.value)">
|
<select class="form-select form-select-sm" onchange="changeStage(${o.id}, this.value)">
|
||||||
${stages.map(s => `<option value="${s.id}" ${s.id === o.stage_id ? 'selected' : ''}>${s.name}</option>`).join('')}
|
<option value="">Ikke sat</option>
|
||||||
|
${stages.map(s => `<option value="${s.id}" ${Number(s.id) === Number(o.pipeline_stage_id) ? 'selected' : ''}>${s.name}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-sm btn-outline-primary ms-2" onclick="goToDetail(${o.id})">
|
<button class="btn btn-sm btn-outline-primary ms-2" onclick="goToDetail(${o.id})">
|
||||||
<i class="bi bi-arrow-right"></i>
|
<i class="bi bi-arrow-right"></i>
|
||||||
@ -179,24 +179,52 @@ function renderBoard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
};
|
||||||
|
|
||||||
return `
|
const unassignedItems = opportunities.filter(o => !o.pipeline_stage_id);
|
||||||
|
const columns = [];
|
||||||
|
|
||||||
|
if (unassignedItems.length > 0) {
|
||||||
|
columns.push(`
|
||||||
|
<div class="pipeline-column">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<strong>Ikke sat</strong>
|
||||||
|
<span class="small text-muted">${unassignedItems.length}</span>
|
||||||
|
</div>
|
||||||
|
${renderCards(unassignedItems, null)}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
stages.forEach(stage => {
|
||||||
|
const items = opportunities.filter(o => Number(o.pipeline_stage_id) === Number(stage.id));
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
|
columns.push(`
|
||||||
<div class="pipeline-column">
|
<div class="pipeline-column">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<strong>${stage.name}</strong>
|
<strong>${stage.name}</strong>
|
||||||
<span class="small text-muted">${items.length}</span>
|
<span class="small text-muted">${items.length}</span>
|
||||||
</div>
|
</div>
|
||||||
${cards || '<div class="text-muted small">Ingen muligheder</div>'}
|
${renderCards(items, stage)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}).join('');
|
});
|
||||||
|
|
||||||
|
if (!columns.length) {
|
||||||
|
board.innerHTML = '<div class="pipeline-column"><div class="text-muted small">Ingen muligheder i pipeline endnu</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
board.innerHTML = columns.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeStage(opportunityId, stageId) {
|
async function changeStage(opportunityId, stageId) {
|
||||||
const response = await fetch(`/api/v1/opportunities/${opportunityId}/stage`, {
|
const response = await fetch(`/api/v1/sag/${opportunityId}/pipeline`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ stage_id: parseInt(stageId) })
|
body: JSON.stringify({ stage_id: stageId ? parseInt(stageId, 10) : null })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -231,6 +259,7 @@ async function createOpportunity() {
|
|||||||
|
|
||||||
const response = await fetch('/api/v1/opportunities', {
|
const response = await fetch('/api/v1/opportunities', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
@ -240,12 +269,27 @@ async function createOpportunity() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdCase = await response.json();
|
||||||
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
|
||||||
await loadOpportunities();
|
await loadOpportunities();
|
||||||
|
|
||||||
|
if (createdCase?.id && (payload.stage_id || payload.amount)) {
|
||||||
|
await fetch(`/api/v1/sag/${createdCase.id}/pipeline`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
stage_id: payload.stage_id || null,
|
||||||
|
amount: payload.amount || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await loadOpportunities();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToDetail(id) {
|
function goToDetail(id) {
|
||||||
window.location.href = `/opportunities/${id}`;
|
window.location.href = `/sag/${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value, currency) {
|
function formatCurrency(value, currency) {
|
||||||
|
|||||||
@ -1,16 +1,106 @@
|
|||||||
from fastapi import APIRouter, Request
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Form
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from app.core.database import execute_query_single
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DISALLOWED_DASHBOARD_PATHS = {
|
||||||
|
"/dashboard/default",
|
||||||
|
"/dashboard/default/clear",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_dashboard_path(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
candidate = value.strip()
|
||||||
|
if not candidate.startswith("/"):
|
||||||
|
return ""
|
||||||
|
if candidate.startswith("/api"):
|
||||||
|
return ""
|
||||||
|
if candidate.startswith("//"):
|
||||||
|
return ""
|
||||||
|
if candidate in _DISALLOWED_DASHBOARD_PATHS:
|
||||||
|
return ""
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_default_dashboard(user_id: int) -> str:
|
||||||
|
try:
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT default_dashboard_path
|
||||||
|
FROM user_dashboard_preferences
|
||||||
|
WHERE user_id = %s
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return _sanitize_dashboard_path((row or {}).get("default_dashboard_path", ""))
|
||||||
|
except Exception as exc:
|
||||||
|
if "user_dashboard_preferences" in str(exc):
|
||||||
|
logger.warning("⚠️ user_dashboard_preferences table not found; using fallback default dashboard")
|
||||||
|
return ""
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_group_names(user_id: int):
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT LOWER(g.name) AS name
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return [r["name"] for r in (rows or []) if r.get("name")]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_technician_group(group_names) -> bool:
|
||||||
|
return any(
|
||||||
|
"technician" in group or "teknik" in group
|
||||||
|
for group in (group_names or [])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sales_group(group_names) -> bool:
|
||||||
|
return any(
|
||||||
|
"sales" in group or "salg" in group
|
||||||
|
for group in (group_names or [])
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request):
|
async def dashboard(request: Request):
|
||||||
"""
|
"""
|
||||||
Render the dashboard page
|
Render the dashboard page
|
||||||
"""
|
"""
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
preferred_dashboard = ""
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
preferred_dashboard = _get_user_default_dashboard(int(user_id))
|
||||||
|
|
||||||
|
if not preferred_dashboard:
|
||||||
|
preferred_dashboard = _sanitize_dashboard_path(request.cookies.get("bmc_default_dashboard", ""))
|
||||||
|
|
||||||
|
if preferred_dashboard and preferred_dashboard != "/":
|
||||||
|
return RedirectResponse(url=preferred_dashboard, status_code=302)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
group_names = _get_user_group_names(int(user_id))
|
||||||
|
if _is_technician_group(group_names):
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/ticket/dashboard/technician/v1?technician_user_id={int(user_id)}",
|
||||||
|
status_code=302
|
||||||
|
)
|
||||||
|
if _is_sales_group(group_names):
|
||||||
|
return RedirectResponse(url="/dashboard/sales", status_code=302)
|
||||||
|
|
||||||
# Fetch count of unknown billing worklogs
|
# Fetch count of unknown billing worklogs
|
||||||
unknown_query = """
|
unknown_query = """
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
@ -60,3 +150,197 @@ async def dashboard(request: Request):
|
|||||||
"bankruptcy_alerts": bankruptcy_alerts
|
"bankruptcy_alerts": bankruptcy_alerts
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/sales", response_class=HTMLResponse)
|
||||||
|
async def sales_dashboard(request: Request):
|
||||||
|
pipeline_stats_query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE s.status = 'åben') AS open_count,
|
||||||
|
COUNT(*) FILTER (WHERE s.status = 'lukket') AS closed_count,
|
||||||
|
COALESCE(SUM(COALESCE(s.pipeline_amount, 0)) FILTER (WHERE s.status = 'åben'), 0) AS open_value,
|
||||||
|
COALESCE(AVG(COALESCE(s.pipeline_probability, 0)) FILTER (WHERE s.status = 'åben'), 0) AS avg_probability
|
||||||
|
FROM sag_sager s
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
s.template_key = 'pipeline'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_tags et
|
||||||
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'case'
|
||||||
|
AND et.entity_id = s.id
|
||||||
|
AND LOWER(t.name) = 'pipeline'
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_tags st
|
||||||
|
WHERE st.sag_id = s.id
|
||||||
|
AND st.deleted_at IS NULL
|
||||||
|
AND LOWER(st.tag_navn) = 'pipeline'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
recent_opportunities_query = """
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.status,
|
||||||
|
s.pipeline_amount,
|
||||||
|
s.pipeline_probability,
|
||||||
|
ps.name AS pipeline_stage,
|
||||||
|
s.deadline,
|
||||||
|
s.created_at,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||||
|
COALESCE(u.full_name, u.username, 'Ingen') AS owner_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
s.template_key = 'pipeline'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_tags et
|
||||||
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'case'
|
||||||
|
AND et.entity_id = s.id
|
||||||
|
AND LOWER(t.name) = 'pipeline'
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_tags st
|
||||||
|
WHERE st.sag_id = s.id
|
||||||
|
AND st.deleted_at IS NULL
|
||||||
|
AND LOWER(st.tag_navn) = 'pipeline'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 12
|
||||||
|
"""
|
||||||
|
|
||||||
|
due_soon_query = """
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.deadline,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||||
|
COALESCE(u.full_name, u.username, 'Ingen') AS owner_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND s.deadline IS NOT NULL
|
||||||
|
AND s.deadline BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '14 days')
|
||||||
|
AND (
|
||||||
|
s.template_key = 'pipeline'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_tags et
|
||||||
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'case'
|
||||||
|
AND et.entity_id = s.id
|
||||||
|
AND LOWER(t.name) = 'pipeline'
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_tags st
|
||||||
|
WHERE st.sag_id = s.id
|
||||||
|
AND st.deleted_at IS NULL
|
||||||
|
AND LOWER(st.tag_navn) = 'pipeline'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY s.deadline ASC
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
|
||||||
|
pipeline_stats = execute_query_single(pipeline_stats_query) or {}
|
||||||
|
recent_opportunities = execute_query(recent_opportunities_query) or []
|
||||||
|
due_soon = execute_query(due_soon_query) or []
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"dashboard/frontend/sales.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"pipeline_stats": pipeline_stats,
|
||||||
|
"recent_opportunities": recent_opportunities,
|
||||||
|
"due_soon": due_soon,
|
||||||
|
"default_dashboard": _get_user_default_dashboard(getattr(request.state, "user_id", 0) or 0)
|
||||||
|
or request.cookies.get("bmc_default_dashboard", "")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboard/default")
|
||||||
|
async def set_default_dashboard(
|
||||||
|
request: Request,
|
||||||
|
dashboard_path: str = Form(...),
|
||||||
|
redirect_to: str = Form(default="/")
|
||||||
|
):
|
||||||
|
safe_path = _sanitize_dashboard_path(dashboard_path)
|
||||||
|
safe_redirect = _sanitize_dashboard_path(redirect_to) or "/"
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
response = RedirectResponse(url=safe_redirect, status_code=303)
|
||||||
|
if safe_path:
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_dashboard_preferences (user_id, default_dashboard_path, updated_at)
|
||||||
|
VALUES (%s, %s, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
default_dashboard_path = EXCLUDED.default_dashboard_path,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(int(user_id), safe_path)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if "user_dashboard_preferences" in str(exc):
|
||||||
|
logger.warning("⚠️ Could not persist dashboard preference in DB (table missing); cookie fallback still active")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
response.set_cookie(
|
||||||
|
key="bmc_default_dashboard",
|
||||||
|
value=safe_path,
|
||||||
|
httponly=True,
|
||||||
|
samesite="Lax"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/default")
|
||||||
|
async def set_default_dashboard_get_fallback():
|
||||||
|
return RedirectResponse(url="/settings#system", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboard/default/clear")
|
||||||
|
async def clear_default_dashboard(
|
||||||
|
request: Request,
|
||||||
|
redirect_to: str = Form(default="/")
|
||||||
|
):
|
||||||
|
safe_redirect = _sanitize_dashboard_path(redirect_to) or "/"
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
execute_query(
|
||||||
|
"DELETE FROM user_dashboard_preferences WHERE user_id = %s",
|
||||||
|
(int(user_id),)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if "user_dashboard_preferences" in str(exc):
|
||||||
|
logger.warning("⚠️ Could not clear DB dashboard preference (table missing); cookie fallback still active")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
response = RedirectResponse(url=safe_redirect, status_code=303)
|
||||||
|
response.delete_cookie("bmc_default_dashboard")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/default/clear")
|
||||||
|
async def clear_default_dashboard_get_fallback():
|
||||||
|
return RedirectResponse(url="/settings#system", status_code=303)
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-bold">Vælg kontaktperson</label>
|
<label class="form-label fw-bold">Vælg kontaktperson</label>
|
||||||
<select id="ownerContactSelect" name="owner_contact_id" class="form-select" required>
|
<input
|
||||||
|
type="text"
|
||||||
|
id="ownerContactSearch"
|
||||||
|
class="form-control mb-2"
|
||||||
|
placeholder="🔍 Søg kontaktperson..."
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="ownerContactSelect"
|
||||||
|
name="owner_contact_id"
|
||||||
|
class="form-select"
|
||||||
|
data-current-owner-contact-id="{{ owner_contact_ns.contact.contact_id if owner_contact_ns.contact else '' }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
<option value="">-- Vælg kontaktperson --</option>
|
<option value="">-- Vælg kontaktperson --</option>
|
||||||
{% for contact in owner_contacts %}
|
|
||||||
<option
|
|
||||||
value="{{ contact.id }}"
|
|
||||||
data-customer-id="{{ contact.customer_id }}"
|
|
||||||
{% if owner_contact_ns.contact and owner_contact_ns.contact.contact_id == contact.id %}selected{% endif %}
|
|
||||||
>
|
|
||||||
{{ contact.first_name }} {{ contact.last_name }}{% if contact.email %} ({{ contact.email }}){% endif %}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
</select>
|
||||||
<div id="ownerContactHelp" class="form-text">Viser kun kontakter for valgt virksomhed.</div>
|
<div id="ownerContactHelp" class="form-text">Viser kun kontakter for valgt virksomhed.</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1038,9 +1042,86 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
|
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
|
||||||
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
|
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
|
||||||
|
const ownerContactSearch = document.getElementById('ownerContactSearch');
|
||||||
const ownerContactSelect = document.getElementById('ownerContactSelect');
|
const ownerContactSelect = document.getElementById('ownerContactSelect');
|
||||||
const ownerCustomerHelp = document.getElementById('ownerCustomerHelp');
|
const ownerCustomerHelp = document.getElementById('ownerCustomerHelp');
|
||||||
const ownerContactHelp = document.getElementById('ownerContactHelp');
|
const ownerContactHelp = document.getElementById('ownerContactHelp');
|
||||||
|
let ownerContactsCache = [];
|
||||||
|
const initialOwnerCustomerOptions = ownerCustomerSelect
|
||||||
|
? Array.from(ownerCustomerSelect.options).map(option => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.textContent,
|
||||||
|
selected: option.selected
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
let ownerCustomerSearchTimeout;
|
||||||
|
|
||||||
|
function renderOwnerCustomerOptions(items, keepValue = null) {
|
||||||
|
if (!ownerCustomerSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerCustomerSelect.innerHTML = '';
|
||||||
|
const placeholder = document.createElement('option');
|
||||||
|
placeholder.value = '';
|
||||||
|
placeholder.textContent = '-- Vælg kunde --';
|
||||||
|
ownerCustomerSelect.appendChild(placeholder);
|
||||||
|
|
||||||
|
(items || []).forEach(item => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = String(item.id);
|
||||||
|
option.textContent = item.name || item.navn || '';
|
||||||
|
ownerCustomerSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keepValue) {
|
||||||
|
ownerCustomerSelect.value = String(keepValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreInitialOwnerCustomers() {
|
||||||
|
if (!ownerCustomerSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerCustomerSelect.innerHTML = '';
|
||||||
|
initialOwnerCustomerOptions.forEach(item => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = item.value;
|
||||||
|
option.textContent = item.label;
|
||||||
|
if (item.selected) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
ownerCustomerSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchOwnerCustomersRemote(query) {
|
||||||
|
if (!ownerCustomerSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Search request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await response.json();
|
||||||
|
const currentValue = ownerCustomerSelect.value;
|
||||||
|
renderOwnerCustomerOptions(rows || [], currentValue);
|
||||||
|
|
||||||
|
if (ownerCustomerHelp) {
|
||||||
|
ownerCustomerHelp.textContent = rows && rows.length
|
||||||
|
? `Viser ${rows.length} virksomhed(er).`
|
||||||
|
: 'Ingen virksomheder matcher søgningen.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (ownerCustomerHelp) {
|
||||||
|
ownerCustomerHelp.textContent = 'Søgning fejlede. Prøv igen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function filterOwnerCustomers() {
|
function filterOwnerCustomers() {
|
||||||
if (!ownerCustomerSearch || !ownerCustomerSelect) {
|
if (!ownerCustomerSearch || !ownerCustomerSelect) {
|
||||||
@ -1048,6 +1129,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filter = ownerCustomerSearch.value.toLowerCase().trim();
|
const filter = ownerCustomerSearch.value.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (filter.length >= 2) {
|
||||||
|
clearTimeout(ownerCustomerSearchTimeout);
|
||||||
|
ownerCustomerSearchTimeout = setTimeout(() => {
|
||||||
|
searchOwnerCustomersRemote(filter);
|
||||||
|
}, 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreInitialOwnerCustomers();
|
||||||
const options = Array.from(ownerCustomerSelect.options);
|
const options = Array.from(ownerCustomerSelect.options);
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
|
|
||||||
@ -1079,56 +1170,104 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterOwnerContacts() {
|
async function loadOwnerContactsForCustomer(customerId) {
|
||||||
if (!ownerCustomerSelect || !ownerContactSelect) {
|
if (!ownerContactSelect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedCustomerId = ownerCustomerSelect.value;
|
ownerContactsCache = [];
|
||||||
const options = Array.from(ownerContactSelect.options);
|
ownerContactSelect.innerHTML = '<option value="">-- Vælg kontaktperson --</option>';
|
||||||
let visibleCount = 0;
|
|
||||||
|
|
||||||
options.forEach((option, index) => {
|
if (!customerId) {
|
||||||
if (index === 0) {
|
ownerContactSelect.disabled = true;
|
||||||
option.hidden = false;
|
if (ownerContactHelp) {
|
||||||
return;
|
ownerContactHelp.textContent = 'Vælg først virksomhed.';
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
const optionCustomerId = option.getAttribute('data-customer-id');
|
|
||||||
const isVisible = selectedCustomerId && optionCustomerId === selectedCustomerId;
|
|
||||||
option.hidden = !isVisible;
|
|
||||||
if (isVisible) {
|
|
||||||
visibleCount += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedOption = ownerContactSelect.selectedOptions[0];
|
|
||||||
if (!selectedOption || selectedOption.hidden) {
|
|
||||||
ownerContactSelect.value = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ownerContactSelect.disabled = !selectedCustomerId || visibleCount === 0;
|
try {
|
||||||
|
const response = await fetch(`/api/v1/customers/${encodeURIComponent(customerId)}/contacts`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load contacts');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await response.json();
|
||||||
|
ownerContactsCache = rows || [];
|
||||||
|
|
||||||
|
ownerContactSelect.disabled = !ownerContactsCache.length;
|
||||||
|
filterOwnerContactsSearch();
|
||||||
|
if (ownerContactHelp) {
|
||||||
|
ownerContactHelp.textContent = ownerContactsCache.length
|
||||||
|
? `Viser ${ownerContactsCache.length} kontaktperson(er) for valgt virksomhed.`
|
||||||
|
: 'Ingen kontaktpersoner fundet for valgt virksomhed.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ownerContactSelect.disabled = true;
|
||||||
|
ownerContactsCache = [];
|
||||||
|
if (ownerContactHelp) {
|
||||||
|
ownerContactHelp.textContent = 'Kunne ikke hente kontaktpersoner. Prøv igen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOwnerContactsSearch() {
|
||||||
|
if (!ownerContactSelect || !ownerContactSearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = ownerContactSearch.value.toLowerCase().trim();
|
||||||
|
const preferredContactId = ownerContactSelect.getAttribute('data-current-owner-contact-id');
|
||||||
|
const currentValue = ownerContactSelect.value;
|
||||||
|
|
||||||
|
const filteredContacts = ownerContactsCache.filter(contact => {
|
||||||
|
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim().toLowerCase();
|
||||||
|
const email = (contact.email || '').toLowerCase();
|
||||||
|
const phone = (contact.phone || '').toLowerCase();
|
||||||
|
return !filter || fullName.includes(filter) || email.includes(filter) || phone.includes(filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
ownerContactSelect.innerHTML = '<option value="">-- Vælg kontaktperson --</option>';
|
||||||
|
filteredContacts.forEach(contact => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = String(contact.id);
|
||||||
|
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
|
||||||
|
option.textContent = contact.email ? `${fullName} (${contact.email})` : fullName;
|
||||||
|
if (
|
||||||
|
(currentValue && String(contact.id) === String(currentValue)) ||
|
||||||
|
(!currentValue && preferredContactId && String(contact.id) === String(preferredContactId))
|
||||||
|
) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
ownerContactSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
if (ownerContactHelp) {
|
if (ownerContactHelp) {
|
||||||
if (!selectedCustomerId) {
|
if (ownerContactSelect.disabled) {
|
||||||
ownerContactHelp.textContent = 'Vælg først virksomhed.';
|
ownerContactHelp.textContent = 'Vælg først virksomhed.';
|
||||||
} else if (visibleCount === 0) {
|
} else if (filteredContacts.length === 0) {
|
||||||
ownerContactHelp.textContent = 'Ingen kontaktpersoner fundet for valgt virksomhed.';
|
ownerContactHelp.textContent = 'Ingen kontaktpersoner matcher søgningen.';
|
||||||
} else {
|
} else {
|
||||||
ownerContactHelp.textContent = 'Viser kun kontakter for valgt virksomhed.';
|
ownerContactHelp.textContent = `Viser ${filteredContacts.length} kontaktperson(er) for valgt virksomhed.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ownerCustomerSelect && ownerContactSelect) {
|
if (ownerCustomerSelect && ownerContactSelect) {
|
||||||
ownerCustomerSelect.addEventListener('change', filterOwnerContacts);
|
ownerCustomerSelect.addEventListener('change', function() {
|
||||||
|
ownerContactSelect.setAttribute('data-current-owner-contact-id', '');
|
||||||
|
loadOwnerContactsForCustomer(ownerCustomerSelect.value);
|
||||||
|
});
|
||||||
if (ownerCustomerSearch) {
|
if (ownerCustomerSearch) {
|
||||||
ownerCustomerSearch.addEventListener('input', function() {
|
ownerCustomerSearch.addEventListener('input', function() {
|
||||||
filterOwnerCustomers();
|
filterOwnerCustomers();
|
||||||
filterOwnerContacts();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (ownerContactSearch) {
|
||||||
|
ownerContactSearch.addEventListener('input', filterOwnerContactsSearch);
|
||||||
|
}
|
||||||
filterOwnerCustomers();
|
filterOwnerCustomers();
|
||||||
filterOwnerContacts();
|
loadOwnerContactsForCustomer(ownerCustomerSelect.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.renderEntityTags) {
|
if (window.renderEntityTags) {
|
||||||
|
|||||||
@ -147,7 +147,7 @@ def list_locations_view(
|
|||||||
is_active_bool = False
|
is_active_bool = False
|
||||||
|
|
||||||
# Query locations directly from database
|
# Query locations directly from database
|
||||||
where_clauses = []
|
where_clauses = ["deleted_at IS NULL"]
|
||||||
query_params = []
|
query_params = []
|
||||||
|
|
||||||
if location_type:
|
if location_type:
|
||||||
@ -272,7 +272,7 @@ def create_location_view():
|
|||||||
parent_locations = execute_query("""
|
parent_locations = execute_query("""
|
||||||
SELECT id, name, location_type
|
SELECT id, name, location_type
|
||||||
FROM locations_locations
|
FROM locations_locations
|
||||||
WHERE is_active = true
|
WHERE deleted_at IS NULL AND is_active = true
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
LIMIT 1000
|
LIMIT 1000
|
||||||
""")
|
""")
|
||||||
@ -322,12 +322,12 @@ def location_wizard_view():
|
|||||||
logger.info("🧭 Rendering location wizard")
|
logger.info("🧭 Rendering location wizard")
|
||||||
|
|
||||||
parent_locations = execute_query("""
|
parent_locations = execute_query("""
|
||||||
SELECT id, name, location_type
|
SELECT id, name, location_type
|
||||||
FROM locations_locations
|
FROM locations_locations
|
||||||
WHERE is_active = true
|
WHERE deleted_at IS NULL AND is_active = true
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
LIMIT 1000
|
LIMIT 1000
|
||||||
""")
|
""")
|
||||||
|
|
||||||
customers = execute_query("""
|
customers = execute_query("""
|
||||||
SELECT id, name, email, phone
|
SELECT id, name, email, phone
|
||||||
|
|||||||
@ -508,12 +508,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(async response => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
deleteModal.hide();
|
deleteModal.hide();
|
||||||
setTimeout(() => location.reload(), 300);
|
setTimeout(() => location.reload(), 300);
|
||||||
} else {
|
} else {
|
||||||
alert('Fejl ved sletning af lokation');
|
const err = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 404) {
|
||||||
|
alert(err.detail || 'Lokationen er allerede slettet. Siden opdateres.');
|
||||||
|
setTimeout(() => location.reload(), 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert(err.detail || 'Fejl ved sletning af lokation');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
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")
|
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_case_status(status_value: Optional[str]) -> str:
|
||||||
|
if not status_value:
|
||||||
|
return "åben"
|
||||||
|
|
||||||
|
normalized = str(status_value).strip().lower()
|
||||||
|
if normalized == "afventer":
|
||||||
|
return "åben"
|
||||||
|
if normalized in {"åben", "lukket"}:
|
||||||
|
return normalized
|
||||||
|
return "åben"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
|
if parsed.tzinfo is not None:
|
||||||
|
parsed = parsed.replace(tzinfo=None)
|
||||||
|
return parsed.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_optional_int(value: Optional[object], field_name: str) -> Optional[int]:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_user_id(user_id: Optional[int], field_name: str = "ansvarlig_bruger_id") -> None:
|
||||||
|
if user_id is None:
|
||||||
|
return
|
||||||
|
exists = execute_query("SELECT 1 FROM users WHERE user_id = %s", (user_id,))
|
||||||
|
if not exists:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_group_id(group_id: Optional[int], field_name: str = "assigned_group_id") -> None:
|
||||||
|
if group_id is None:
|
||||||
|
return
|
||||||
|
exists = execute_query("SELECT 1 FROM groups WHERE id = %s", (group_id,))
|
||||||
|
if not exists:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SAGER - CRUD Operations
|
# SAGER - CRUD Operations
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -52,6 +109,7 @@ async def list_sager(
|
|||||||
tag: Optional[str] = Query(None),
|
tag: Optional[str] = Query(None),
|
||||||
customer_id: Optional[int] = Query(None),
|
customer_id: Optional[int] = Query(None),
|
||||||
ansvarlig_bruger_id: Optional[int] = Query(None),
|
ansvarlig_bruger_id: Optional[int] = Query(None),
|
||||||
|
assigned_group_id: Optional[int] = Query(None),
|
||||||
include_deferred: bool = Query(False),
|
include_deferred: bool = Query(False),
|
||||||
q: Optional[str] = Query(None),
|
q: Optional[str] = Query(None),
|
||||||
limit: Optional[int] = Query(None, ge=1, le=200),
|
limit: Optional[int] = Query(None, ge=1, le=200),
|
||||||
@ -59,28 +117,39 @@ async def list_sager(
|
|||||||
):
|
):
|
||||||
"""List all cases with optional filtering."""
|
"""List all cases with optional filtering."""
|
||||||
try:
|
try:
|
||||||
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
|
query = """
|
||||||
|
SELECT s.*,
|
||||||
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||||
|
g.name AS assigned_group_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
"""
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if not include_deferred:
|
if not include_deferred:
|
||||||
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
|
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query += " AND status = %s"
|
query += " AND s.status = %s"
|
||||||
params.append(status)
|
params.append(status)
|
||||||
if customer_id:
|
if customer_id:
|
||||||
query += " AND customer_id = %s"
|
query += " AND s.customer_id = %s"
|
||||||
params.append(customer_id)
|
params.append(customer_id)
|
||||||
if ansvarlig_bruger_id:
|
if ansvarlig_bruger_id:
|
||||||
query += " AND ansvarlig_bruger_id = %s"
|
query += " AND s.ansvarlig_bruger_id = %s"
|
||||||
params.append(ansvarlig_bruger_id)
|
params.append(ansvarlig_bruger_id)
|
||||||
|
if assigned_group_id:
|
||||||
|
query += " AND s.assigned_group_id = %s"
|
||||||
|
params.append(assigned_group_id)
|
||||||
|
|
||||||
if q:
|
if q:
|
||||||
query += " AND (LOWER(titel) LIKE %s OR CAST(id AS TEXT) LIKE %s)"
|
query += " AND (LOWER(s.titel) LIKE %s OR CAST(s.id AS TEXT) LIKE %s)"
|
||||||
q_like = f"%{q.lower()}%"
|
q_like = f"%{q.lower()}%"
|
||||||
params.extend([q_like, q_like])
|
params.extend([q_like, q_like])
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC"
|
query += " ORDER BY s.created_at DESC"
|
||||||
|
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
query += " LIMIT %s OFFSET %s"
|
query += " LIMIT %s OFFSET %s"
|
||||||
@ -162,14 +231,19 @@ async def create_sag(data: dict):
|
|||||||
if not data.get("customer_id"):
|
if not data.get("customer_id"):
|
||||||
raise HTTPException(status_code=400, detail="customer_id is required")
|
raise HTTPException(status_code=400, detail="customer_id is required")
|
||||||
|
|
||||||
status = data.get("status", "åben")
|
status = _normalize_case_status(data.get("status"))
|
||||||
if status not in {"åben", "lukket"}:
|
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
||||||
status = "åben"
|
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
|
||||||
|
ansvarlig_bruger_id = _coerce_optional_int(data.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
|
||||||
|
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
|
||||||
|
|
||||||
|
_validate_user_id(ansvarlig_bruger_id)
|
||||||
|
_validate_group_id(assigned_group_id)
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO sag_sager
|
INSERT INTO sag_sager
|
||||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status)
|
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
params = (
|
params = (
|
||||||
@ -178,10 +252,11 @@ async def create_sag(data: dict):
|
|||||||
data.get("template_key") or data.get("type", "ticket"),
|
data.get("template_key") or data.get("type", "ticket"),
|
||||||
status,
|
status,
|
||||||
data.get("customer_id"),
|
data.get("customer_id"),
|
||||||
data.get("ansvarlig_bruger_id"),
|
ansvarlig_bruger_id,
|
||||||
|
assigned_group_id,
|
||||||
data.get("created_by_user_id", 1),
|
data.get("created_by_user_id", 1),
|
||||||
data.get("deadline"),
|
deadline,
|
||||||
data.get("deferred_until"),
|
deferred_until,
|
||||||
data.get("deferred_until_case_id"),
|
data.get("deferred_until_case_id"),
|
||||||
data.get("deferred_until_status"),
|
data.get("deferred_until_status"),
|
||||||
)
|
)
|
||||||
@ -199,7 +274,15 @@ async def create_sag(data: dict):
|
|||||||
async def get_sag(sag_id: int):
|
async def get_sag(sag_id: int):
|
||||||
"""Get a specific case."""
|
"""Get a specific case."""
|
||||||
try:
|
try:
|
||||||
query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
|
query = """
|
||||||
|
SELECT s.*,
|
||||||
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||||
|
g.name AS assigned_group_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||||
|
WHERE s.id = %s AND s.deleted_at IS NULL
|
||||||
|
"""
|
||||||
result = execute_query(query, (sag_id,))
|
result = execute_query(query, (sag_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Case not found")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
@ -402,8 +485,32 @@ async def update_sag(sag_id: int, updates: dict):
|
|||||||
if "type" in updates and "template_key" not in updates:
|
if "type" in updates and "template_key" not in updates:
|
||||||
updates["template_key"] = updates.get("type")
|
updates["template_key"] = updates.get("type")
|
||||||
|
|
||||||
|
if "status" in updates:
|
||||||
|
updates["status"] = _normalize_case_status(updates.get("status"))
|
||||||
|
if "deadline" in updates:
|
||||||
|
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
||||||
|
if "deferred_until" in updates:
|
||||||
|
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
|
||||||
|
if "ansvarlig_bruger_id" in updates:
|
||||||
|
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
|
||||||
|
_validate_user_id(updates["ansvarlig_bruger_id"])
|
||||||
|
if "assigned_group_id" in updates:
|
||||||
|
updates["assigned_group_id"] = _coerce_optional_int(updates.get("assigned_group_id"), "assigned_group_id")
|
||||||
|
_validate_group_id(updates["assigned_group_id"])
|
||||||
|
|
||||||
# Build dynamic update query
|
# Build dynamic update query
|
||||||
allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status"]
|
allowed_fields = [
|
||||||
|
"titel",
|
||||||
|
"beskrivelse",
|
||||||
|
"template_key",
|
||||||
|
"status",
|
||||||
|
"ansvarlig_bruger_id",
|
||||||
|
"assigned_group_id",
|
||||||
|
"deadline",
|
||||||
|
"deferred_until",
|
||||||
|
"deferred_until_case_id",
|
||||||
|
"deferred_until_status",
|
||||||
|
]
|
||||||
set_clauses = []
|
set_clauses = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
@ -1036,8 +1143,15 @@ async def add_case_location(sag_id: int, data: dict):
|
|||||||
async def remove_case_location(sag_id: int, location_id: int):
|
async def remove_case_location(sag_id: int, location_id: int):
|
||||||
"""Remove location from case."""
|
"""Remove location from case."""
|
||||||
try:
|
try:
|
||||||
query = "UPDATE sag_lokationer SET deleted_at = NOW() WHERE sag_id = %s AND location_id = %s RETURNING id"
|
query = """
|
||||||
result = execute_query(query, (sag_id, location_id))
|
UPDATE sag_lokationer
|
||||||
|
SET deleted_at = NOW()
|
||||||
|
WHERE sag_id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND (location_id = %s OR id = %s)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (sag_id, location_id, location_id))
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.info("✅ Location %s removed from case %s", location_id, sag_id)
|
logger.info("✅ Location %s removed from case %s", location_id, sag_id)
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@ -8,25 +10,78 @@ from app.core.database import execute_query
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_deadline_overdue(deadline_value) -> bool:
|
||||||
|
if not deadline_value:
|
||||||
|
return False
|
||||||
|
if isinstance(deadline_value, datetime):
|
||||||
|
return deadline_value.date() < date.today()
|
||||||
|
if isinstance(deadline_value, date):
|
||||||
|
return deadline_value < date.today()
|
||||||
|
return False
|
||||||
|
|
||||||
# Setup template directory
|
# Setup template directory
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_assignment_users():
|
||||||
|
return execute_query(
|
||||||
|
"""
|
||||||
|
SELECT user_id, COALESCE(full_name, username) AS display_name
|
||||||
|
FROM users
|
||||||
|
ORDER BY display_name
|
||||||
|
""",
|
||||||
|
()
|
||||||
|
) or []
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_assignment_groups():
|
||||||
|
return execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, name
|
||||||
|
FROM groups
|
||||||
|
ORDER BY name
|
||||||
|
""",
|
||||||
|
()
|
||||||
|
) or []
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
|
||||||
|
"""Convert empty strings and None to None, otherwise parse as int."""
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sag", response_class=HTMLResponse)
|
@router.get("/sag", response_class=HTMLResponse)
|
||||||
async def sager_liste(
|
async def sager_liste(
|
||||||
request: Request,
|
request: Request,
|
||||||
status: str = Query(None),
|
status: str = Query(None),
|
||||||
tag: str = Query(None),
|
tag: str = Query(None),
|
||||||
customer_id: int = Query(None),
|
customer_id: str = Query(None),
|
||||||
|
ansvarlig_bruger_id: str = Query(None),
|
||||||
|
assigned_group_id: str = Query(None),
|
||||||
include_deferred: bool = Query(False),
|
include_deferred: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""Display list of all cases."""
|
"""Display list of all cases."""
|
||||||
try:
|
try:
|
||||||
|
# Coerce string params to optional ints
|
||||||
|
customer_id_int = _coerce_optional_int(customer_id)
|
||||||
|
ansvarlig_bruger_id_int = _coerce_optional_int(ansvarlig_bruger_id)
|
||||||
|
assigned_group_id_int = _coerce_optional_int(assigned_group_id)
|
||||||
query = """
|
query = """
|
||||||
SELECT s.*,
|
SELECT s.*,
|
||||||
c.name as customer_name,
|
c.name as customer_name,
|
||||||
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn
|
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
|
||||||
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||||
|
g.name AS assigned_group_name
|
||||||
FROM sag_sager s
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT cc.contact_id
|
SELECT cc.contact_id
|
||||||
FROM contact_companies cc
|
FROM contact_companies cc
|
||||||
@ -50,9 +105,15 @@ async def sager_liste(
|
|||||||
if status:
|
if status:
|
||||||
query += " AND s.status = %s"
|
query += " AND s.status = %s"
|
||||||
params.append(status)
|
params.append(status)
|
||||||
if customer_id:
|
if customer_id_int:
|
||||||
query += " AND s.customer_id = %s"
|
query += " AND s.customer_id = %s"
|
||||||
params.append(customer_id)
|
params.append(customer_id_int)
|
||||||
|
if ansvarlig_bruger_id_int:
|
||||||
|
query += " AND s.ansvarlig_bruger_id = %s"
|
||||||
|
params.append(ansvarlig_bruger_id_int)
|
||||||
|
if assigned_group_id_int:
|
||||||
|
query += " AND s.assigned_group_id = %s"
|
||||||
|
params.append(assigned_group_id_int)
|
||||||
|
|
||||||
query += " ORDER BY s.created_at DESC"
|
query += " ORDER BY s.created_at DESC"
|
||||||
sager = execute_query(query, tuple(params))
|
sager = execute_query(query, tuple(params))
|
||||||
@ -119,6 +180,10 @@ async def sager_liste(
|
|||||||
"current_tag": tag,
|
"current_tag": tag,
|
||||||
"include_deferred": include_deferred,
|
"include_deferred": include_deferred,
|
||||||
"toggle_include_deferred_url": toggle_include_deferred_url,
|
"toggle_include_deferred_url": toggle_include_deferred_url,
|
||||||
|
"assignment_users": _fetch_assignment_users(),
|
||||||
|
"assignment_groups": _fetch_assignment_groups(),
|
||||||
|
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||||
|
"current_assigned_group_id": assigned_group_id_int,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error displaying case list: %s", e)
|
logger.error("❌ Error displaying case list: %s", e)
|
||||||
@ -127,7 +192,11 @@ async def sager_liste(
|
|||||||
@router.get("/sag/new", response_class=HTMLResponse)
|
@router.get("/sag/new", response_class=HTMLResponse)
|
||||||
async def opret_sag_side(request: Request):
|
async def opret_sag_side(request: Request):
|
||||||
"""Show create case form."""
|
"""Show create case form."""
|
||||||
return templates.TemplateResponse("modules/sag/templates/create.html", {"request": request})
|
return templates.TemplateResponse("modules/sag/templates/create.html", {
|
||||||
|
"request": request,
|
||||||
|
"assignment_users": _fetch_assignment_users(),
|
||||||
|
"assignment_groups": _fetch_assignment_groups(),
|
||||||
|
})
|
||||||
|
|
||||||
@router.get("/sag/varekob-salg", response_class=HTMLResponse)
|
@router.get("/sag/varekob-salg", response_class=HTMLResponse)
|
||||||
async def sag_varekob_salg(request: Request):
|
async def sag_varekob_salg(request: Request):
|
||||||
@ -141,7 +210,15 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
"""Display case details."""
|
"""Display case details."""
|
||||||
try:
|
try:
|
||||||
# Fetch main case
|
# Fetch main case
|
||||||
sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
|
sag_query = """
|
||||||
|
SELECT s.*,
|
||||||
|
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||||
|
g.name AS assigned_group_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||||
|
WHERE s.id = %s AND s.deleted_at IS NULL
|
||||||
|
"""
|
||||||
sag_result = execute_query(sag_query, (sag_id,))
|
sag_result = execute_query(sag_query, (sag_id,))
|
||||||
|
|
||||||
if not sag_result:
|
if not sag_result:
|
||||||
@ -375,6 +452,7 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
pipeline_stages = []
|
pipeline_stages = []
|
||||||
|
|
||||||
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
|
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
|
||||||
|
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
|
||||||
|
|
||||||
return templates.TemplateResponse("modules/sag/templates/detail.html", {
|
return templates.TemplateResponse("modules/sag/templates/detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
@ -398,6 +476,9 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
"related_case_options": related_case_options,
|
"related_case_options": related_case_options,
|
||||||
"pipeline_stages": pipeline_stages,
|
"pipeline_stages": pipeline_stages,
|
||||||
"status_options": [s["status"] for s in statuses],
|
"status_options": [s["status"] for s in statuses],
|
||||||
|
"is_deadline_overdue": is_deadline_overdue,
|
||||||
|
"assignment_users": _fetch_assignment_users(),
|
||||||
|
"assignment_groups": _fetch_assignment_groups(),
|
||||||
})
|
})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@ -419,6 +500,8 @@ async def sag_rediger(request: Request, sag_id: int):
|
|||||||
return templates.TemplateResponse("modules/sag/templates/edit.html", {
|
return templates.TemplateResponse("modules/sag/templates/edit.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"case": sag_result[0],
|
"case": sag_result[0],
|
||||||
|
"assignment_users": _fetch_assignment_users(),
|
||||||
|
"assignment_groups": _fetch_assignment_groups(),
|
||||||
})
|
})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -238,7 +238,7 @@
|
|||||||
<!-- Section: Metadata -->
|
<!-- Section: Metadata -->
|
||||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
|
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="type" required>
|
<select class="form-select" id="type" required>
|
||||||
<option value="ticket" selected>🎫 Ticket</option>
|
<option value="ticket" selected>🎫 Ticket</option>
|
||||||
@ -248,7 +248,7 @@
|
|||||||
<option value="service">🛠️ Service</option>
|
<option value="service">🛠️ Service</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="status" required>
|
<select class="form-select" id="status" required>
|
||||||
<option value="åben" selected>🟢 Åben</option>
|
<option value="åben" selected>🟢 Åben</option>
|
||||||
@ -256,15 +256,28 @@
|
|||||||
<option value="lukket">🔴 Lukket</option>
|
<option value="lukket">🔴 Lukket</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
<div class="col-md-4">
|
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
||||||
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig (ID)</label>
|
<select class="form-select" id="ansvarlig_bruger_id">
|
||||||
<div class="input-group">
|
<option value="">Ingen</option>
|
||||||
<span class="input-group-text bg-light border-end-0"><i class="bi bi-person-badge"></i></span>
|
{% for user in assignment_users or [] %}
|
||||||
<input type="number" class="form-control border-start-0 ps-0" id="ansvarlig_bruger_id" placeholder="Bruger ID">
|
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
|
||||||
</div>
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
||||||
|
<select class="form-select" id="assigned_group_id">
|
||||||
|
<option value="">Ingen</option>
|
||||||
|
{% for group in assignment_groups or [] %}
|
||||||
|
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="deadline" class="form-label">Deadline</label>
|
<label for="deadline" class="form-label">Deadline</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@ -839,6 +852,7 @@
|
|||||||
status: status,
|
status: status,
|
||||||
customer_id: selectedCustomer ? selectedCustomer.id : null,
|
customer_id: selectedCustomer ? selectedCustomer.id : null,
|
||||||
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
||||||
|
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
|
||||||
created_by_user_id: 1, // HARDCODED for now, should come from auth
|
created_by_user_id: 1, // HARDCODED for now, should come from auth
|
||||||
deadline: document.getElementById('deadline').value || null
|
deadline: document.getElementById('deadline').value || null
|
||||||
};
|
};
|
||||||
|
|||||||
@ -716,11 +716,20 @@
|
|||||||
<span class="text-muted" style="margin-right: 0.3rem;">Opr:</span> {{ case.created_at.strftime('%d/%m-%y') if case.created_at else '-' }}
|
<span class="text-muted" style="margin-right: 0.3rem;">Opr:</span> {{ case.created_at.strftime('%d/%m-%y') if case.created_at else '-' }}
|
||||||
<span class="text-muted mx-2">|</span>
|
<span class="text-muted mx-2">|</span>
|
||||||
<span class="text-muted" style="margin-right: 0.3rem;">Opd:</span> {{ case.updated_at.strftime('%d/%m-%y') if case.updated_at else '-' }}
|
<span class="text-muted" style="margin-right: 0.3rem;">Opd:</span> {{ case.updated_at.strftime('%d/%m-%y') if case.updated_at else '-' }}
|
||||||
<span class="text-muted mx-2">|</span>
|
</div>
|
||||||
<span class="text-muted" style="margin-right: 0.3rem;">Deadline:</span>
|
|
||||||
<strong class="{{ 'text-danger' if case.deadline and case.deadline < now else '' }}">
|
<div class="d-flex align-items-center ps-3 border-start">
|
||||||
{{ case.deadline.strftime('%d/%m-%y') if case.deadline else 'Ingen' }}
|
<strong style="color: var(--accent); margin-right: 0.4rem;">Deadline:</strong>
|
||||||
</strong>
|
{% if case.deadline %}
|
||||||
|
<span class="badge bg-light text-dark border me-1 {{ 'text-danger border-danger' if is_deadline_overdue else '' }}">
|
||||||
|
<i class="bi bi-clock me-1"></i>{{ case.deadline.strftime('%d/%m-%y') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted fst-italic me-1">Ingen</span>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn-link btn-sm p-0 text-muted" onclick="openDeadlineModal()" title="Rediger deadline">
|
||||||
|
<i class="bi bi-pencil-square"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Deferred Logic integrated -->
|
<!-- Deferred Logic integrated -->
|
||||||
@ -741,6 +750,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment Card -->
|
||||||
|
<div class="card mb-3" style="background: var(--bg-card); box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small text-muted">Ansvarlig medarbejder</label>
|
||||||
|
<select id="assignmentUserSelect" class="form-select form-select-sm">
|
||||||
|
<option value="">Ingen</option>
|
||||||
|
{% for user in assignment_users or [] %}
|
||||||
|
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small text-muted">Ansvarlig gruppe</label>
|
||||||
|
<select id="assignmentGroupSelect" class="form-select form-select-sm">
|
||||||
|
<option value="">Ingen</option>
|
||||||
|
{% for group in assignment_groups or [] %}
|
||||||
|
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex justify-content-end">
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="saveAssignment()">Gem tildeling</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="assignmentStatus" class="small text-muted mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tabs Navigation -->
|
<!-- Tabs Navigation -->
|
||||||
<ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist">
|
<ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
@ -925,6 +964,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
|
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
|
||||||
|
<div class="alert alert-light border small py-2 px-3 mb-3">
|
||||||
|
<div class="fw-semibold mb-1"><i class="bi bi-info-circle me-1"></i>Hvad betyder relationstyper?</div>
|
||||||
|
<div><strong>Relateret til</strong>: Faglig kobling uden direkte afhængighed.</div>
|
||||||
|
<div><strong>Afledt af</strong>: Denne sag er opstået på baggrund af en anden sag.</div>
|
||||||
|
<div><strong>Årsag til</strong>: Denne sag er årsagen til en anden sag.</div>
|
||||||
|
<div><strong>Blokkerer</strong>: Arbejde i en sag stopper fremdrift i den anden.</div>
|
||||||
|
</div>
|
||||||
{% macro render_tree(nodes) %}
|
{% macro render_tree(nodes) %}
|
||||||
<ul class="relation-tree">
|
<ul class="relation-tree">
|
||||||
{% for node in nodes %}
|
{% for node in nodes %}
|
||||||
@ -936,16 +982,23 @@
|
|||||||
{% if node.relation_type %}
|
{% if node.relation_type %}
|
||||||
{% set rel_icon = 'bi-link-45deg' %}
|
{% set rel_icon = 'bi-link-45deg' %}
|
||||||
{% set rel_color = 'text-muted' %}
|
{% set rel_color = 'text-muted' %}
|
||||||
|
{% set rel_help = 'Faglig kobling uden direkte afhængighed' %}
|
||||||
|
|
||||||
{% if node.relation_type == 'Afledt af' %}
|
{% if node.relation_type == 'Afledt af' %}
|
||||||
{% set rel_icon = 'bi-arrow-return-right' %}
|
{% set rel_icon = 'bi-arrow-return-right' %}
|
||||||
{% set rel_color = 'text-info' %}
|
{% set rel_color = 'text-info' %}
|
||||||
|
{% set rel_help = 'Denne sag er opstået på baggrund af en anden sag' %}
|
||||||
|
{% elif node.relation_type == 'Årsag til' %}
|
||||||
|
{% set rel_icon = 'bi-arrow-right-circle' %}
|
||||||
|
{% set rel_color = 'text-primary' %}
|
||||||
|
{% set rel_help = 'Denne sag er årsag til en anden sag' %}
|
||||||
{% elif node.relation_type == 'Blokkerer' %}
|
{% elif node.relation_type == 'Blokkerer' %}
|
||||||
{% set rel_icon = 'bi-slash-circle' %}
|
{% set rel_icon = 'bi-slash-circle' %}
|
||||||
{% set rel_color = 'text-danger' %}
|
{% set rel_color = 'text-danger' %}
|
||||||
|
{% set rel_help = 'Arbejdet i denne sag blokerer den anden' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<span class="relation-type-badge {{ rel_color }}" title="{{ node.relation_type }}">
|
<span class="relation-type-badge {{ rel_color }}" title="{{ node.relation_type }}: {{ rel_help }}">
|
||||||
<i class="bi {{ rel_icon }}"></i>
|
<i class="bi {{ rel_icon }}"></i>
|
||||||
<span class="d-none d-md-inline ms-1" style="font-size: 0.7rem;">{{ node.relation_type }}</span>
|
<span class="d-none d-md-inline ms-1" style="font-size: 0.7rem;">{{ node.relation_type }}</span>
|
||||||
</span>
|
</span>
|
||||||
@ -1244,17 +1297,25 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-bold">2. Vælg relationstype</label>
|
<label class="form-label fw-bold">2. Vælg relationstype</label>
|
||||||
<select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton()">
|
<select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();">
|
||||||
<option value="">Vælg hvordan sagerne er relateret...</option>
|
<option value="">Vælg hvordan sagerne er relateret...</option>
|
||||||
<option value="relateret">🔗 Relateret - Generel relation</option>
|
<option value="Relateret til">🔗 Relateret til - Faglig kobling uden direkte afhængighed</option>
|
||||||
<option value="afhænger af">⏳ Afhænger af - Denne sag venter på den anden</option>
|
<option value="Afledt af">↪ Afledt af - Denne sag er opstået på baggrund af den anden</option>
|
||||||
<option value="blokkerer">🚫 Blokkerer - Denne sag blokerer den anden</option>
|
<option value="Årsag til">➡ Årsag til - Denne sag er årsagen til den anden</option>
|
||||||
<option value="duplikat">📋 Duplikat - Sagerne er den samme</option>
|
<option value="Blokkerer">⛔ Blokkerer - Denne sag stopper fremdrift i den anden</option>
|
||||||
<option value="forårsaget af">🔄 Forårsaget af - Denne sag er konsekvens af den anden</option>
|
|
||||||
<option value="følger op på">📌 Følger op på - Fortsættelse af tidligere sag</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="relationTypeHint" class="alert alert-info small mb-3" style="display:none;"></div>
|
||||||
|
|
||||||
|
<div class="alert alert-light border small mb-3">
|
||||||
|
<div class="fw-semibold mb-1">Betydning i praksis</div>
|
||||||
|
<div><strong>Relateret til</strong>: Bruges når sager hænger sammen, men ingen af dem afhænger direkte af den anden.</div>
|
||||||
|
<div><strong>Afledt af</strong>: Bruges når denne sag er afledt af et tidligere problem/arbejde.</div>
|
||||||
|
<div><strong>Årsag til</strong>: Bruges når denne sag skaber behovet for den anden.</div>
|
||||||
|
<div><strong>Blokkerer</strong>: Bruges når løsning i én sag er nødvendig før den anden kan videre.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;">
|
<div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
<div>
|
<div>
|
||||||
@ -1350,6 +1411,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Deadline Modal -->
|
||||||
|
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Deadline</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="form-label">Dato</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
id="deadlineInput"
|
||||||
|
value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}"
|
||||||
|
/>
|
||||||
|
<div class="defer-controls mt-2">
|
||||||
|
<button class="btn btn-outline-primary" onclick="shiftDeadlineDays(1)">+1 dag</button>
|
||||||
|
<button class="btn btn-outline-primary" onclick="shiftDeadlineDays(7)">+1 uge</button>
|
||||||
|
<button class="btn btn-outline-primary" onclick="shiftDeadlineMonths(1)">+1 mnd</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="clearDeadlineAll()">Ryd</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveDeadlineAll()">Gem</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Deferred Modal -->
|
<!-- Deferred Modal -->
|
||||||
<div class="modal fade" id="deferredModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="deferredModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
@ -1465,6 +1557,8 @@
|
|||||||
setupContactSearch();
|
setupContactSearch();
|
||||||
setupCustomerSearch();
|
setupCustomerSearch();
|
||||||
setupRelationSearch();
|
setupRelationSearch();
|
||||||
|
updateRelationTypeHint();
|
||||||
|
updateNewCaseRelationTypeHint();
|
||||||
|
|
||||||
// Render Global Tags
|
// Render Global Tags
|
||||||
if (window.renderEntityTags) {
|
if (window.renderEntityTags) {
|
||||||
@ -1521,6 +1615,7 @@
|
|||||||
|
|
||||||
function showRelationModal() {
|
function showRelationModal() {
|
||||||
relationModal.show();
|
relationModal.show();
|
||||||
|
updateRelationTypeHint();
|
||||||
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
|
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1585,6 +1680,67 @@
|
|||||||
|
|
||||||
function showCreateRelatedModal() {
|
function showCreateRelatedModal() {
|
||||||
createRelatedCaseModalInstance.show();
|
createRelatedCaseModalInstance.show();
|
||||||
|
updateNewCaseRelationTypeHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationTypeMeaning(type) {
|
||||||
|
const map = {
|
||||||
|
'Relateret til': {
|
||||||
|
icon: '🔗',
|
||||||
|
text: 'Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.'
|
||||||
|
},
|
||||||
|
'Afledt af': {
|
||||||
|
icon: '↪',
|
||||||
|
text: 'Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).'
|
||||||
|
},
|
||||||
|
'Årsag til': {
|
||||||
|
icon: '➡',
|
||||||
|
text: 'Denne sag er årsag til den anden sag (du peger frem mod en konsekvens/opfølgning).'
|
||||||
|
},
|
||||||
|
'Blokkerer': {
|
||||||
|
icon: '⛔',
|
||||||
|
text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return map[type] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRelationTypeHint() {
|
||||||
|
const select = document.getElementById('relationTypeSelect');
|
||||||
|
const hint = document.getElementById('relationTypeHint');
|
||||||
|
if (!select || !hint) return;
|
||||||
|
|
||||||
|
const meaning = relationTypeMeaning(select.value);
|
||||||
|
if (!meaning) {
|
||||||
|
hint.style.display = 'none';
|
||||||
|
hint.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hint.style.display = 'block';
|
||||||
|
hint.innerHTML = `<strong>${meaning.icon} Betydning:</strong> ${meaning.text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNewCaseRelationTypeHint() {
|
||||||
|
const select = document.getElementById('newCaseRelationType');
|
||||||
|
const hint = document.getElementById('newCaseRelationTypeHint');
|
||||||
|
if (!select || !hint) return;
|
||||||
|
|
||||||
|
const selected = select.value;
|
||||||
|
if (selected === 'Afledt af') {
|
||||||
|
hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som afledt af den nye sag.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected === 'Årsag til') {
|
||||||
|
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som årsag til den nye sag.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected === 'Blokkerer') {
|
||||||
|
hint.innerHTML = '<strong>⛔ Effekt:</strong> Nuværende sag markeres som blokering for den nye sag.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hint.innerHTML = '<strong>🔗 Effekt:</strong> Sagerne kobles fagligt uden direkte afhængighed.';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRelatedCase() {
|
async function createRelatedCase() {
|
||||||
@ -2118,7 +2274,7 @@
|
|||||||
${l.name}
|
${l.name}
|
||||||
</div>
|
</div>
|
||||||
<small>${l.location_type || '-'}</small>
|
<small>${l.location_type || '-'}</small>
|
||||||
<button class="btn btn-sm btn-delete" onclick="unlinkLocation(${l.id})" title="Slet">
|
<button class="btn btn-sm btn-delete" onclick="unlinkLocation(${l.relation_id || l.id})" title="Slet">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -2459,10 +2615,14 @@
|
|||||||
async function unlinkLocation(locId) {
|
async function unlinkLocation(locId) {
|
||||||
if(!confirm("Fjern link til denne lokation?")) return;
|
if(!confirm("Fjern link til denne lokation?")) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
|
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Kunne ikke fjerne lokation');
|
||||||
|
}
|
||||||
loadCaseLocations();
|
loadCaseLocations();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Fejl ved sletning");
|
alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2506,6 +2666,121 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Tid & Fakturering Section (Moved from Right Column) -->
|
||||||
|
<div class="card mt-3" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h5>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
|
||||||
|
<i class="bi bi-fullscreen me-1"></i>Fuld Formular
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Quick Add Time Entry Form -->
|
||||||
|
<div class="border rounded p-2 mb-2 bg-light" id="quickTimeFormContainer">
|
||||||
|
<form id="quickAddTimeForm" onsubmit="quickAddTime(event); return false;">
|
||||||
|
<div class="row g-1 align-items-end">
|
||||||
|
<div class="col-md-2 col-6">
|
||||||
|
<label for="quickTimeDate" class="form-label small mb-1">Dato</label>
|
||||||
|
<input type="date" class="form-control form-control-sm" id="quickTimeDate" name="date"
|
||||||
|
value="{{ today or '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 col-3">
|
||||||
|
<label for="quickTimeHours" class="form-label small mb-1">Timer</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="quickTimeHours" name="hours"
|
||||||
|
min="0" max="23" value="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 col-3">
|
||||||
|
<label for="quickTimeMinutes" class="form-label small mb-1">Min</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes"
|
||||||
|
min="0" max="59" step="15" value="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<label for="quickTimeBillingMethod" class="form-label small mb-1">Afregning</label>
|
||||||
|
<select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method">
|
||||||
|
<option value="invoice" selected>Faktura</option>
|
||||||
|
{% if prepaid_cards %}
|
||||||
|
<optgroup label="Klippekort">
|
||||||
|
{% for card in prepaid_cards %}
|
||||||
|
<option value="card_{{ card.id }}">💳 Kort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t)</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
{% if fixed_price_agreements %}
|
||||||
|
<optgroup label="Fastpris">
|
||||||
|
{% for agr in fixed_price_agreements %}
|
||||||
|
<option value="fpa_{{ agr.id }}">📋 #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t)</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endif %}
|
||||||
|
<option value="internal">Internt</option>
|
||||||
|
<option value="warranty">Garanti</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-12">
|
||||||
|
<label for="quickTimeDescription" class="form-label small mb-1">Beskrivelse</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description"
|
||||||
|
placeholder="Hvad har du lavet?" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 col-12 d-flex align-items-end">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||||
|
<i class="bi bi-plus-lg me-0"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Entries Table -->
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Dato</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th>Bruger</th>
|
||||||
|
<th class="text-end">Timer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in time_entries %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.worked_date }}</td>
|
||||||
|
<td>{{ entry.description or '-' }}</td>
|
||||||
|
<td>{{ entry.user_name }}</td>
|
||||||
|
<td class="text-end fw-bold">{{ entry.original_hours }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center py-3 text-muted">
|
||||||
|
<i class="bi bi-inbox me-2"></i>Ingen tid registreret endnu
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prepaid Cards Info -->
|
||||||
|
{% if prepaid_cards %}
|
||||||
|
<div class="border-top mt-3 pt-3">
|
||||||
|
<h6 class="mb-2"><i class="bi bi-credit-card me-1"></i>Aktive Klippekort</h6>
|
||||||
|
<div class="row g-2">
|
||||||
|
{% for card in prepaid_cards %}
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="border rounded p-2 bg-light">
|
||||||
|
<div class="small text-muted">Kort #{{ card.card_number or card.id }}</div>
|
||||||
|
<div class="fw-bold text-primary">{{ '%.2f' % card.remaining_hours }} timer tilbage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4" id="case-right-column">
|
<div class="col-lg-4" id="case-right-column">
|
||||||
<div class="right-modules-grid">
|
<div class="right-modules-grid">
|
||||||
@ -2655,57 +2930,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card h-100 d-flex flex-column right-module-card" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h6>
|
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
|
|
||||||
<i class="bi bi-plus-lg me-1"></i>Registrer Tid
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0" style="max-height: 180px; overflow: auto;">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
|
||||||
<thead class="bg-light">
|
|
||||||
<tr>
|
|
||||||
<th class="ps-3">Dato</th>
|
|
||||||
<th>Beskrivelse</th>
|
|
||||||
<th>Bruger</th>
|
|
||||||
<th>Timer</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for entry in time_entries %}
|
|
||||||
<tr>
|
|
||||||
<td class="ps-3">{{ entry.worked_date }}</td>
|
|
||||||
<td>{{ entry.description or '-' }}</td>
|
|
||||||
<td>{{ entry.user_name }}</td>
|
|
||||||
<td class="fw-bold">{{ entry.original_hours }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="text-center py-3 text-muted">Ingen tid registreret</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="border-top px-3 py-2 small text-muted">
|
|
||||||
<div class="fw-semibold text-primary mb-1"><i class="bi bi-credit-card me-1"></i>Klippekort</div>
|
|
||||||
{% if prepaid_cards %}
|
|
||||||
<div class="d-flex flex-column gap-1">
|
|
||||||
{% for card in prepaid_cards %}
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<span>#{{ card.card_number or card.id }}</span>
|
|
||||||
<span>{{ '%.2f' % card.remaining_hours }}t</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div>Ingen aktive klippekort</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2967,29 +3191,37 @@
|
|||||||
|
|
||||||
<div id="subscriptionDetails" class="d-none">
|
<div id="subscriptionDetails" class="d-none">
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<label class="small text-muted">Abonnement</label>
|
<label class="small text-muted">Abonnement</label>
|
||||||
<div class="fw-semibold" id="subscriptionNumber">-</div>
|
<div class="fw-semibold" id="subscriptionNumber">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<label class="small text-muted">Produkt</label>
|
<label class="small text-muted">Produkt</label>
|
||||||
<div class="fw-semibold" id="subscriptionProduct">-</div>
|
<div class="fw-semibold" id="subscriptionProduct">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
|
<label class="small text-muted">Status</label>
|
||||||
|
<div class="fw-semibold" id="subscriptionStatusText">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
<label class="small text-muted">Interval</label>
|
<label class="small text-muted">Interval</label>
|
||||||
<div class="fw-semibold" id="subscriptionInterval">-</div>
|
<div class="fw-semibold" id="subscriptionInterval">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<label class="small text-muted">Pris</label>
|
<label class="small text-muted">Pris</label>
|
||||||
<div class="fw-semibold" id="subscriptionPrice">-</div>
|
<div class="fw-semibold" id="subscriptionPrice">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<label class="small text-muted">Startdato</label>
|
<label class="small text-muted">Startdato</label>
|
||||||
<div class="fw-semibold" id="subscriptionStartDate">-</div>
|
<div class="fw-semibold" id="subscriptionStartDate">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="small text-muted">Status</label>
|
<label class="small text-muted">Periode start <i class="bi bi-info-circle" title="Nuværende faktureringsperiode"></i></label>
|
||||||
<div class="fw-semibold" id="subscriptionStatusText">-</div>
|
<div class="fw-semibold" id="subscriptionPeriodStart">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Næste faktura <i class="bi bi-info-circle" title="Dato for næste automatiske faktura"></i></label>
|
||||||
|
<div class="fw-semibold" id="subscriptionNextInvoice">-</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive mb-3">
|
<div class="table-responsive mb-3">
|
||||||
@ -3020,6 +3252,8 @@
|
|||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Interval *</label>
|
<label class="form-label">Interval *</label>
|
||||||
<select class="form-select" id="subscriptionIntervalInput" required>
|
<select class="form-select" id="subscriptionIntervalInput" required>
|
||||||
|
<option value="daily">Daglig</option>
|
||||||
|
<option value="biweekly">Hver 14. dag</option>
|
||||||
<option value="monthly" selected>Maaned</option>
|
<option value="monthly" selected>Maaned</option>
|
||||||
<option value="quarterly">Kvartal</option>
|
<option value="quarterly">Kvartal</option>
|
||||||
<option value="yearly">Aar</option>
|
<option value="yearly">Aar</option>
|
||||||
@ -3125,6 +3359,8 @@
|
|||||||
<label class="form-label">Faktureringsinterval</label>
|
<label class="form-label">Faktureringsinterval</label>
|
||||||
<select class="form-select" id="subscriptionProductBillingPeriod">
|
<select class="form-select" id="subscriptionProductBillingPeriod">
|
||||||
<option value="">-</option>
|
<option value="">-</option>
|
||||||
|
<option value="daily">Daglig</option>
|
||||||
|
<option value="biweekly">Hver 14. dag</option>
|
||||||
<option value="monthly">Maaned</option>
|
<option value="monthly">Maaned</option>
|
||||||
<option value="quarterly">Kvartal</option>
|
<option value="quarterly">Kvartal</option>
|
||||||
<option value="yearly">Aar</option>
|
<option value="yearly">Aar</option>
|
||||||
@ -4660,13 +4896,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Relationstype *</label>
|
<label class="form-label">Relationstype *</label>
|
||||||
<select class="form-select" id="newCaseRelationType">
|
<select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
|
||||||
<option value="Relateret til">Relateret til (Generel kobling)</option>
|
<option value="Relateret til">Relateret til (Ingen direkte afhængighed)</option>
|
||||||
<option value="Afledt af">Afledt af (Denne sag afventer den nye)</option>
|
<option value="Afledt af">Afledt af (Nuværende sag er afledt af den nye)</option>
|
||||||
<option value="Årsag til">Årsag til (Den nye sag afventer denne)</option>
|
<option value="Årsag til">Årsag til (Nuværende sag er årsag til den nye)</option>
|
||||||
<option value="Blokkerer">Blokkerer (Denne sag blokkerer den nye)</option>
|
<option value="Blokkerer">Blokkerer (Nuværende sag blokerer den nye)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="newCaseRelationTypeHint" class="alert alert-info small mb-3"></div>
|
||||||
|
<div class="alert alert-light border small">
|
||||||
|
<div class="fw-semibold mb-1">Sådan vælger du korrekt relation</div>
|
||||||
|
<div><strong>Relateret til</strong>: Samme emne/område, men ingen direkte afhængighed.</div>
|
||||||
|
<div><strong>Afledt af</strong>: Den nye sag opstår fordi den nuværende sag findes.</div>
|
||||||
|
<div><strong>Årsag til</strong>: Den nuværende sag opstår fordi den nye sag findes.</div>
|
||||||
|
<div><strong>Blokkerer</strong>: Løsning i én sag er nødvendig før den anden kan afsluttes.</div>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Beskrivelse</label>
|
<label class="form-label">Beskrivelse</label>
|
||||||
<textarea class="form-control" id="newCaseDescription" rows="3"></textarea>
|
<textarea class="form-control" id="newCaseDescription" rows="3"></textarea>
|
||||||
@ -4889,6 +5133,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateDeadline(value) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ deadline: value || null })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || 'Kunne ikke opdatere deadline');
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fejl: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDeadlineDays(days) {
|
||||||
|
const input = document.getElementById('deadlineInput');
|
||||||
|
const base = input.value ? new Date(input.value) : new Date();
|
||||||
|
base.setDate(base.getDate() + days);
|
||||||
|
input.value = base.toISOString().slice(0, 10);
|
||||||
|
updateDeadline(input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDeadlineMonths(months) {
|
||||||
|
const input = document.getElementById('deadlineInput');
|
||||||
|
const base = input.value ? new Date(input.value) : new Date();
|
||||||
|
base.setMonth(base.getMonth() + months);
|
||||||
|
input.value = base.toISOString().slice(0, 10);
|
||||||
|
updateDeadline(input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeadlineModal() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('deadlineModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDeadlineAll() {
|
||||||
|
const input = document.getElementById('deadlineInput');
|
||||||
|
updateDeadline(input.value || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDeadlineAll() {
|
||||||
|
const input = document.getElementById('deadlineInput');
|
||||||
|
input.value = '';
|
||||||
|
updateDeadline(null);
|
||||||
|
}
|
||||||
|
|
||||||
function setDeferredFromInput() {
|
function setDeferredFromInput() {
|
||||||
const input = document.getElementById('deferredUntilInput');
|
const input = document.getElementById('deferredUntilInput');
|
||||||
updateDeferredUntil(input.value || null);
|
updateDeferredUntil(input.value || null);
|
||||||
@ -4986,6 +5279,80 @@
|
|||||||
view.classList.remove('d-none');
|
view.classList.remove('d-none');
|
||||||
edit.classList.add('d-none');
|
edit.classList.add('d-none');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldEdit) {
|
||||||
|
ensurePipelineStagesLoaded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePipelineStagesLoaded() {
|
||||||
|
const select = document.getElementById('pipelineStageSelect');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
if (select.options.length > 1) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const stages = await response.json();
|
||||||
|
if (!Array.isArray(stages) || stages.length === 0) return;
|
||||||
|
|
||||||
|
const existingValue = select.value || '';
|
||||||
|
select.innerHTML = '<option value="">Ikke sat</option>' +
|
||||||
|
stages.map((stage) => `<option value="${stage.id}">${stage.name}</option>`).join('');
|
||||||
|
if (existingValue) {
|
||||||
|
select.value = existingValue;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not load pipeline stages', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAssignment() {
|
||||||
|
const statusEl = document.getElementById('assignmentStatus');
|
||||||
|
const userValue = document.getElementById('assignmentUserSelect')?.value || '';
|
||||||
|
const groupValue = document.getElementById('assignmentGroupSelect')?.value || '';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ansvarlig_bruger_id: userValue ? parseInt(userValue, 10) : null,
|
||||||
|
assigned_group_id: groupValue ? parseInt(groupValue, 10) : null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = 'Gemmer...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Kunne ikke gemme tildeling';
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
message = data.detail || message;
|
||||||
|
} catch (err) {
|
||||||
|
// Keep default message
|
||||||
|
}
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = `❌ ${message}`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '✅ Tildeling gemt';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = `❌ ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePipeline() {
|
async function savePipeline() {
|
||||||
@ -5010,8 +5377,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = await response.json();
|
let message = 'Kunne ikke opdatere pipeline';
|
||||||
throw new Error(err.detail || 'Kunne ikke opdatere pipeline');
|
try {
|
||||||
|
const err = await response.json();
|
||||||
|
message = err.detail || err.message || message;
|
||||||
|
} catch (_e) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text) message = text;
|
||||||
|
}
|
||||||
|
throw new Error(`${message} (HTTP ${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@ -5065,10 +5439,18 @@
|
|||||||
document.querySelectorAll('[data-module]').forEach((el) => {
|
document.querySelectorAll('[data-module]').forEach((el) => {
|
||||||
const moduleName = el.getAttribute('data-module');
|
const moduleName = el.getAttribute('data-module');
|
||||||
const hasContent = moduleHasContent(el);
|
const hasContent = moduleHasContent(el);
|
||||||
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline';
|
const isTimeModule = moduleName === 'time';
|
||||||
|
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
|
||||||
const pref = modulePrefs[moduleName];
|
const pref = modulePrefs[moduleName];
|
||||||
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
|
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
|
||||||
|
|
||||||
|
if (isTimeModule) {
|
||||||
|
el.classList.remove('d-none');
|
||||||
|
el.classList.remove('module-empty-compact');
|
||||||
|
if (tabButton) tabButton.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasContent) {
|
if (hasContent) {
|
||||||
el.classList.remove('d-none');
|
el.classList.remove('d-none');
|
||||||
el.classList.remove('module-empty-compact');
|
el.classList.remove('module-empty-compact');
|
||||||
@ -5150,6 +5532,7 @@
|
|||||||
acc[p.module_key] = p.is_enabled;
|
acc[p.module_key] = p.is_enabled;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
modulePrefs.time = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Module prefs load failed', e);
|
console.error('Module prefs load failed', e);
|
||||||
}
|
}
|
||||||
@ -5185,12 +5568,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
list.innerHTML = modules.map(m => {
|
list.innerHTML = modules.map(m => {
|
||||||
const checked = modulePrefs[m.key] !== false;
|
const isTimeModule = m.key === 'time';
|
||||||
|
const checked = isTimeModule ? true : modulePrefs[m.key] !== false;
|
||||||
return `
|
return `
|
||||||
<div class="form-check mb-2">
|
<div class="form-check mb-2">
|
||||||
<input class="form-check-input" type="checkbox" id="module_${m.key}" ${checked ? 'checked' : ''}
|
<input class="form-check-input" type="checkbox" id="module_${m.key}" ${checked ? 'checked' : ''}
|
||||||
|
${isTimeModule ? 'disabled' : ''}
|
||||||
onchange="toggleModulePref('${m.key}', this.checked)">
|
onchange="toggleModulePref('${m.key}', this.checked)">
|
||||||
<label class="form-check-label" for="module_${m.key}">${m.label}</label>
|
<label class="form-check-label" for="module_${m.key}">${m.label}${isTimeModule ? ' (altid synlig)' : ''}</label>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@ -5200,6 +5585,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleModulePref(moduleKey, isEnabled) {
|
async function toggleModulePref(moduleKey, isEnabled) {
|
||||||
|
if (moduleKey === 'time') {
|
||||||
|
modulePrefs.time = true;
|
||||||
|
applyViewFromTags();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/sag/${caseIds}/modules`, {
|
const res = await fetch(`/api/v1/sag/${caseIds}/modules`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -5579,6 +5969,8 @@
|
|||||||
|
|
||||||
function formatSubscriptionInterval(interval) {
|
function formatSubscriptionInterval(interval) {
|
||||||
const map = {
|
const map = {
|
||||||
|
'daily': 'Daglig',
|
||||||
|
'biweekly': '14-dage',
|
||||||
'monthly': 'Maaned',
|
'monthly': 'Maaned',
|
||||||
'quarterly': 'Kvartal',
|
'quarterly': 'Kvartal',
|
||||||
'yearly': 'Aar'
|
'yearly': 'Aar'
|
||||||
@ -5856,6 +6248,24 @@
|
|||||||
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
|
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
|
||||||
document.getElementById('subscriptionStatusText').textContent = subscription.status || '-';
|
document.getElementById('subscriptionStatusText').textContent = subscription.status || '-';
|
||||||
|
|
||||||
|
// New fields
|
||||||
|
const periodStartEl = document.getElementById('subscriptionPeriodStart');
|
||||||
|
const nextInvoiceEl = document.getElementById('subscriptionNextInvoice');
|
||||||
|
if (periodStartEl) {
|
||||||
|
periodStartEl.textContent = subscription.period_start ? formatSubscriptionDate(subscription.period_start) : '-';
|
||||||
|
}
|
||||||
|
if (nextInvoiceEl) {
|
||||||
|
const nextDate = subscription.next_invoice_date ? formatSubscriptionDate(subscription.next_invoice_date) : '-';
|
||||||
|
nextInvoiceEl.textContent = nextDate;
|
||||||
|
// Highlight if invoice is due soon
|
||||||
|
if (subscription.next_invoice_date) {
|
||||||
|
const daysUntil = Math.ceil((new Date(subscription.next_invoice_date) - new Date()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (daysUntil <= 7 && daysUntil >= 0) {
|
||||||
|
nextInvoiceEl.innerHTML = `${nextDate} <span class="badge bg-warning text-dark">Om ${daysUntil} dage</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSubscriptionBadge(subscription.status);
|
setSubscriptionBadge(subscription.status);
|
||||||
|
|
||||||
const itemsBody = document.getElementById('subscriptionItemsBody');
|
const itemsBody = document.getElementById('subscriptionItemsBody');
|
||||||
@ -5898,7 +6308,7 @@
|
|||||||
|
|
||||||
async function loadSubscriptionForCase() {
|
async function loadSubscriptionForCase() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/subscriptions/by-sag/${subscriptionCaseId}`);
|
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`);
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
showSubscriptionCreateForm();
|
showSubscriptionCreateForm();
|
||||||
setModuleContentState('subscription', false);
|
setModuleContentState('subscription', false);
|
||||||
@ -5935,7 +6345,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/subscriptions', {
|
const res = await fetch('/api/v1/sag-subscriptions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -5967,7 +6377,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/subscriptions/${currentSubscription.id}/status`, {
|
const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status })
|
||||||
@ -5989,6 +6399,99 @@
|
|||||||
loadSubscriptionProducts();
|
loadSubscriptionProducts();
|
||||||
loadSubscriptionForCase();
|
loadSubscriptionForCase();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === Quick Time Entry Functions (for inline time tracking) ===
|
||||||
|
function toggleQuickTimeForm() {
|
||||||
|
const container = document.getElementById('quickTimeFormContainer');
|
||||||
|
if (container) {
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make function globally available for onclick handler
|
||||||
|
window.toggleQuickTimeForm = toggleQuickTimeForm;
|
||||||
|
|
||||||
|
async function quickAddTime(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const form = document.getElementById('quickAddTimeForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
// Parse hours and minutes
|
||||||
|
const hours = parseInt(formData.get('hours')) || 0;
|
||||||
|
const minutes = parseInt(formData.get('minutes')) || 0;
|
||||||
|
const totalHours = hours + (minutes / 60);
|
||||||
|
|
||||||
|
if (totalHours === 0) {
|
||||||
|
alert('Angiv venligst timer eller minutter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingSelect = document.getElementById('quickTimeBillingMethod');
|
||||||
|
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
|
||||||
|
let prepaidCardId = null;
|
||||||
|
let fixedPriceAgreementId = null;
|
||||||
|
|
||||||
|
if (billingMethod.startsWith('card_')) {
|
||||||
|
prepaidCardId = parseInt(billingMethod.split('_')[1]);
|
||||||
|
billingMethod = 'prepaid';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (billingMethod.startsWith('fpa_')) {
|
||||||
|
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
|
||||||
|
billingMethod = 'fixed_price';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInternal = billingMethod === 'internal';
|
||||||
|
|
||||||
|
// Build payload
|
||||||
|
const payload = {
|
||||||
|
sag_id: {{ case.id }},
|
||||||
|
worked_date: formData.get('date'),
|
||||||
|
original_hours: totalHours,
|
||||||
|
description: formData.get('description'),
|
||||||
|
billing_method: billingMethod,
|
||||||
|
is_internal: isInternal
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prepaidCardId) {
|
||||||
|
payload.prepaid_card_id = prepaidCardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixedPriceAgreementId) {
|
||||||
|
payload.fixed_price_agreement_id = fixedPriceAgreementId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/timetracking/entries/internal', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Kunne ikke gemme tidsregistrering');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - reload page to show new entry
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fejl: ' + error.message);
|
||||||
|
console.error('Quick add time error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set today's date as default for quick time form
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const dateInput = document.getElementById('quickTimeDate');
|
||||||
|
if (dateInput && !dateInput.value) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
dateInput.value = today;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -212,13 +212,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ansvarlig_bruger_id">Ansvarlig Bruger (valgfrit)</label>
|
<label for="ansvarlig_bruger_id">Ansvarlig medarbejder</label>
|
||||||
<input type="number" class="form-control" id="ansvarlig_bruger_id" placeholder="Brugers ID" value="{{ case.ansvarlig_bruger_id or '' }}">
|
<select class="form-select" id="ansvarlig_bruger_id">
|
||||||
|
<option value="">Ingen</option>
|
||||||
|
{% for user in assignment_users or [] %}
|
||||||
|
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="assigned_group_id">Ansvarlig gruppe</label>
|
||||||
|
<select class="form-select" id="assigned_group_id">
|
||||||
|
<option value="">Ingen</option>
|
||||||
|
{% for group in assignment_groups or [] %}
|
||||||
|
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="deadline">Deadline (valgfrit)</label>
|
<label for="deadline">Deadline (valgfrit)</label>
|
||||||
<input type="datetime-local" class="form-control" id="deadline" value="{{ (case.deadline | string | truncate(19, True, '')) if case.deadline else '' }}">
|
<input type="datetime-local" class="form-control" id="deadline" value="{{ case.deadline.strftime('%Y-%m-%dT%H:%M') if case.deadline else '' }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
@ -278,6 +293,7 @@
|
|||||||
type: type,
|
type: type,
|
||||||
status: status,
|
status: status,
|
||||||
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
||||||
|
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
|
||||||
deadline: document.getElementById('deadline').value || null
|
deadline: document.getElementById('deadline').value || null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -298,6 +298,27 @@
|
|||||||
<option value="all">Alle typer</option>
|
<option value="all">Alle typer</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<form id="assignmentFilterForm" class="d-flex flex-wrap gap-2 align-items-center" method="get" action="/sag">
|
||||||
|
<div style="min-width: 220px;">
|
||||||
|
<select class="form-select" name="ansvarlig_bruger_id" id="assigneeFilter">
|
||||||
|
<option value="">Alle medarbejdere</option>
|
||||||
|
{% for user in assignment_users or [] %}
|
||||||
|
<option value="{{ user.user_id }}" {% if current_ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="min-width: 220px;">
|
||||||
|
<select class="form-select" name="assigned_group_id" id="groupFilter">
|
||||||
|
<option value="">Alle grupper</option>
|
||||||
|
{% for group in assignment_groups or [] %}
|
||||||
|
<option value="{{ group.id }}" {% if current_assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% if include_deferred %}
|
||||||
|
<input type="hidden" name="include_deferred" value="1">
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
|
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
|
||||||
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
|
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
|
||||||
</a>
|
</a>
|
||||||
@ -314,6 +335,8 @@
|
|||||||
<th style="width: 120px;">Type</th>
|
<th style="width: 120px;">Type</th>
|
||||||
<th style="width: 180px;">Kunde</th>
|
<th style="width: 180px;">Kunde</th>
|
||||||
<th style="width: 150px;">Hovedkontakt</th>
|
<th style="width: 150px;">Hovedkontakt</th>
|
||||||
|
<th style="width: 160px;">Ansvarlig</th>
|
||||||
|
<th style="width: 160px;">Gruppe</th>
|
||||||
<th style="width: 100px;">Status</th>
|
<th style="width: 100px;">Status</th>
|
||||||
<th style="width: 120px;">Udsat start</th>
|
<th style="width: 120px;">Udsat start</th>
|
||||||
<th style="width: 120px;">Oprettet</th>
|
<th style="width: 120px;">Oprettet</th>
|
||||||
@ -327,7 +350,7 @@
|
|||||||
<tr class="tree-row {% if has_relations %}has-children{% endif %}"
|
<tr class="tree-row {% if has_relations %}has-children{% endif %}"
|
||||||
data-sag-id="{{ sag.id }}"
|
data-sag-id="{{ sag.id }}"
|
||||||
data-status="{{ sag.status }}"
|
data-status="{{ sag.status }}"
|
||||||
data-type="{{ sag.type or 'ticket' }}">
|
data-type="{{ sag.template_key or sag.type or 'ticket' }}">
|
||||||
<td>
|
<td>
|
||||||
{% if has_relations %}
|
{% if has_relations %}
|
||||||
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
||||||
@ -341,7 +364,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||||
<span class="badge bg-light text-dark border">{{ sag.type or 'ticket' }}</span>
|
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ sag.customer_name if sag.customer_name else '-' }}
|
{{ sag.customer_name if sag.customer_name else '-' }}
|
||||||
@ -349,6 +372,12 @@
|
|||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
|
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
|
||||||
</td>
|
</td>
|
||||||
|
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
|
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
|
||||||
|
</td>
|
||||||
|
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
|
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
||||||
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||||
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
|
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
@ -369,7 +398,7 @@
|
|||||||
{% if related_sag and rel.target_id not in seen_targets %}
|
{% if related_sag and rel.target_id not in seen_targets %}
|
||||||
{% set _ = seen_targets.append(rel.target_id) %}
|
{% set _ = seen_targets.append(rel.target_id) %}
|
||||||
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
|
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
|
||||||
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.type or 'ticket' }}" style="display: none;">
|
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" style="display: none;">
|
||||||
<td>
|
<td>
|
||||||
<span class="sag-id">#{{ related_sag.id }}</span>
|
<span class="sag-id">#{{ related_sag.id }}</span>
|
||||||
</td>
|
</td>
|
||||||
@ -383,7 +412,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||||
<span class="badge bg-light text-dark border">{{ related_sag.type or 'ticket' }}</span>
|
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
||||||
@ -391,6 +420,12 @@
|
|||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
|
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
|
||||||
</td>
|
</td>
|
||||||
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
|
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
|
||||||
|
</td>
|
||||||
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
|
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
||||||
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||||
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
|
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
@ -449,6 +484,17 @@
|
|||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let currentType = 'all';
|
let currentType = 'all';
|
||||||
|
|
||||||
|
const assigneeFilter = document.getElementById('assigneeFilter');
|
||||||
|
const groupFilter = document.getElementById('groupFilter');
|
||||||
|
const assignmentFilterForm = document.getElementById('assignmentFilterForm');
|
||||||
|
|
||||||
|
if (assigneeFilter && assignmentFilterForm) {
|
||||||
|
assigneeFilter.addEventListener('change', () => assignmentFilterForm.submit());
|
||||||
|
}
|
||||||
|
if (groupFilter && assignmentFilterForm) {
|
||||||
|
groupFilter.addEventListener('change', () => assignmentFilterForm.submit());
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const search = currentSearch;
|
const search = currentSearch;
|
||||||
|
|
||||||
@ -512,14 +558,26 @@
|
|||||||
async function loadTypeFilters() {
|
async function loadTypeFilters() {
|
||||||
if (!typeFilter) return;
|
if (!typeFilter) return;
|
||||||
try {
|
try {
|
||||||
|
const rowTypes = new Set(Array.from(document.querySelectorAll('.tree-row[data-type], .tree-child[data-type]'))
|
||||||
|
.map((row) => (row.dataset.type || '').trim())
|
||||||
|
.filter(Boolean));
|
||||||
|
|
||||||
const res = await fetch('/api/v1/settings/case_types');
|
const res = await fetch('/api/v1/settings/case_types');
|
||||||
if (!res.ok) return;
|
let configuredTypes = [];
|
||||||
const setting = await res.json();
|
if (res.ok) {
|
||||||
const types = JSON.parse(setting.value || '[]');
|
const setting = await res.json();
|
||||||
if (!Array.isArray(types) || types.length === 0) return;
|
const types = JSON.parse(setting.value || '[]');
|
||||||
|
if (Array.isArray(types)) {
|
||||||
|
configuredTypes = types;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredTypes.forEach((t) => rowTypes.add(String(t || '').trim()));
|
||||||
|
const mergedTypes = Array.from(rowTypes).filter(Boolean).sort((a, b) => a.localeCompare(b, 'da'));
|
||||||
|
if (mergedTypes.length === 0) return;
|
||||||
|
|
||||||
typeFilter.innerHTML = `<option value="all">Alle typer</option>` +
|
typeFilter.innerHTML = `<option value="all">Alle typer</option>` +
|
||||||
types.map(type => `<option value="${type}">${type}</option>`).join('');
|
mergedTypes.map(type => `<option value="${type}">${type}</option>`).join('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load case types', err);
|
console.error('Failed to load case types', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,29 @@
|
|||||||
border: 1px solid rgba(0,0,0,0.1);
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
background: var(--bg-light);
|
background: var(--bg-light);
|
||||||
}
|
}
|
||||||
|
.table-secondary {
|
||||||
|
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.08) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.table-secondary:hover {
|
||||||
|
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.15) !important;
|
||||||
|
}
|
||||||
|
.table-secondary td {
|
||||||
|
padding: 0.75rem !important;
|
||||||
|
}
|
||||||
|
.case-lines-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.case-lines-container.show {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
.expand-icon {
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
.expand-icon.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -142,9 +165,9 @@
|
|||||||
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 30px;"></th>
|
||||||
<th>Dato</th>
|
<th>Dato</th>
|
||||||
<th>Beskrivelse</th>
|
<th>Beskrivelse</th>
|
||||||
<th>Sag</th>
|
|
||||||
<th>Kunde</th>
|
<th>Kunde</th>
|
||||||
<th>Antal</th>
|
<th>Antal</th>
|
||||||
<th>Enhed</th>
|
<th>Enhed</th>
|
||||||
@ -172,9 +195,9 @@
|
|||||||
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 30px;"></th>
|
||||||
<th>Dato</th>
|
<th>Dato</th>
|
||||||
<th>Beskrivelse</th>
|
<th>Beskrivelse</th>
|
||||||
<th>Sag</th>
|
|
||||||
<th>Kunde</th>
|
<th>Kunde</th>
|
||||||
<th>Antal</th>
|
<th>Antal</th>
|
||||||
<th>Enhed</th>
|
<th>Enhed</th>
|
||||||
@ -216,23 +239,83 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = items.map(item => {
|
// Group items by case (sag_id)
|
||||||
const statusLabel = item.status || 'draft';
|
const grouped = {};
|
||||||
const caseLink = item.sag_id ? `<a href="/sag/${item.sag_id}" class="text-decoration-none">${item.sag_titel || 'Sag ' + item.sag_id}</a>` : '-';
|
items.forEach((item, originalIndex) => {
|
||||||
return `
|
const caseKey = item.sag_id || 'ingen-sag';
|
||||||
<tr>
|
if (!grouped[caseKey]) {
|
||||||
<td>${item.line_date || '-'}</td>
|
grouped[caseKey] = {
|
||||||
<td>${item.description || '-'}</td>
|
sag_id: item.sag_id || null,
|
||||||
<td>${caseLink}</td>
|
sag_titel: item.sag_titel || 'Ingen sag',
|
||||||
<td>${item.customer_name || '-'}</td>
|
items: []
|
||||||
<td>${item.quantity ?? '-'}</td>
|
};
|
||||||
<td>${item.unit || '-'}</td>
|
}
|
||||||
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
|
grouped[caseKey].items.push({ ...item, originalIndex });
|
||||||
<td class="fw-bold">${formatCurrency(item.amount)}</td>
|
});
|
||||||
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
|
|
||||||
|
// Render grouped rows with collapsible structure
|
||||||
|
let html = '';
|
||||||
|
Object.keys(grouped).forEach((caseKey, groupIndex) => {
|
||||||
|
const group = grouped[caseKey];
|
||||||
|
const groupTotal = group.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
|
const tableId = tbodyId.replace('Body', ''); // Extract table identifier
|
||||||
|
const groupId = `${tableId}-case-${groupIndex}`;
|
||||||
|
|
||||||
|
// Case header row (clickable to expand/collapse)
|
||||||
|
const caseLink = group.sag_id
|
||||||
|
? `<a href="/sag/${group.sag_id}" class="text-decoration-none fw-bold" onclick="event.stopPropagation();">${group.sag_titel} <span class="badge bg-light text-dark border">Sag ${group.sag_id}</span></a>`
|
||||||
|
: `<span class="fw-bold">${group.sag_titel}</span>`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr class="table-secondary" onclick="toggleCaseLines('${groupId}')">
|
||||||
|
<td>
|
||||||
|
<i class="bi bi-chevron-right expand-icon" id="icon-${groupId}"></i>
|
||||||
|
</td>
|
||||||
|
<td colspan="8">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>${caseLink}</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="badge bg-primary me-2">${group.items.length} ${group.items.length === 1 ? 'linje' : 'linjer'}</span>
|
||||||
|
<span class="fw-bold">${formatCurrency(groupTotal)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
|
||||||
|
// Render lines in this case (hidden by default)
|
||||||
|
group.items.forEach(item => {
|
||||||
|
const statusLabel = item.status || 'draft';
|
||||||
|
html += `
|
||||||
|
<tr class="case-lines-container" data-case="${groupId}">
|
||||||
|
<td></td>
|
||||||
|
<td>${item.line_date || '-'}</td>
|
||||||
|
<td>${item.description || '-'}</td>
|
||||||
|
<td>${item.customer_name || '-'}</td>
|
||||||
|
<td>${item.quantity ?? '-'}</td>
|
||||||
|
<td>${item.unit || '-'}</td>
|
||||||
|
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
|
||||||
|
<td class="fw-bold">${formatCurrency(item.amount)}</td>
|
||||||
|
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCaseLines(caseId) {
|
||||||
|
const lines = document.querySelectorAll(`tr[data-case="${caseId}"]`);
|
||||||
|
const icon = document.getElementById(`icon-${caseId}`);
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
line.classList.toggle('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.toggle('expanded');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
|
|||||||
@ -217,6 +217,11 @@ async def yealink_established(
|
|||||||
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
|
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
|
||||||
kontakt_id = kontakt.get("id") if kontakt else None
|
kontakt_id = kontakt.get("id") if kontakt else None
|
||||||
|
|
||||||
|
# Get extended contact details if we found a contact
|
||||||
|
contact_details = {}
|
||||||
|
if kontakt_id:
|
||||||
|
contact_details = TelefoniService.get_contact_details(kontakt_id)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"callid": resolved_callid,
|
"callid": resolved_callid,
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
@ -252,6 +257,8 @@ async def yealink_established(
|
|||||||
"number": ekstern_e164 or (ekstern_raw or ""),
|
"number": ekstern_e164 or (ekstern_raw or ""),
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"contact": kontakt,
|
"contact": kontakt,
|
||||||
|
"recent_cases": contact_details.get("recent_cases", []),
|
||||||
|
"last_call": contact_details.get("last_call"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -395,6 +402,15 @@ async def telefoni_test_popup(
|
|||||||
"name": "Test popup",
|
"name": "Test popup",
|
||||||
"company": "BMC Hub",
|
"company": "BMC Hub",
|
||||||
},
|
},
|
||||||
|
"recent_cases": [
|
||||||
|
{"id": 1, "titel": "Test sag 1", "created_at": datetime.utcnow()},
|
||||||
|
{"id": 2, "titel": "Test sag 2", "created_at": datetime.utcnow()},
|
||||||
|
],
|
||||||
|
"last_call": {
|
||||||
|
"started_at": datetime.utcnow(),
|
||||||
|
"bruger_navn": "Test Medarbejder",
|
||||||
|
"duration_sec": 125,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -109,3 +109,67 @@ class TelefoniService:
|
|||||||
(duration_sec, callid),
|
(duration_sec, callid),
|
||||||
)
|
)
|
||||||
return bool(rows)
|
return bool(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_contact_details(contact_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get extended contact details including:
|
||||||
|
- Latest 3 open cases
|
||||||
|
- Last call date
|
||||||
|
"""
|
||||||
|
if not contact_id:
|
||||||
|
return {"recent_cases": [], "last_call": None}
|
||||||
|
|
||||||
|
# Get the 3 newest open cases for this contact
|
||||||
|
cases_query = """
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.created_at
|
||||||
|
FROM sag_sager s
|
||||||
|
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
||||||
|
WHERE sk.contact_id = %s
|
||||||
|
AND s.status = 'åben'
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND sk.deleted_at IS NULL
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
"""
|
||||||
|
cases = execute_query(cases_query, (contact_id,)) or []
|
||||||
|
|
||||||
|
# Get the most recent call for this contact
|
||||||
|
last_call_query = """
|
||||||
|
SELECT
|
||||||
|
t.started_at,
|
||||||
|
t.bruger_id,
|
||||||
|
t.duration_sec,
|
||||||
|
u.full_name,
|
||||||
|
u.username
|
||||||
|
FROM telefoni_opkald t
|
||||||
|
LEFT JOIN users u ON t.bruger_id = u.user_id
|
||||||
|
WHERE t.kontakt_id = %s
|
||||||
|
AND t.ended_at IS NOT NULL
|
||||||
|
ORDER BY t.started_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
last_call_row = execute_query_single(last_call_query, (contact_id,))
|
||||||
|
|
||||||
|
last_call_data = None
|
||||||
|
if last_call_row:
|
||||||
|
last_call_data = {
|
||||||
|
"started_at": last_call_row.get("started_at"),
|
||||||
|
"bruger_navn": last_call_row.get("full_name") or last_call_row.get("username"),
|
||||||
|
"duration_sec": last_call_row.get("duration_sec"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"recent_cases": [
|
||||||
|
{
|
||||||
|
"id": case["id"],
|
||||||
|
"titel": case["titel"],
|
||||||
|
"created_at": case["created_at"],
|
||||||
|
}
|
||||||
|
for case in cases
|
||||||
|
],
|
||||||
|
"last_call": last_call_data,
|
||||||
|
}
|
||||||
|
|||||||
@ -39,18 +39,28 @@ async def list_opportunities(
|
|||||||
s.customer_id,
|
s.customer_id,
|
||||||
COALESCE(c.name, 'Ukendt kunde') as customer_name,
|
COALESCE(c.name, 'Ukendt kunde') as customer_name,
|
||||||
s.ansvarlig_bruger_id,
|
s.ansvarlig_bruger_id,
|
||||||
COALESCE(u.first_name || ' ' || COALESCE(u.last_name, ''), 'Ingen') as ansvarlig_navn
|
COALESCE(u.full_name, u.username, 'Ingen') as ansvarlig_navn
|
||||||
FROM sag_sager s
|
FROM sag_sager s
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
LEFT JOIN users u ON s.ansvarlig_bruger_id = u.id
|
LEFT JOIN users u ON s.ansvarlig_bruger_id = u.user_id
|
||||||
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
|
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
|
||||||
WHERE s.deleted_at IS NULL
|
WHERE s.deleted_at IS NULL
|
||||||
AND (
|
AND (
|
||||||
s.template_key = 'pipeline'
|
s.template_key = 'pipeline'
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM sag_tags st
|
SELECT 1
|
||||||
JOIN tags t ON st.tag_id = t.id
|
FROM entity_tags et
|
||||||
WHERE st.sag_id = s.id AND t.name = 'pipeline'
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'case'
|
||||||
|
AND et.entity_id = s.id
|
||||||
|
AND LOWER(t.name) = 'pipeline'
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_tags st
|
||||||
|
WHERE st.sag_id = s.id
|
||||||
|
AND st.deleted_at IS NULL
|
||||||
|
AND LOWER(st.tag_navn) = 'pipeline'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
@ -136,12 +146,26 @@ async def create_opportunity(
|
|||||||
|
|
||||||
@router.get("/pipeline/stages", tags=["Opportunities"])
|
@router.get("/pipeline/stages", tags=["Opportunities"])
|
||||||
async def list_pipeline_stages():
|
async def list_pipeline_stages():
|
||||||
"""
|
"""List available pipeline stages from DB with a safe static fallback."""
|
||||||
Legacy endpoint for stages.
|
try:
|
||||||
Returns static stages mapped to Case statuses for compatibility.
|
stages = execute_query(
|
||||||
"""
|
"""
|
||||||
|
SELECT id, name, color, sort_order
|
||||||
|
FROM pipeline_stages
|
||||||
|
WHERE COALESCE(is_active, TRUE) = TRUE
|
||||||
|
ORDER BY sort_order ASC, id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if stages:
|
||||||
|
return stages
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not load pipeline stages from DB: %s", e)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{"id": "open", "name": "Åben"},
|
{"id": 1, "name": "Lead", "color": "#6c757d", "sort_order": 10},
|
||||||
{"id": "won", "name": "Vundet"},
|
{"id": 2, "name": "Kontakt", "color": "#17a2b8", "sort_order": 20},
|
||||||
{"id": "lost", "name": "Tabt"}
|
{"id": 3, "name": "Tilbud", "color": "#ffc107", "sort_order": 30},
|
||||||
|
{"id": 4, "name": "Forhandling", "color": "#fd7e14", "sort_order": 40},
|
||||||
|
{"id": 5, "name": "Vundet", "color": "#28a745", "sort_order": 50},
|
||||||
|
{"id": 6, "name": "Tabt", "color": "#dc3545", "sort_order": 60},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import json
|
import json
|
||||||
|
import asyncio
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -22,6 +23,57 @@ def _apigw_headers() -> Dict[str, str]:
|
|||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _apigw_base_url() -> str:
|
||||||
|
base_url = (
|
||||||
|
settings.APIGW_BASE_URL
|
||||||
|
or settings.APIGATEWAY_URL
|
||||||
|
or os.getenv("APIGW_BASE_URL")
|
||||||
|
or os.getenv("APIGATEWAY_URL")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not base_url:
|
||||||
|
raise HTTPException(status_code=500, detail="API Gateway base URL is not configured")
|
||||||
|
return base_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_error_detail(payload: Any) -> str:
|
||||||
|
if payload is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if isinstance(payload, str):
|
||||||
|
return payload.strip()
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key in ("detail", "message", "error", "description"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
if isinstance(payload, list):
|
||||||
|
parts = [_extract_error_detail(item) for item in payload]
|
||||||
|
cleaned = [part for part in parts if part]
|
||||||
|
return "; ".join(cleaned)
|
||||||
|
|
||||||
|
return str(payload).strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_apigw_error(response: aiohttp.ClientResponse) -> str:
|
||||||
|
try:
|
||||||
|
body = await response.json(content_type=None)
|
||||||
|
detail = _extract_error_detail(body)
|
||||||
|
if detail:
|
||||||
|
return detail
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
text = (await response.text() or "").strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return f"API Gateway request failed (HTTP {response.status})"
|
||||||
|
|
||||||
|
|
||||||
def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: str = "manual") -> Dict[str, Any]:
|
def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: str = "manual") -> Dict[str, Any]:
|
||||||
supplier_name = payload.get("supplier_name")
|
supplier_name = payload.get("supplier_name")
|
||||||
supplier_code = payload.get("supplier_code")
|
supplier_code = payload.get("supplier_code")
|
||||||
@ -157,7 +209,7 @@ def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens:
|
|||||||
supplier = str(product.get("supplier_name") or "")
|
supplier = str(product.get("supplier_name") or "")
|
||||||
|
|
||||||
haystack = " ".join(
|
haystack = " ".join(
|
||||||
"".join(ch.lower() if ch.isalnum() else " " for ch in value).split()
|
" ".join("".join(ch.lower() if ch.isalnum() else " " for ch in value).split())
|
||||||
for value in (name, sku, manufacturer, category, supplier)
|
for value in (name, sku, manufacturer, category, supplier)
|
||||||
if value
|
if value
|
||||||
)
|
)
|
||||||
@ -220,8 +272,7 @@ async def search_apigw_products(
|
|||||||
if not params:
|
if not params:
|
||||||
raise HTTPException(status_code=400, detail="Provide at least one search parameter")
|
raise HTTPException(status_code=400, detail="Provide at least one search parameter")
|
||||||
|
|
||||||
base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL
|
url = f"{_apigw_base_url()}/api/v1/products/search"
|
||||||
url = f"{base_url.rstrip('/')}/api/v1/products/search"
|
|
||||||
logger.info("🔍 APIGW product search: %s", params)
|
logger.info("🔍 APIGW product search: %s", params)
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
|
timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
|
||||||
@ -229,8 +280,11 @@ async def search_apigw_products(
|
|||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
async with session.get(url, headers=_apigw_headers(), params=params) as response:
|
async with session.get(url, headers=_apigw_headers(), params=params) as response:
|
||||||
if response.status >= 400:
|
if response.status >= 400:
|
||||||
detail = await response.text()
|
detail = await _read_apigw_error(response)
|
||||||
raise HTTPException(status_code=response.status, detail=detail)
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"API Gateway product search failed ({response.status}): {detail}",
|
||||||
|
)
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
|
||||||
if q and isinstance(data, dict) and isinstance(data.get("products"), list):
|
if q and isinstance(data, dict) and isinstance(data.get("products"), list):
|
||||||
@ -243,6 +297,12 @@ async def search_apigw_products(
|
|||||||
return data
|
return data
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("❌ APIGW product search timeout for params: %s", params, exc_info=True)
|
||||||
|
raise HTTPException(status_code=504, detail="API Gateway product search timed out")
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error("❌ APIGW product search connection error: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=502, detail=f"API Gateway connection failed: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error searching APIGW products: %s", e, exc_info=True)
|
logger.error("❌ Error searching APIGW products: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@ -348,7 +348,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Type</label>
|
<label class="form-label">Type</label>
|
||||||
<input type="text" class="form-control" id="productType" placeholder="subscription, service, hardware">
|
<select class="form-select" id="productType">
|
||||||
|
<option value="">- Vaelg type -</option>
|
||||||
|
<option value="hardware">Hardware</option>
|
||||||
|
<option value="service">Service</option>
|
||||||
|
<option value="subscription">Abonnement</option>
|
||||||
|
<option value="bundle">Bundle</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Vaelg produkttype for korrekt kategorisering.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Status</label>
|
<label class="form-label">Status</label>
|
||||||
@ -359,15 +366,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">SKU</label>
|
<label class="form-label">SKU</label>
|
||||||
<input type="text" class="form-control" id="productSku">
|
<input type="text" class="form-control" id="productSku" placeholder="Internt varenummer">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">EAN</label>
|
||||||
|
<input type="text" class="form-control" id="productEan" placeholder="Fx 5701234567890" inputmode="numeric">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Salgspris</label>
|
<label class="form-label">Salgspris</label>
|
||||||
<input type="number" class="form-control" id="productSalesPrice" step="0.01" min="0">
|
<div class="input-group">
|
||||||
|
<input type="number" class="form-control" id="productSalesPrice" step="0.01" min="0" placeholder="0,00">
|
||||||
|
<span class="input-group-text">DKK</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Kostpris</label>
|
<label class="form-label">Købspris</label>
|
||||||
<input type="number" class="form-control" id="productCostPrice" step="0.01" min="0">
|
<div class="input-group">
|
||||||
|
<input type="number" class="form-control" id="productCostPrice" step="0.01" min="0" placeholder="0,00">
|
||||||
|
<span class="input-group-text">DKK</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Moms (%)</label>
|
<label class="form-label">Moms (%)</label>
|
||||||
@ -685,6 +702,7 @@ async function createProduct() {
|
|||||||
type: document.getElementById('productType').value.trim() || null,
|
type: document.getElementById('productType').value.trim() || null,
|
||||||
status: document.getElementById('productStatus').value,
|
status: document.getElementById('productStatus').value,
|
||||||
sku_internal: document.getElementById('productSku').value.trim() || null,
|
sku_internal: document.getElementById('productSku').value.trim() || null,
|
||||||
|
ean: document.getElementById('productEan').value.trim() || null,
|
||||||
sales_price: document.getElementById('productSalesPrice').value || null,
|
sales_price: document.getElementById('productSalesPrice').value || null,
|
||||||
cost_price: document.getElementById('productCostPrice').value || null,
|
cost_price: document.getElementById('productCostPrice').value || null,
|
||||||
vat_rate: document.getElementById('productVatRate').value || null,
|
vat_rate: document.getElementById('productVatRate').value || null,
|
||||||
|
|||||||
@ -29,6 +29,8 @@ class SimplyCRMService:
|
|||||||
|
|
||||||
self.session_name: Optional[str] = None
|
self.session_name: Optional[str] = None
|
||||||
self.session: Optional[aiohttp.ClientSession] = None
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
self.last_query_error: Optional[Dict[str, Any]] = None
|
||||||
|
self._denied_relation_fields: set[str] = set()
|
||||||
|
|
||||||
if not all([self.base_url, self.username, self.access_key]):
|
if not all([self.base_url, self.username, self.access_key]):
|
||||||
logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)")
|
logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)")
|
||||||
@ -169,14 +171,20 @@ class SimplyCRMService:
|
|||||||
data = await response.json()
|
data = await response.json()
|
||||||
if not data.get("success"):
|
if not data.get("success"):
|
||||||
error = data.get("error", {})
|
error = data.get("error", {})
|
||||||
logger.error(f"❌ Simply-CRM query error: {error}")
|
self.last_query_error = error if isinstance(error, dict) else {"message": str(error)}
|
||||||
|
if (self.last_query_error or {}).get("code") == "ACCESS_DENIED":
|
||||||
|
logger.warning(f"⚠️ Simply-CRM query access denied: {error}")
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ Simply-CRM query error: {error}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
result = data.get("result", [])
|
result = data.get("result", [])
|
||||||
|
self.last_query_error = None
|
||||||
logger.debug(f"✅ Simply-CRM query returned {len(result)} records")
|
logger.debug(f"✅ Simply-CRM query returned {len(result)} records")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.last_query_error = {"message": str(e)}
|
||||||
logger.error(f"❌ Simply-CRM query error: {e}")
|
logger.error(f"❌ Simply-CRM query error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -224,8 +232,26 @@ class SimplyCRMService:
|
|||||||
"""
|
"""
|
||||||
module_name = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_MODULE", "ModComments")
|
module_name = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_MODULE", "ModComments")
|
||||||
relation_field = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_RELATION_FIELD", "related_to")
|
relation_field = getattr(settings, "SIMPLYCRM_TICKET_COMMENT_RELATION_FIELD", "related_to")
|
||||||
query = f"SELECT * FROM {module_name} WHERE {relation_field} = '{ticket_id}' ORDER BY createdtime ASC;"
|
relation_candidates: List[str] = []
|
||||||
return await self.query(query)
|
for candidate in [relation_field, "parent_id", "ticket_id", "crmid", "related_to"]:
|
||||||
|
if candidate and candidate not in relation_candidates:
|
||||||
|
relation_candidates.append(candidate)
|
||||||
|
|
||||||
|
for candidate in relation_candidates:
|
||||||
|
if candidate in self._denied_relation_fields:
|
||||||
|
continue
|
||||||
|
|
||||||
|
query = f"SELECT * FROM {module_name} WHERE {candidate} = '{ticket_id}' ORDER BY createdtime ASC;"
|
||||||
|
result = await self.query(query)
|
||||||
|
error_code = (self.last_query_error or {}).get("code")
|
||||||
|
|
||||||
|
if error_code == "ACCESS_DENIED":
|
||||||
|
self._denied_relation_fields.add(candidate)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
async def fetch_ticket_emails(self, ticket_id: str) -> List[Dict]:
|
async def fetch_ticket_emails(self, ticket_id: str) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
@ -242,12 +268,26 @@ class SimplyCRMService:
|
|||||||
fallback_relation_field = getattr(settings, "SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD", "related_to")
|
fallback_relation_field = getattr(settings, "SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD", "related_to")
|
||||||
|
|
||||||
records: List[Dict] = []
|
records: List[Dict] = []
|
||||||
query = f"SELECT * FROM {module_name} WHERE {relation_field} = '{ticket_id}';"
|
relation_candidates: List[str] = []
|
||||||
records.extend(await self.query(query))
|
for candidate in [relation_field, fallback_relation_field, "parent_id", "ticket_id", "crmid"]:
|
||||||
|
if candidate and candidate not in relation_candidates:
|
||||||
|
relation_candidates.append(candidate)
|
||||||
|
|
||||||
if not records and fallback_relation_field:
|
for candidate in relation_candidates:
|
||||||
query = f"SELECT * FROM {module_name} WHERE {fallback_relation_field} = '{ticket_id}';"
|
if candidate in self._denied_relation_fields:
|
||||||
records.extend(await self.query(query))
|
continue
|
||||||
|
|
||||||
|
query = f"SELECT * FROM {module_name} WHERE {candidate} = '{ticket_id}';"
|
||||||
|
batch = await self.query(query)
|
||||||
|
error_code = (self.last_query_error or {}).get("code")
|
||||||
|
|
||||||
|
if error_code == "ACCESS_DENIED":
|
||||||
|
self._denied_relation_fields.add(candidate)
|
||||||
|
continue
|
||||||
|
|
||||||
|
records.extend(batch)
|
||||||
|
if records:
|
||||||
|
break
|
||||||
|
|
||||||
# De-duplicate by record id if present
|
# De-duplicate by record id if present
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
|
|||||||
@ -28,6 +28,9 @@ class VTigerService:
|
|||||||
if not all([self.base_url, self.username, self.api_key]):
|
if not all([self.base_url, self.username, self.api_key]):
|
||||||
logger.warning("⚠️ vTiger credentials not fully configured")
|
logger.warning("⚠️ vTiger credentials not fully configured")
|
||||||
|
|
||||||
|
self.last_query_status: Optional[int] = None
|
||||||
|
self.last_query_error: Optional[Dict] = None
|
||||||
|
|
||||||
def _get_auth(self):
|
def _get_auth(self):
|
||||||
"""Get HTTP Basic Auth credentials"""
|
"""Get HTTP Basic Auth credentials"""
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
@ -49,6 +52,8 @@ class VTigerService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
auth = self._get_auth()
|
auth = self._get_auth()
|
||||||
|
self.last_query_status = None
|
||||||
|
self.last_query_error = None
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(
|
async with session.get(
|
||||||
f"{self.rest_endpoint}/query",
|
f"{self.rest_endpoint}/query",
|
||||||
@ -56,6 +61,7 @@ class VTigerService:
|
|||||||
auth=auth
|
auth=auth
|
||||||
) as response:
|
) as response:
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
|
self.last_query_status = response.status
|
||||||
|
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
# vTiger returns text/json instead of application/json
|
# vTiger returns text/json instead of application/json
|
||||||
@ -69,16 +75,28 @@ class VTigerService:
|
|||||||
if data.get('success'):
|
if data.get('success'):
|
||||||
result = data.get('result', [])
|
result = data.get('result', [])
|
||||||
logger.info(f"✅ Query returned {len(result)} records")
|
logger.info(f"✅ Query returned {len(result)} records")
|
||||||
|
self.last_query_error = None
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ vTiger query failed: {data.get('error')}")
|
self.last_query_error = data.get('error')
|
||||||
|
logger.error(f"❌ vTiger query failed: {self.last_query_error}")
|
||||||
return []
|
return []
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ vTiger query HTTP error {response.status}")
|
try:
|
||||||
|
parsed = json.loads(text) if text else {}
|
||||||
|
self.last_query_error = parsed.get('error')
|
||||||
|
except Exception:
|
||||||
|
self.last_query_error = None
|
||||||
|
if response.status == 429:
|
||||||
|
logger.warning(f"⚠️ vTiger query rate-limited (HTTP {response.status})")
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ vTiger query HTTP error {response.status}")
|
||||||
logger.error(f"Query: {query_string}")
|
logger.error(f"Query: {query_string}")
|
||||||
logger.error(f"Response: {text[:500]}")
|
logger.error(f"Response: {text[:500]}")
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.last_query_status = None
|
||||||
|
self.last_query_error = {"message": str(e)}
|
||||||
logger.error(f"❌ vTiger query error: {e}")
|
logger.error(f"❌ vTiger query error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db_connection, release_db_connection
|
from app.core.database import get_db_connection, release_db_connection, execute_query_single
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
@ -19,9 +19,27 @@ templates = Jinja2Templates(directory="app")
|
|||||||
@router.get("/settings", response_class=HTMLResponse, tags=["Frontend"])
|
@router.get("/settings", response_class=HTMLResponse, tags=["Frontend"])
|
||||||
async def settings_page(request: Request):
|
async def settings_page(request: Request):
|
||||||
"""Render settings page"""
|
"""Render settings page"""
|
||||||
|
default_dashboard_path = ""
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT default_dashboard_path
|
||||||
|
FROM user_dashboard_preferences
|
||||||
|
WHERE user_id = %s
|
||||||
|
""",
|
||||||
|
(int(user_id),)
|
||||||
|
)
|
||||||
|
default_dashboard_path = (row or {}).get("default_dashboard_path") or ""
|
||||||
|
except Exception:
|
||||||
|
default_dashboard_path = ""
|
||||||
|
|
||||||
return templates.TemplateResponse("settings/frontend/settings.html", {
|
return templates.TemplateResponse("settings/frontend/settings.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"title": "Indstillinger"
|
"title": "Indstillinger",
|
||||||
|
"default_dashboard_path": default_dashboard_path
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1020,6 +1020,40 @@ async def scan_document(file_path: str):
|
|||||||
|
|
||||||
<!-- System Settings -->
|
<!-- System Settings -->
|
||||||
<div class="tab-pane fade" id="system">
|
<div class="tab-pane fade" id="system">
|
||||||
|
<div class="card p-4 mb-4">
|
||||||
|
<h5 class="mb-3 fw-bold">Standard Dashboard</h5>
|
||||||
|
<p class="text-muted mb-3">Dashboard vises altid fra roden af sitet via <code>/</code>. Vælg her hvilken side der skal åbnes som dit standard-dashboard.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/dashboard/default" class="row g-2 align-items-end">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<label class="form-label small text-muted" for="defaultDashboardPathInput">Dashboard</label>
|
||||||
|
<select id="defaultDashboardPathInput" name="dashboard_path" class="form-select" required>
|
||||||
|
<option value="/ticket/dashboard/technician/v1" {% if (default_dashboard_path or '/ticket/dashboard/technician/v1') == '/ticket/dashboard/technician/v1' %}selected{% endif %}>Tekniker Dashboard V1</option>
|
||||||
|
<option value="/ticket/dashboard/technician/v2" {% if default_dashboard_path == '/ticket/dashboard/technician/v2' %}selected{% endif %}>Tekniker Dashboard V2</option>
|
||||||
|
<option value="/ticket/dashboard/technician/v3" {% if default_dashboard_path == '/ticket/dashboard/technician/v3' %}selected{% endif %}>Tekniker Dashboard V3</option>
|
||||||
|
<option value="/dashboard/sales" {% if default_dashboard_path == '/dashboard/sales' %}selected{% endif %}>Salg Dashboard</option>
|
||||||
|
{% if default_dashboard_path and default_dashboard_path not in ['/ticket/dashboard/technician/v1', '/ticket/dashboard/technician/v2', '/ticket/dashboard/technician/v3', '/dashboard/sales'] %}
|
||||||
|
<option value="{{ default_dashboard_path }}" selected>Nuværende (tilpasset): {{ default_dashboard_path }}</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Vælg et gyldigt dashboard fra listen.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 d-flex gap-2">
|
||||||
|
<input type="hidden" name="redirect_to" value="/settings#system">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="bi bi-save me-2"></i>Gem standard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-3 flex-wrap">
|
||||||
|
<form method="post" action="/dashboard/default/clear" class="d-inline">
|
||||||
|
<input type="hidden" name="redirect_to" value="/settings#system">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="submit">Ryd standard</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<h5 class="mb-4 fw-bold">System Indstillinger</h5>
|
<h5 class="mb-4 fw-bold">System Indstillinger</h5>
|
||||||
<div id="systemSettings">
|
<div id="systemSettings">
|
||||||
|
|||||||
@ -261,7 +261,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu mt-2">
|
<ul class="dropdown-menu mt-2">
|
||||||
<li><a class="dropdown-item py-2" href="#">Tilbud</a></li>
|
<li><a class="dropdown-item py-2" href="#">Tilbud</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Ordre</a></li>
|
<li><a class="dropdown-item py-2" href="/ordre"><i class="bi bi-receipt me-2"></i>Ordre</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
|
<li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
|
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
|
||||||
@ -523,7 +523,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/static/js/tag-picker.js?v=2.0"></script>
|
<script src="/static/js/tag-picker.js?v=2.0"></script>
|
||||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=1.0"></script>
|
<script src="/static/js/telefoni.js?v=2.2"></script>
|
||||||
<script src="/static/js/sms.js?v=1.0"></script>
|
<script src="/static/js/sms.js?v=1.0"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
|
|||||||
@ -3,19 +3,104 @@ Subscriptions API
|
|||||||
Sag-based subscriptions listing and stats
|
Sag-based subscriptions listing and stats
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any, Optional
|
||||||
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
|
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
import logging
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from uuid import uuid4
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from fastapi import Request
|
||||||
|
from app.services.simplycrm_service import SimplyCRMService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
|
ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
|
||||||
|
STAGING_KEY_SQL = "COALESCE(source_account_id, 'name:' || LOWER(COALESCE(source_customer_name, 'ukendt')))"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any])
|
def _staging_status_with_mapping(status: str, has_customer: bool) -> str:
|
||||||
|
if status == "approved":
|
||||||
|
return "approved"
|
||||||
|
return "mapped" if has_customer else "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_date(value: Optional[Any]) -> Optional[date]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(text.replace("Z", "+00:00")).date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _simply_to_hub_interval(frequency: Optional[str]) -> str:
|
||||||
|
normalized = (frequency or "").strip().lower()
|
||||||
|
mapping = {
|
||||||
|
"daily": "daily",
|
||||||
|
"biweekly": "biweekly",
|
||||||
|
"weekly": "biweekly",
|
||||||
|
"monthly": "monthly",
|
||||||
|
"quarterly": "quarterly",
|
||||||
|
"yearly": "yearly",
|
||||||
|
"annually": "yearly",
|
||||||
|
"semi_annual": "yearly",
|
||||||
|
}
|
||||||
|
return mapping.get(normalized, "monthly")
|
||||||
|
|
||||||
|
|
||||||
|
def _next_invoice_date(start_date: date, interval: str) -> date:
|
||||||
|
if interval == "daily":
|
||||||
|
return start_date + timedelta(days=1)
|
||||||
|
if interval == "biweekly":
|
||||||
|
return start_date + timedelta(days=14)
|
||||||
|
if interval == "quarterly":
|
||||||
|
return start_date + relativedelta(months=3)
|
||||||
|
if interval == "yearly":
|
||||||
|
return start_date + relativedelta(years=1)
|
||||||
|
return start_date + relativedelta(months=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_map_customer(account_id: Optional[str], customer_name: Optional[str], customer_cvr: Optional[str]) -> Optional[int]:
|
||||||
|
if account_id:
|
||||||
|
row = execute_query_single(
|
||||||
|
"SELECT id FROM customers WHERE vtiger_id = %s LIMIT 1",
|
||||||
|
(account_id,)
|
||||||
|
)
|
||||||
|
if row and row.get("id"):
|
||||||
|
return int(row["id"])
|
||||||
|
|
||||||
|
if customer_cvr:
|
||||||
|
row = execute_query_single(
|
||||||
|
"SELECT id FROM customers WHERE cvr_number = %s LIMIT 1",
|
||||||
|
(customer_cvr,)
|
||||||
|
)
|
||||||
|
if row and row.get("id"):
|
||||||
|
return int(row["id"])
|
||||||
|
|
||||||
|
if customer_name:
|
||||||
|
row = execute_query_single(
|
||||||
|
"SELECT id FROM customers WHERE LOWER(name) = LOWER(%s) LIMIT 1",
|
||||||
|
(customer_name,)
|
||||||
|
)
|
||||||
|
if row and row.get("id"):
|
||||||
|
return int(row["id"])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sag-subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any])
|
||||||
async def get_subscription_by_sag(sag_id: int):
|
async def get_subscription_by_sag(sag_id: int):
|
||||||
"""Get latest subscription for a case."""
|
"""Get latest subscription for a case."""
|
||||||
try:
|
try:
|
||||||
@ -72,7 +157,7 @@ async def get_subscription_by_sag(sag_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/subscriptions", response_model=Dict[str, Any])
|
@router.post("/sag-subscriptions", response_model=Dict[str, Any])
|
||||||
async def create_subscription(payload: Dict[str, Any]):
|
async def create_subscription(payload: Dict[str, Any]):
|
||||||
"""Create a new subscription tied to a case (status = draft)."""
|
"""Create a new subscription tied to a case (status = draft)."""
|
||||||
try:
|
try:
|
||||||
@ -158,6 +243,25 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
if len(cleaned_items) > 1:
|
if len(cleaned_items) > 1:
|
||||||
product_name = f"{product_name} (+{len(cleaned_items) - 1})"
|
product_name = f"{product_name} (+{len(cleaned_items) - 1})"
|
||||||
|
|
||||||
|
# Calculate next_invoice_date based on billing_interval
|
||||||
|
|
||||||
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||||
|
period_start = start_dt
|
||||||
|
|
||||||
|
# Calculate next invoice date
|
||||||
|
if billing_interval == "daily":
|
||||||
|
next_invoice_date = start_dt + timedelta(days=1)
|
||||||
|
elif billing_interval == "biweekly":
|
||||||
|
next_invoice_date = start_dt + timedelta(days=14)
|
||||||
|
elif billing_interval == "monthly":
|
||||||
|
next_invoice_date = start_dt + relativedelta(months=1)
|
||||||
|
elif billing_interval == "quarterly":
|
||||||
|
next_invoice_date = start_dt + relativedelta(months=3)
|
||||||
|
elif billing_interval == "yearly":
|
||||||
|
next_invoice_date = start_dt + relativedelta(years=1)
|
||||||
|
else:
|
||||||
|
next_invoice_date = start_dt + relativedelta(months=1) # Default to monthly
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
@ -171,9 +275,11 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
billing_day,
|
billing_day,
|
||||||
price,
|
price,
|
||||||
start_date,
|
start_date,
|
||||||
|
period_start,
|
||||||
|
next_invoice_date,
|
||||||
status,
|
status,
|
||||||
notes
|
notes
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'draft', %s)
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@ -184,6 +290,8 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
billing_day,
|
billing_day,
|
||||||
total_price,
|
total_price,
|
||||||
start_date,
|
start_date,
|
||||||
|
period_start,
|
||||||
|
next_invoice_date,
|
||||||
notes,
|
notes,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -226,7 +334,165 @@ async def create_subscription(payload: Dict[str, Any]):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/subscriptions/{subscription_id}/status", response_model=Dict[str, Any])
|
@router.get("/sag-subscriptions/{subscription_id}", response_model=Dict[str, Any])
|
||||||
|
async def get_subscription(subscription_id: int):
|
||||||
|
"""Get a single subscription by ID with all details."""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.subscription_number,
|
||||||
|
s.sag_id,
|
||||||
|
sg.titel AS sag_title,
|
||||||
|
s.customer_id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
s.product_name,
|
||||||
|
s.billing_interval,
|
||||||
|
s.billing_day,
|
||||||
|
s.price,
|
||||||
|
s.start_date,
|
||||||
|
s.end_date,
|
||||||
|
s.next_invoice_date,
|
||||||
|
s.period_start,
|
||||||
|
s.notice_period_days,
|
||||||
|
s.status,
|
||||||
|
s.notes,
|
||||||
|
s.cancelled_at,
|
||||||
|
s.cancellation_reason,
|
||||||
|
s.created_at,
|
||||||
|
s.updated_at
|
||||||
|
FROM sag_subscriptions s
|
||||||
|
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE s.id = %s
|
||||||
|
"""
|
||||||
|
subscription = execute_query_single(query, (subscription_id,))
|
||||||
|
if not subscription:
|
||||||
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||||
|
|
||||||
|
# Get line items
|
||||||
|
items = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.line_no,
|
||||||
|
i.product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
i.description,
|
||||||
|
i.quantity,
|
||||||
|
i.unit_price,
|
||||||
|
i.line_total
|
||||||
|
FROM sag_subscription_items i
|
||||||
|
LEFT JOIN products p ON p.id = i.product_id
|
||||||
|
WHERE i.subscription_id = %s
|
||||||
|
ORDER BY i.line_no ASC, i.id ASC
|
||||||
|
""",
|
||||||
|
(subscription_id,)
|
||||||
|
)
|
||||||
|
subscription["line_items"] = items or []
|
||||||
|
return subscription
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error loading subscription: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sag-subscriptions/{subscription_id}", response_model=Dict[str, Any])
|
||||||
|
async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
|
||||||
|
"""Update subscription - all fields editable including line items."""
|
||||||
|
try:
|
||||||
|
subscription = execute_query_single(
|
||||||
|
"SELECT id, status FROM sag_subscriptions WHERE id = %s",
|
||||||
|
(subscription_id,)
|
||||||
|
)
|
||||||
|
if not subscription:
|
||||||
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||||
|
|
||||||
|
# Extract line_items before processing other fields
|
||||||
|
line_items = payload.pop("line_items", None)
|
||||||
|
|
||||||
|
# Build dynamic update query
|
||||||
|
allowed_fields = {
|
||||||
|
"product_name", "billing_interval", "billing_day", "price",
|
||||||
|
"start_date", "end_date", "next_invoice_date", "period_start",
|
||||||
|
"notice_period_days", "status", "notes"
|
||||||
|
}
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
values = []
|
||||||
|
for field, value in payload.items():
|
||||||
|
if field in allowed_fields:
|
||||||
|
updates.append(f"{field} = %s")
|
||||||
|
values.append(value)
|
||||||
|
|
||||||
|
# Validate status if provided
|
||||||
|
if "status" in payload and payload["status"] not in ALLOWED_STATUSES:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
# Update subscription fields if any
|
||||||
|
if updates:
|
||||||
|
values.append(subscription_id)
|
||||||
|
query = f"""
|
||||||
|
UPDATE sag_subscriptions
|
||||||
|
SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
cursor.execute(query, tuple(values))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
else:
|
||||||
|
cursor.execute("SELECT * FROM sag_subscriptions WHERE id = %s", (subscription_id,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
|
||||||
|
# Update line items if provided
|
||||||
|
if line_items is not None:
|
||||||
|
# Delete existing line items
|
||||||
|
cursor.execute(
|
||||||
|
"DELETE FROM sag_subscription_items WHERE subscription_id = %s",
|
||||||
|
(subscription_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert new line items
|
||||||
|
for idx, item in enumerate(line_items, start=1):
|
||||||
|
description = item.get("description", "").strip()
|
||||||
|
quantity = float(item.get("quantity", 0))
|
||||||
|
unit_price = float(item.get("unit_price", 0))
|
||||||
|
|
||||||
|
if not description or quantity <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_total = quantity * unit_price
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_subscription_items (
|
||||||
|
subscription_id, line_no, description,
|
||||||
|
quantity, unit_price, line_total, product_id
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
subscription_id, idx, description,
|
||||||
|
quantity, unit_price, line_total,
|
||||||
|
item.get("product_id")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating subscription: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sag-subscriptions/{subscription_id}/status", response_model=Dict[str, Any])
|
||||||
async def update_subscription_status(subscription_id: int, payload: Dict[str, Any]):
|
async def update_subscription_status(subscription_id: int, payload: Dict[str, Any]):
|
||||||
"""Update subscription status."""
|
"""Update subscription status."""
|
||||||
try:
|
try:
|
||||||
@ -251,9 +517,9 @@ async def update_subscription_status(subscription_id: int, payload: Dict[str, An
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subscriptions", response_model=List[Dict[str, Any]])
|
@router.get("/sag-subscriptions", response_model=List[Dict[str, Any]])
|
||||||
async def list_subscriptions(status: str = Query("all")):
|
async def list_subscriptions(status: str = Query("all")):
|
||||||
"""List subscriptions by status (default: all)."""
|
"""List subscriptions by status (default: all) with line item counts."""
|
||||||
try:
|
try:
|
||||||
where_clause = ""
|
where_clause = ""
|
||||||
params: List[Any] = []
|
params: List[Any] = []
|
||||||
@ -275,20 +541,28 @@ async def list_subscriptions(status: str = Query("all")):
|
|||||||
s.price,
|
s.price,
|
||||||
s.start_date,
|
s.start_date,
|
||||||
s.end_date,
|
s.end_date,
|
||||||
s.status
|
s.status,
|
||||||
|
(SELECT COUNT(*) FROM sag_subscription_items WHERE subscription_id = s.id) as item_count
|
||||||
FROM sag_subscriptions s
|
FROM sag_subscriptions s
|
||||||
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
|
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
|
||||||
LEFT JOIN customers c ON c.id = s.customer_id
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
{where_clause}
|
{where_clause}
|
||||||
ORDER BY s.start_date DESC, s.id DESC
|
ORDER BY s.start_date DESC, s.id DESC
|
||||||
"""
|
"""
|
||||||
return execute_query(query, tuple(params)) or []
|
subscriptions = execute_query(query, tuple(params)) or []
|
||||||
|
|
||||||
|
# Add line_items array with count for display
|
||||||
|
for sub in subscriptions:
|
||||||
|
item_count = sub.get('item_count', 0)
|
||||||
|
sub['line_items'] = [{'count': item_count}] if item_count > 0 else []
|
||||||
|
|
||||||
|
return subscriptions
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
|
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subscriptions/stats/summary", response_model=Dict[str, Any])
|
@router.get("/sag-subscriptions/stats/summary", response_model=Dict[str, Any])
|
||||||
async def subscription_stats(status: str = Query("all")):
|
async def subscription_stats(status: str = Query("all")):
|
||||||
"""Summary stats for subscriptions by status (default: all)."""
|
"""Summary stats for subscriptions by status (default: all)."""
|
||||||
try:
|
try:
|
||||||
@ -314,3 +588,517 @@ async def subscription_stats(status: str = Query("all")):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error loading subscription stats: {e}", exc_info=True)
|
logger.error(f"❌ Error loading subscription stats: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sag-subscriptions/process-invoices")
|
||||||
|
async def trigger_subscription_processing():
|
||||||
|
"""Manual trigger for subscription invoice processing (for testing)."""
|
||||||
|
try:
|
||||||
|
from app.jobs.process_subscriptions import process_subscriptions
|
||||||
|
await process_subscriptions()
|
||||||
|
return {"status": "success", "message": "Subscription processing completed"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Manual subscription processing failed: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/simply-subscription-staging/import", response_model=Dict[str, Any])
|
||||||
|
async def import_simply_subscriptions_to_staging():
|
||||||
|
"""Import recurring Simply CRM SalesOrders into staging (parking area)."""
|
||||||
|
try:
|
||||||
|
async with SimplyCRMService() as service:
|
||||||
|
raw_subscriptions = await service.fetch_active_subscriptions()
|
||||||
|
import_batch_id = str(uuid4())
|
||||||
|
|
||||||
|
account_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
upserted = 0
|
||||||
|
auto_mapped = 0
|
||||||
|
|
||||||
|
for raw in raw_subscriptions:
|
||||||
|
normalized = service.extract_subscription_data(raw)
|
||||||
|
source_record_id = str(normalized.get("simplycrm_id") or raw.get("id") or "").strip()
|
||||||
|
if not source_record_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_account_id = normalized.get("account_id")
|
||||||
|
source_customer_name = None
|
||||||
|
source_customer_cvr = None
|
||||||
|
|
||||||
|
if source_account_id:
|
||||||
|
if source_account_id not in account_cache:
|
||||||
|
account_cache[source_account_id] = await service.fetch_account_by_id(source_account_id) or {}
|
||||||
|
account = account_cache[source_account_id]
|
||||||
|
source_customer_name = (account.get("accountname") or "").strip() or None
|
||||||
|
source_customer_cvr = (account.get("siccode") or account.get("vat_number") or "").strip() or None
|
||||||
|
|
||||||
|
if not source_customer_name:
|
||||||
|
source_customer_name = (raw.get("accountname") or raw.get("account_id") or "").strip() or None
|
||||||
|
|
||||||
|
hub_customer_id = _auto_map_customer(source_account_id, source_customer_name, source_customer_cvr)
|
||||||
|
if hub_customer_id:
|
||||||
|
auto_mapped += 1
|
||||||
|
|
||||||
|
source_status = (normalized.get("status") or "active").strip()
|
||||||
|
source_subject = (normalized.get("name") or raw.get("subject") or "").strip() or None
|
||||||
|
source_total_amount = float(normalized.get("total_amount") or normalized.get("subtotal") or 0)
|
||||||
|
source_currency = (normalized.get("currency") or "DKK").strip() or "DKK"
|
||||||
|
source_start_date = _safe_date(normalized.get("start_date"))
|
||||||
|
source_end_date = _safe_date(normalized.get("end_date"))
|
||||||
|
source_binding_end_date = _safe_date(normalized.get("binding_end_date"))
|
||||||
|
source_billing_frequency = _simply_to_hub_interval(normalized.get("billing_frequency"))
|
||||||
|
|
||||||
|
sync_hash = hashlib.sha256(
|
||||||
|
json.dumps(raw, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO simply_subscription_staging (
|
||||||
|
source_system,
|
||||||
|
source_record_id,
|
||||||
|
source_account_id,
|
||||||
|
source_customer_name,
|
||||||
|
source_customer_cvr,
|
||||||
|
source_salesorder_no,
|
||||||
|
source_subject,
|
||||||
|
source_status,
|
||||||
|
source_start_date,
|
||||||
|
source_end_date,
|
||||||
|
source_binding_end_date,
|
||||||
|
source_billing_frequency,
|
||||||
|
source_total_amount,
|
||||||
|
source_currency,
|
||||||
|
source_raw,
|
||||||
|
sync_hash,
|
||||||
|
hub_customer_id,
|
||||||
|
approval_status,
|
||||||
|
import_batch_id,
|
||||||
|
imported_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s::jsonb,
|
||||||
|
%s, %s, %s, %s::uuid, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
ON CONFLICT (source_system, source_record_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
source_account_id = EXCLUDED.source_account_id,
|
||||||
|
source_customer_name = EXCLUDED.source_customer_name,
|
||||||
|
source_customer_cvr = EXCLUDED.source_customer_cvr,
|
||||||
|
source_salesorder_no = EXCLUDED.source_salesorder_no,
|
||||||
|
source_subject = EXCLUDED.source_subject,
|
||||||
|
source_status = EXCLUDED.source_status,
|
||||||
|
source_start_date = EXCLUDED.source_start_date,
|
||||||
|
source_end_date = EXCLUDED.source_end_date,
|
||||||
|
source_binding_end_date = EXCLUDED.source_binding_end_date,
|
||||||
|
source_billing_frequency = EXCLUDED.source_billing_frequency,
|
||||||
|
source_total_amount = EXCLUDED.source_total_amount,
|
||||||
|
source_currency = EXCLUDED.source_currency,
|
||||||
|
source_raw = EXCLUDED.source_raw,
|
||||||
|
sync_hash = EXCLUDED.sync_hash,
|
||||||
|
hub_customer_id = COALESCE(simply_subscription_staging.hub_customer_id, EXCLUDED.hub_customer_id),
|
||||||
|
approval_status = CASE
|
||||||
|
WHEN simply_subscription_staging.approval_status = 'approved' THEN 'approved'
|
||||||
|
ELSE %s
|
||||||
|
END,
|
||||||
|
approval_error = CASE
|
||||||
|
WHEN simply_subscription_staging.approval_status = 'approved' THEN simply_subscription_staging.approval_error
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
import_batch_id = EXCLUDED.import_batch_id,
|
||||||
|
imported_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"simplycrm",
|
||||||
|
source_record_id,
|
||||||
|
source_account_id,
|
||||||
|
source_customer_name,
|
||||||
|
source_customer_cvr,
|
||||||
|
normalized.get("salesorder_no"),
|
||||||
|
source_subject,
|
||||||
|
source_status,
|
||||||
|
source_start_date,
|
||||||
|
source_end_date,
|
||||||
|
source_binding_end_date,
|
||||||
|
source_billing_frequency,
|
||||||
|
source_total_amount,
|
||||||
|
source_currency,
|
||||||
|
json.dumps(raw, ensure_ascii=False, default=str),
|
||||||
|
sync_hash,
|
||||||
|
hub_customer_id,
|
||||||
|
_staging_status_with_mapping("pending", bool(hub_customer_id)),
|
||||||
|
import_batch_id,
|
||||||
|
_staging_status_with_mapping("pending", bool(hub_customer_id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upserted += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"batch_id": import_batch_id,
|
||||||
|
"fetched": len(raw_subscriptions),
|
||||||
|
"upserted": upserted,
|
||||||
|
"auto_mapped": auto_mapped,
|
||||||
|
"pending_manual": max(upserted - auto_mapped, 0),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Simply staging import failed: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Could not import subscriptions from Simply CRM")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/simply-subscription-staging/customers", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_staging_customers(status: str = Query("pending")):
|
||||||
|
"""List staging queue grouped by customer/account key."""
|
||||||
|
try:
|
||||||
|
where_clauses = []
|
||||||
|
params: List[Any] = []
|
||||||
|
if status and status != "all":
|
||||||
|
where_clauses.append("approval_status = %s")
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
{STAGING_KEY_SQL} AS customer_key,
|
||||||
|
COALESCE(MAX(source_customer_name), 'Ukendt kunde') AS source_customer_name,
|
||||||
|
MAX(source_account_id) AS source_account_id,
|
||||||
|
COUNT(*) AS row_count,
|
||||||
|
COUNT(*) FILTER (WHERE hub_customer_id IS NOT NULL) AS mapped_count,
|
||||||
|
COUNT(*) FILTER (WHERE approval_status = 'approved') AS approved_count,
|
||||||
|
COUNT(*) FILTER (WHERE approval_status = 'error') AS error_count,
|
||||||
|
COALESCE(SUM(source_total_amount), 0) AS total_amount,
|
||||||
|
MAX(updated_at) AS updated_at
|
||||||
|
FROM simply_subscription_staging
|
||||||
|
{where_sql}
|
||||||
|
GROUP BY {STAGING_KEY_SQL}
|
||||||
|
ORDER BY MAX(updated_at) DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
return execute_query(query, tuple(params)) or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed listing staging customers: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Could not list staging customers")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/simply-subscription-staging/customers/{customer_key}/rows", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_staging_customer_rows(customer_key: str):
|
||||||
|
"""List staging rows for one customer group."""
|
||||||
|
try:
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.source_record_id,
|
||||||
|
s.source_salesorder_no,
|
||||||
|
s.source_subject,
|
||||||
|
s.source_status,
|
||||||
|
s.source_billing_frequency,
|
||||||
|
s.source_start_date,
|
||||||
|
s.source_end_date,
|
||||||
|
s.source_total_amount,
|
||||||
|
s.source_currency,
|
||||||
|
s.hub_customer_id,
|
||||||
|
c.name AS hub_customer_name,
|
||||||
|
s.hub_sag_id,
|
||||||
|
s.approval_status,
|
||||||
|
s.approval_error,
|
||||||
|
s.approved_at,
|
||||||
|
s.updated_at
|
||||||
|
FROM simply_subscription_staging s
|
||||||
|
LEFT JOIN customers c ON c.id = s.hub_customer_id
|
||||||
|
WHERE {STAGING_KEY_SQL} = %s
|
||||||
|
ORDER BY s.source_salesorder_no NULLS LAST, s.id ASC
|
||||||
|
"""
|
||||||
|
return execute_query(query, (customer_key,)) or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed listing staging rows for customer key {customer_key}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Could not list staging rows")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/simply-subscription-staging/rows", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_all_staging_rows(
|
||||||
|
status: str = Query("all"),
|
||||||
|
limit: int = Query(500, ge=1, le=2000),
|
||||||
|
):
|
||||||
|
"""List all imported staging rows for overview page/table."""
|
||||||
|
try:
|
||||||
|
where_clauses = []
|
||||||
|
params: List[Any] = []
|
||||||
|
|
||||||
|
if status and status != "all":
|
||||||
|
where_clauses.append("s.approval_status = %s")
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.source_record_id,
|
||||||
|
s.source_salesorder_no,
|
||||||
|
s.source_account_id,
|
||||||
|
s.source_customer_name,
|
||||||
|
s.source_customer_cvr,
|
||||||
|
s.source_subject,
|
||||||
|
s.source_status,
|
||||||
|
s.source_billing_frequency,
|
||||||
|
s.source_start_date,
|
||||||
|
s.source_end_date,
|
||||||
|
s.source_total_amount,
|
||||||
|
s.source_currency,
|
||||||
|
s.hub_customer_id,
|
||||||
|
c.name AS hub_customer_name,
|
||||||
|
s.hub_sag_id,
|
||||||
|
s.approval_status,
|
||||||
|
s.approval_error,
|
||||||
|
s.approved_at,
|
||||||
|
s.import_batch_id,
|
||||||
|
s.imported_at,
|
||||||
|
s.updated_at
|
||||||
|
FROM simply_subscription_staging s
|
||||||
|
LEFT JOIN customers c ON c.id = s.hub_customer_id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY s.updated_at DESC, s.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
params.append(limit)
|
||||||
|
return execute_query(query, tuple(params)) or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed listing all staging rows: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Could not list imported staging rows")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/simply-subscription-staging/{staging_id}/map", response_model=Dict[str, Any])
|
||||||
|
async def map_staging_row(staging_id: int, payload: Dict[str, Any]):
|
||||||
|
"""Map a staging row to Hub customer (and optional existing sag)."""
|
||||||
|
try:
|
||||||
|
hub_customer_id = payload.get("hub_customer_id")
|
||||||
|
hub_sag_id = payload.get("hub_sag_id")
|
||||||
|
|
||||||
|
if not hub_customer_id:
|
||||||
|
raise HTTPException(status_code=400, detail="hub_customer_id is required")
|
||||||
|
|
||||||
|
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (hub_customer_id,))
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=400, detail="Hub customer not found")
|
||||||
|
|
||||||
|
if hub_sag_id:
|
||||||
|
sag = execute_query_single(
|
||||||
|
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(hub_sag_id,)
|
||||||
|
)
|
||||||
|
if not sag:
|
||||||
|
raise HTTPException(status_code=400, detail="Hub sag not found")
|
||||||
|
if int(sag.get("customer_id") or 0) != int(hub_customer_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Hub sag does not belong to selected customer")
|
||||||
|
|
||||||
|
result = execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE simply_subscription_staging
|
||||||
|
SET hub_customer_id = %s,
|
||||||
|
hub_sag_id = %s,
|
||||||
|
approval_status = CASE WHEN approval_status = 'approved' THEN 'approved' ELSE 'mapped' END,
|
||||||
|
approval_error = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(hub_customer_id, hub_sag_id, staging_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Staging row not found")
|
||||||
|
|
||||||
|
return result[0]
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed mapping staging row: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Could not map staging row")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/simply-subscription-staging/customers/{customer_key}/approve", response_model=Dict[str, Any])
|
||||||
|
async def approve_staging_customer_rows(customer_key: str, payload: Dict[str, Any], request: Request):
|
||||||
|
"""Approve selected rows for one customer key and copy to Hub subscriptions."""
|
||||||
|
try:
|
||||||
|
row_ids = payload.get("row_ids") or []
|
||||||
|
if not isinstance(row_ids, list) or not row_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="row_ids is required")
|
||||||
|
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
created_by_user_id = int(user_id) if user_id is not None else 1
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT *
|
||||||
|
FROM simply_subscription_staging
|
||||||
|
WHERE {STAGING_KEY_SQL} = %s
|
||||||
|
AND id = ANY(%s)
|
||||||
|
""",
|
||||||
|
(customer_key, row_ids)
|
||||||
|
) or []
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="No staging rows found for customer + selection")
|
||||||
|
|
||||||
|
success_rows: List[int] = []
|
||||||
|
error_rows: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
row_id = int(row["id"])
|
||||||
|
hub_customer_id = row.get("hub_customer_id")
|
||||||
|
|
||||||
|
if not hub_customer_id:
|
||||||
|
error_message = "Missing hub_customer_id mapping"
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE simply_subscription_staging
|
||||||
|
SET approval_status = 'error',
|
||||||
|
approval_error = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(error_message, row_id)
|
||||||
|
)
|
||||||
|
error_rows.append({"id": row_id, "error": error_message})
|
||||||
|
continue
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
start_date = _safe_date(row.get("source_start_date")) or date.today()
|
||||||
|
billing_interval = _simply_to_hub_interval(row.get("source_billing_frequency"))
|
||||||
|
billing_day = min(max(start_date.day, 1), 31)
|
||||||
|
next_invoice_date = _next_invoice_date(start_date, billing_interval)
|
||||||
|
|
||||||
|
source_subject = (row.get("source_subject") or row.get("source_salesorder_no") or "Simply abonnement").strip()
|
||||||
|
source_record_id = row.get("source_record_id") or str(row_id)
|
||||||
|
source_salesorder_no = row.get("source_salesorder_no") or source_record_id
|
||||||
|
amount = float(row.get("source_total_amount") or 0)
|
||||||
|
|
||||||
|
sag_id = row.get("hub_sag_id")
|
||||||
|
if not sag_id:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_sager (
|
||||||
|
titel,
|
||||||
|
beskrivelse,
|
||||||
|
template_key,
|
||||||
|
status,
|
||||||
|
customer_id,
|
||||||
|
created_by_user_id
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"Simply abonnement {source_salesorder_no}",
|
||||||
|
f"Auto-oprettet fra Simply CRM staging row {source_record_id}",
|
||||||
|
"subscription",
|
||||||
|
"åben",
|
||||||
|
hub_customer_id,
|
||||||
|
created_by_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sag_id = cursor.fetchone()["id"]
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_subscriptions (
|
||||||
|
sag_id,
|
||||||
|
customer_id,
|
||||||
|
product_name,
|
||||||
|
billing_interval,
|
||||||
|
billing_day,
|
||||||
|
price,
|
||||||
|
start_date,
|
||||||
|
period_start,
|
||||||
|
next_invoice_date,
|
||||||
|
status,
|
||||||
|
notes
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sag_id,
|
||||||
|
hub_customer_id,
|
||||||
|
source_subject,
|
||||||
|
billing_interval,
|
||||||
|
billing_day,
|
||||||
|
amount,
|
||||||
|
start_date,
|
||||||
|
start_date,
|
||||||
|
next_invoice_date,
|
||||||
|
f"Imported from Simply CRM source {source_record_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
subscription_id = cursor.fetchone()["id"]
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_subscription_items (
|
||||||
|
subscription_id,
|
||||||
|
line_no,
|
||||||
|
product_id,
|
||||||
|
description,
|
||||||
|
quantity,
|
||||||
|
unit_price,
|
||||||
|
line_total
|
||||||
|
) VALUES (%s, 1, NULL, %s, 1, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
subscription_id,
|
||||||
|
source_subject,
|
||||||
|
amount,
|
||||||
|
amount,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE simply_subscription_staging
|
||||||
|
SET hub_sag_id = %s,
|
||||||
|
approval_status = 'approved',
|
||||||
|
approval_error = NULL,
|
||||||
|
approved_at = CURRENT_TIMESTAMP,
|
||||||
|
approved_by_user_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(sag_id, created_by_user_id, row_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
success_rows.append(row_id)
|
||||||
|
except Exception as row_exc:
|
||||||
|
conn.rollback()
|
||||||
|
error_message = str(row_exc)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE simply_subscription_staging
|
||||||
|
SET approval_status = 'error',
|
||||||
|
approval_error = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(error_message[:1000], row_id)
|
||||||
|
)
|
||||||
|
error_rows.append({"id": row_id, "error": error_message})
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"selected_count": len(row_ids),
|
||||||
|
"approved_count": len(success_rows),
|
||||||
|
"error_count": len(error_rows),
|
||||||
|
"approved_row_ids": success_rows,
|
||||||
|
"errors": error_rows,
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed approving staging rows: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Could not approve selected staging rows")
|
||||||
|
|||||||
@ -9,7 +9,10 @@
|
|||||||
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
|
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
|
||||||
<p class="text-muted">Alle solgte, aktive abonnementer</p>
|
<p class="text-muted">Alle solgte, aktive abonnementer</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto d-flex gap-2 align-items-start">
|
||||||
|
<a href="/subscriptions/simply-imports" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-cloud-arrow-down me-1"></i>Simply Import Oversigt
|
||||||
|
</a>
|
||||||
<select class="form-select" id="subscriptionStatusFilter" style="min-width: 180px;">
|
<select class="form-select" id="subscriptionStatusFilter" style="min-width: 180px;">
|
||||||
<option value="all" selected>Alle statuser</option>
|
<option value="all" selected>Alle statuser</option>
|
||||||
<option value="active">Aktiv</option>
|
<option value="active">Aktiv</option>
|
||||||
@ -64,11 +67,12 @@
|
|||||||
<th>Pris</th>
|
<th>Pris</th>
|
||||||
<th>Start</th>
|
<th>Start</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th width="150">Handlinger</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="subscriptionsBody">
|
<tbody id="subscriptionsBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="text-center text-muted py-5">
|
<td colspan="9" class="text-center text-muted py-5">
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
|
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -79,16 +83,444 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Subscription Modal -->
|
||||||
|
<div class="modal fade" id="editModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Rediger Abonnement</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="editSubId">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Produkt navn</label>
|
||||||
|
<input type="text" class="form-control" id="editProductName">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Pris (DKK)</label>
|
||||||
|
<input type="number" class="form-control" id="editPrice" step="0.01" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Billing interval</label>
|
||||||
|
<select class="form-select" id="editInterval">
|
||||||
|
<option value="daily">Daglig</option>
|
||||||
|
<option value="biweekly">Hver 14. dag</option>
|
||||||
|
<option value="monthly">Månedlig</option>
|
||||||
|
<option value="quarterly">Kvartalsvis</option>
|
||||||
|
<option value="yearly">Årlig</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Billing dag (1-31)</label>
|
||||||
|
<input type="number" class="form-control" id="editBillingDay" min="1" max="31">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Start dato</label>
|
||||||
|
<input type="date" class="form-control" id="editStartDate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Slut dato (valgfri)</label>
|
||||||
|
<input type="date" class="form-control" id="editEndDate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Periode start <i class="bi bi-info-circle" title="Startdato for nuværende faktureringsperiode"></i></label>
|
||||||
|
<input type="date" class="form-control" id="editPeriodStart">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Næste faktura dato <i class="bi bi-info-circle" title="Dato for næste automatiske faktura"></i></label>
|
||||||
|
<input type="date" class="form-control" id="editNextInvoiceDate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Opsigelsesfrist (dage)</label>
|
||||||
|
<input type="number" class="form-control" id="editNoticePeriod" min="0" value="30">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="editStatus">
|
||||||
|
<option value="draft">Kladde</option>
|
||||||
|
<option value="active">Aktiv</option>
|
||||||
|
<option value="paused">Pauset</option>
|
||||||
|
<option value="cancelled">Opsagt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Noter</label>
|
||||||
|
<textarea class="form-control" id="editNotes" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- Line Items Section -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="mb-0">📦 Abonnementsvarer</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addLineItem()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Tilføj vare
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered" id="lineItemsTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th width="40%">Beskrivelse</th>
|
||||||
|
<th width="15%">Antal</th>
|
||||||
|
<th width="20%">Pris/stk</th>
|
||||||
|
<th width="20%">Total</th>
|
||||||
|
<th width="5%"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="lineItemsBody">
|
||||||
|
<!-- Line items will be inserted here -->
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="table-light">
|
||||||
|
<td colspan="3" class="text-end"><strong>Total:</strong></td>
|
||||||
|
<td><strong id="lineItemsTotal">0,00 kr</strong></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveEdit()">Gem ændringer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancel Subscription Modal -->
|
||||||
|
<div class="modal fade" id="cancelModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title">Opsig Abonnement</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="cancelSubId">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
Dette opretter en opsigelsessag og beregner slutdato baseret på opsigelsesfrist.
|
||||||
|
</div>
|
||||||
|
<p><strong>Abonnement:</strong> <span id="cancelSubName"></span></p>
|
||||||
|
<p><strong>Kunde:</strong> <span id="cancelCustomerName"></span></p>
|
||||||
|
<p><strong>Opsigelsesfrist:</strong> <span id="cancelNoticeDays"></span> dage</p>
|
||||||
|
<p><strong>Beregnet slutdato:</strong> <span id="cancelEndDate"></span></p>
|
||||||
|
<div class="mb-3 mt-4">
|
||||||
|
<label class="form-label">Årsag til opsigelse</label>
|
||||||
|
<textarea class="form-control" id="cancelReason" rows="3" placeholder="Angiv årsag..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmCancel()">Opsig abonnement</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
let currentSubscriptions = [];
|
||||||
|
let currentStagingCustomerKey = null;
|
||||||
|
|
||||||
|
function stagingStatusBadge(status) {
|
||||||
|
const badges = {
|
||||||
|
pending: '<span class="badge bg-light text-dark">Pending</span>',
|
||||||
|
mapped: '<span class="badge bg-info text-dark">Mapped</span>',
|
||||||
|
approved: '<span class="badge bg-success">Approved</span>',
|
||||||
|
error: '<span class="badge bg-danger">Fejl</span>'
|
||||||
|
};
|
||||||
|
return badges[status] || `<span class="badge bg-light text-dark">${status || '-'}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.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() {
|
async function loadSubscriptions() {
|
||||||
try {
|
try {
|
||||||
const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
|
const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
|
||||||
const stats = await fetch(`/api/v1/subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
|
const stats = await fetch(`/api/v1/sag-subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
|
||||||
document.getElementById('activeCount').textContent = stats.subscription_count || 0;
|
document.getElementById('activeCount').textContent = stats.subscription_count || 0;
|
||||||
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
|
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
|
||||||
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
|
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
|
||||||
|
|
||||||
const subscriptions = await fetch(`/api/v1/subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
|
const subscriptions = await fetch(`/api/v1/sag-subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
|
||||||
|
currentSubscriptions = subscriptions;
|
||||||
renderSubscriptions(subscriptions);
|
renderSubscriptions(subscriptions);
|
||||||
|
|
||||||
const title = document.getElementById('subscriptionsTitle');
|
const title = document.getElementById('subscriptionsTitle');
|
||||||
@ -105,7 +537,7 @@ async function loadSubscriptions() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading subscriptions:', e);
|
console.error('Error loading subscriptions:', e);
|
||||||
document.getElementById('subscriptionsBody').innerHTML = `
|
document.getElementById('subscriptionsBody').innerHTML = `
|
||||||
<tr><td colspan="8" class="text-center text-danger py-5">
|
<tr><td colspan="9" class="text-center text-danger py-5">
|
||||||
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
|
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
|
||||||
<p>Fejl ved indlaesning</p>
|
<p>Fejl ved indlaesning</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
@ -118,7 +550,7 @@ function renderSubscriptions(subscriptions) {
|
|||||||
|
|
||||||
if (!subscriptions || subscriptions.length === 0) {
|
if (!subscriptions || subscriptions.length === 0) {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = `
|
||||||
<tr><td colspan="8" class="text-center text-muted py-5">
|
<tr><td colspan="9" class="text-center text-muted py-5">
|
||||||
<i class="bi bi-inbox fs-1 mb-3"></i>
|
<i class="bi bi-inbox fs-1 mb-3"></i>
|
||||||
<p>Ingen aktive abonnementer</p>
|
<p>Ingen aktive abonnementer</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
@ -132,23 +564,262 @@ function renderSubscriptions(subscriptions) {
|
|||||||
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
|
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
|
||||||
const subNumber = sub.subscription_number || `#${sub.id}`;
|
const subNumber = sub.subscription_number || `#${sub.id}`;
|
||||||
|
|
||||||
|
// Show product name with item count if available
|
||||||
|
let productDisplay = sub.product_name || '-';
|
||||||
|
if (sub.line_items && sub.line_items.length > 0) {
|
||||||
|
productDisplay = `${sub.product_name} <span class="badge bg-light text-dark">${sub.line_items.length} varer</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canEdit = sub.status !== 'cancelled';
|
||||||
|
const canCancel = sub.status === 'active' || sub.status === 'paused';
|
||||||
|
|
||||||
|
const actions = `
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
${canEdit ? `<button class="btn btn-outline-primary" onclick="openEditModal(${sub.id})" title="Rediger">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>` : ''}
|
||||||
|
${canCancel ? `<button class="btn btn-outline-danger" onclick="openCancelModal(${sub.id})" title="Opsig">
|
||||||
|
<i class="bi bi-x-circle"></i>
|
||||||
|
</button>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${subNumber}</strong></td>
|
<td><strong>${subNumber}</strong></td>
|
||||||
<td>${sub.customer_name || '-'}</td>
|
<td>${sub.customer_name || '-'}</td>
|
||||||
<td>${sagLink}</td>
|
<td>${sagLink}</td>
|
||||||
<td>${sub.product_name || '-'}</td>
|
<td>${productDisplay}</td>
|
||||||
<td>${intervalLabel}${sub.billing_day ? ' (dag ' + sub.billing_day + ')' : ''}</td>
|
<td>${intervalLabel}${sub.billing_day ? ' (dag ' + sub.billing_day + ')' : ''}</td>
|
||||||
<td>${formatCurrency(sub.price || 0)}</td>
|
<td>${formatCurrency(sub.price || 0)}</td>
|
||||||
<td>${formatDate(sub.start_date)}</td>
|
<td>${formatDate(sub.start_date)}</td>
|
||||||
<td>${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
|
<td>${actions}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEditModal(subId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const sub = await response.json();
|
||||||
|
|
||||||
|
console.log('Loaded subscription:', sub);
|
||||||
|
|
||||||
|
document.getElementById('editSubId').value = sub.id || '';
|
||||||
|
document.getElementById('editProductName').value = sub.product_name || '';
|
||||||
|
document.getElementById('editPrice').value = sub.price || 0;
|
||||||
|
document.getElementById('editInterval').value = sub.billing_interval || 'monthly';
|
||||||
|
document.getElementById('editBillingDay').value = sub.billing_day || 1;
|
||||||
|
document.getElementById('editStartDate').value = sub.start_date || '';
|
||||||
|
document.getElementById('editEndDate').value = sub.end_date || '';
|
||||||
|
document.getElementById('editPeriodStart').value = sub.period_start || '';
|
||||||
|
document.getElementById('editNextInvoiceDate').value = sub.next_invoice_date || '';
|
||||||
|
document.getElementById('editNoticePeriod').value = sub.notice_period_days || 30;
|
||||||
|
document.getElementById('editStatus').value = sub.status || 'draft';
|
||||||
|
document.getElementById('editNotes').value = sub.notes || '';
|
||||||
|
|
||||||
|
// Load line items
|
||||||
|
renderLineItems(sub.line_items || []);
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('editModal')).show();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading subscription:', e);
|
||||||
|
alert('Fejl ved indlæsning af abonnement: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lineItemsData = [];
|
||||||
|
|
||||||
|
function renderLineItems(items) {
|
||||||
|
lineItemsData = items.map((item, idx) => ({
|
||||||
|
id: item.id || null,
|
||||||
|
description: item.description || '',
|
||||||
|
quantity: item.quantity || 1,
|
||||||
|
unit_price: item.unit_price || 0,
|
||||||
|
product_id: item.product_id || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
updateLineItemsTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLineItemsTable() {
|
||||||
|
const tbody = document.getElementById('lineItemsBody');
|
||||||
|
|
||||||
|
if (lineItemsData.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen varer - klik "Tilføj vare" for at tilføje</td></tr>';
|
||||||
|
document.getElementById('lineItemsTotal').textContent = '0,00 kr';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = lineItemsData.map((item, idx) => {
|
||||||
|
const total = (parseFloat(item.quantity) || 0) * (parseFloat(item.unit_price) || 0);
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
value="${item.description}"
|
||||||
|
onchange="updateLineItem(${idx}, 'description', this.value)"
|
||||||
|
placeholder="Beskrivelse">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control form-control-sm"
|
||||||
|
value="${item.quantity}"
|
||||||
|
step="0.01" min="0"
|
||||||
|
onchange="updateLineItem(${idx}, 'quantity', this.value)">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control form-control-sm"
|
||||||
|
value="${item.unit_price}"
|
||||||
|
step="0.01" min="0"
|
||||||
|
onchange="updateLineItem(${idx}, 'unit_price', this.value)">
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">${formatCurrency(total)}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick="removeLineItem(${idx})" title="Fjern">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
calculateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLineItem() {
|
||||||
|
lineItemsData.push({
|
||||||
|
id: null,
|
||||||
|
description: '',
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 0,
|
||||||
|
product_id: null
|
||||||
|
});
|
||||||
|
updateLineItemsTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLineItem(idx) {
|
||||||
|
if (confirm('Er du sikker på at du vil fjerne denne vare?')) {
|
||||||
|
lineItemsData.splice(idx, 1);
|
||||||
|
updateLineItemsTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLineItem(idx, field, value) {
|
||||||
|
lineItemsData[idx][field] = value;
|
||||||
|
updateLineItemsTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTotal() {
|
||||||
|
const total = lineItemsData.reduce((sum, item) => {
|
||||||
|
return sum + ((parseFloat(item.quantity) || 0) * (parseFloat(item.unit_price) || 0));
|
||||||
|
}, 0);
|
||||||
|
document.getElementById('lineItemsTotal').textContent = formatCurrency(total);
|
||||||
|
// Also update the main price field
|
||||||
|
document.getElementById('editPrice').value = total.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
try {
|
||||||
|
const subId = document.getElementById('editSubId').value;
|
||||||
|
|
||||||
|
// Validate line items
|
||||||
|
const validLineItems = lineItemsData.filter(item => {
|
||||||
|
return item.description && item.description.trim() !== '' &&
|
||||||
|
parseFloat(item.quantity) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validLineItems.length === 0) {
|
||||||
|
alert('⚠️ Du skal tilføje mindst én vare');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
product_name: document.getElementById('editProductName').value,
|
||||||
|
price: parseFloat(document.getElementById('editPrice').value),
|
||||||
|
billing_interval: document.getElementById('editInterval').value,
|
||||||
|
billing_day: parseInt(document.getElementById('editBillingDay').value),
|
||||||
|
start_date: document.getElementById('editStartDate').value || null,
|
||||||
|
end_date: document.getElementById('editEndDate').value || null,
|
||||||
|
period_start: document.getElementById('editPeriodStart').value || null,
|
||||||
|
next_invoice_date: document.getElementById('editNextInvoiceDate').value || null,
|
||||||
|
notice_period_days: parseInt(document.getElementById('editNoticePeriod').value),
|
||||||
|
status: document.getElementById('editStatus').value,
|
||||||
|
notes: document.getElementById('editNotes').value,
|
||||||
|
line_items: validLineItems
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/sag-subscriptions/${subId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
||||||
|
loadSubscriptions();
|
||||||
|
alert('✅ Abonnement opdateret');
|
||||||
|
} catch (e) {
|
||||||
|
alert('❌ Fejl ved opdatering: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCancelModal(subId) {
|
||||||
|
try {
|
||||||
|
const sub = await fetch(`/api/v1/sag-subscriptions/${subId}`).then(r => r.json());
|
||||||
|
const noticeDays = sub.notice_period_days || 30;
|
||||||
|
const endDate = new Date();
|
||||||
|
endDate.setDate(endDate.getDate() + noticeDays);
|
||||||
|
|
||||||
|
document.getElementById('cancelSubId').value = sub.id;
|
||||||
|
document.getElementById('cancelSubName').textContent = sub.product_name || 'Ukendt';
|
||||||
|
document.getElementById('cancelCustomerName').textContent = sub.customer_name || 'Ukendt';
|
||||||
|
document.getElementById('cancelNoticeDays').textContent = noticeDays;
|
||||||
|
document.getElementById('cancelEndDate').textContent = endDate.toLocaleDateString('da-DK');
|
||||||
|
document.getElementById('cancelReason').value = '';
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('cancelModal')).show();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fejl ved indlæsning af abonnement: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCancel() {
|
||||||
|
try {
|
||||||
|
const subId = document.getElementById('cancelSubId').value;
|
||||||
|
const reason = document.getElementById('cancelReason').value;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/subscriptions/${subId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason, user_id: 1 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to cancel');
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('cancelModal')).hide();
|
||||||
|
loadSubscriptions();
|
||||||
|
|
||||||
|
alert(`✅ Abonnement opsagt\nSlutdato: ${new Date(result.end_date).toLocaleDateString('da-DK')}\nSag oprettet: #${result.cancellation_case_id}`);
|
||||||
|
} catch (e) {
|
||||||
|
alert('❌ Fejl ved opsigelse: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatInterval(interval) {
|
function formatInterval(interval) {
|
||||||
const map = {
|
const map = {
|
||||||
|
'daily': 'Daglig',
|
||||||
|
'biweekly': '14-dage',
|
||||||
'monthly': 'Maaned',
|
'monthly': 'Maaned',
|
||||||
'quarterly': 'Kvartal',
|
'quarterly': 'Kvartal',
|
||||||
'yearly': 'Aar'
|
'yearly': 'Aar'
|
||||||
|
|||||||
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", {
|
return templates.TemplateResponse("subscriptions/frontend/list.html", {
|
||||||
"request": request
|
"request": request
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscriptions/simply-imports", response_class=HTMLResponse)
|
||||||
|
async def subscriptions_simply_imports(request: Request):
|
||||||
|
"""Dedicated page for Simply subscription import parking/staging overview."""
|
||||||
|
return templates.TemplateResponse("subscriptions/frontend/simply_imports.html", {
|
||||||
|
"request": request
|
||||||
|
})
|
||||||
|
|||||||
@ -9,12 +9,14 @@ import logging
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, HTTPException, Query, status
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.ticket.backend.ticket_service import TicketService
|
from app.ticket.backend.ticket_service import TicketService
|
||||||
from app.services.simplycrm_service import SimplyCRMService
|
from app.services.simplycrm_service import SimplyCRMService
|
||||||
|
from app.services.vtiger_service import get_vtiger_service
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.ticket.backend.economic_export import ticket_economic_service
|
from app.ticket.backend.economic_export import ticket_economic_service
|
||||||
from app.ticket.backend.models import (
|
from app.ticket.backend.models import (
|
||||||
@ -125,6 +127,24 @@ def _escape_simply_value(value: str) -> str:
|
|||||||
return value.replace("'", "''")
|
return value.replace("'", "''")
|
||||||
|
|
||||||
|
|
||||||
|
async def _vtiger_query_with_retry(vtiger, query_string: str, retries: int = 5, base_delay: float = 1.25) -> List[dict]:
|
||||||
|
"""Run vTiger query with exponential backoff on rate-limit responses."""
|
||||||
|
for attempt in range(retries + 1):
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
result = await vtiger.query(query_string)
|
||||||
|
status_code = getattr(vtiger, "last_query_status", None)
|
||||||
|
error = getattr(vtiger, "last_query_error", None) or {}
|
||||||
|
error_code = error.get("code") if isinstance(error, dict) else None
|
||||||
|
|
||||||
|
if status_code != 429 and error_code != "TOO_MANY_REQUESTS":
|
||||||
|
return result
|
||||||
|
|
||||||
|
if attempt < retries:
|
||||||
|
await asyncio.sleep(base_delay * (2 ** attempt))
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# TICKET ENDPOINTS
|
# TICKET ENDPOINTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -1810,7 +1830,7 @@ async def import_simply_archived_tickets(
|
|||||||
"""
|
"""
|
||||||
One-time import of archived tickets from Simply-CRM.
|
One-time import of archived tickets from Simply-CRM.
|
||||||
"""
|
"""
|
||||||
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
|
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0, "messages_imported": 0}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with SimplyCRMService() as service:
|
async with SimplyCRMService() as service:
|
||||||
@ -1854,6 +1874,10 @@ async def import_simply_archived_tickets(
|
|||||||
ticket,
|
ticket,
|
||||||
["title", "subject", "ticket_title", "tickettitle", "summary"]
|
["title", "subject", "ticket_title", "tickettitle", "summary"]
|
||||||
)
|
)
|
||||||
|
contact_name = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["contactname", "contact_name", "contact"]
|
||||||
|
)
|
||||||
organization_name = _get_first_value(
|
organization_name = _get_first_value(
|
||||||
ticket,
|
ticket,
|
||||||
["accountname", "account_name", "organization", "company"]
|
["accountname", "account_name", "organization", "company"]
|
||||||
@ -1958,50 +1982,51 @@ async def import_simply_archived_tickets(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
if not force and existing.get("sync_hash") == data_hash:
|
hash_matches = existing.get("sync_hash") == data_hash
|
||||||
|
if not force and hash_matches:
|
||||||
|
archived_ticket_id = existing["id"]
|
||||||
stats["skipped"] += 1
|
stats["skipped"] += 1
|
||||||
continue
|
else:
|
||||||
|
execute_update(
|
||||||
execute_update(
|
"""
|
||||||
"""
|
UPDATE tticket_archived_tickets
|
||||||
UPDATE tticket_archived_tickets
|
SET ticket_number = %s,
|
||||||
SET ticket_number = %s,
|
title = %s,
|
||||||
title = %s,
|
organization_name = %s,
|
||||||
organization_name = %s,
|
contact_name = %s,
|
||||||
contact_name = %s,
|
email_from = %s,
|
||||||
email_from = %s,
|
time_spent_hours = %s,
|
||||||
time_spent_hours = %s,
|
description = %s,
|
||||||
description = %s,
|
solution = %s,
|
||||||
solution = %s,
|
status = %s,
|
||||||
status = %s,
|
priority = %s,
|
||||||
priority = %s,
|
source_created_at = %s,
|
||||||
source_created_at = %s,
|
source_updated_at = %s,
|
||||||
source_updated_at = %s,
|
last_synced_at = CURRENT_TIMESTAMP,
|
||||||
last_synced_at = CURRENT_TIMESTAMP,
|
sync_hash = %s,
|
||||||
sync_hash = %s,
|
raw_data = %s::jsonb
|
||||||
raw_data = %s::jsonb
|
WHERE id = %s
|
||||||
WHERE id = %s
|
""",
|
||||||
""",
|
(
|
||||||
(
|
ticket_number,
|
||||||
ticket_number,
|
title,
|
||||||
title,
|
organization_name,
|
||||||
organization_name,
|
contact_name,
|
||||||
contact_name,
|
email_from,
|
||||||
email_from,
|
time_spent_hours,
|
||||||
time_spent_hours,
|
description,
|
||||||
description,
|
solution,
|
||||||
solution,
|
status,
|
||||||
status,
|
priority,
|
||||||
priority,
|
source_created_at,
|
||||||
source_created_at,
|
source_updated_at,
|
||||||
source_updated_at,
|
data_hash,
|
||||||
data_hash,
|
json.dumps(ticket, default=str),
|
||||||
json.dumps(ticket, default=str),
|
existing["id"]
|
||||||
existing["id"]
|
)
|
||||||
)
|
)
|
||||||
)
|
archived_ticket_id = existing["id"]
|
||||||
archived_ticket_id = existing["id"]
|
stats["updated"] += 1
|
||||||
stats["updated"] += 1
|
|
||||||
else:
|
else:
|
||||||
archived_ticket_id = execute_insert(
|
archived_ticket_id = execute_insert(
|
||||||
"""
|
"""
|
||||||
@ -2085,6 +2110,7 @@ async def import_simply_archived_tickets(
|
|||||||
json.dumps(comment, default=str)
|
json.dumps(comment, default=str)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
stats["messages_imported"] += 1
|
||||||
|
|
||||||
for email in emails:
|
for email in emails:
|
||||||
execute_insert(
|
execute_insert(
|
||||||
@ -2112,6 +2138,7 @@ async def import_simply_archived_tickets(
|
|||||||
json.dumps(email, default=str)
|
json.dumps(email, default=str)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
stats["messages_imported"] += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Archived ticket import failed: {e}")
|
logger.error(f"❌ Archived ticket import failed: {e}")
|
||||||
@ -2125,6 +2152,347 @@ async def import_simply_archived_tickets(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/archived/vtiger/import", tags=["Archived Tickets"])
|
||||||
|
async def import_vtiger_archived_tickets(
|
||||||
|
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
|
||||||
|
include_messages: bool = Query(True, description="Include comments and emails"),
|
||||||
|
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
|
||||||
|
force: bool = Query(False, description="Update even if sync hash matches")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
One-time import of archived tickets from vTiger (Cases module).
|
||||||
|
"""
|
||||||
|
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0, "messages_imported": 0}
|
||||||
|
|
||||||
|
try:
|
||||||
|
vtiger = get_vtiger_service()
|
||||||
|
|
||||||
|
if ticket_number:
|
||||||
|
sanitized = _escape_simply_value(ticket_number)
|
||||||
|
tickets = []
|
||||||
|
for field in ("ticket_no", "ticketnumber", "ticket_number"):
|
||||||
|
query = f"SELECT * FROM Cases WHERE {field} = '{sanitized}' LIMIT 1;"
|
||||||
|
tickets = await _vtiger_query_with_retry(vtiger, query)
|
||||||
|
if tickets:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
tickets = []
|
||||||
|
offset = 0
|
||||||
|
batch_size = 200
|
||||||
|
while len(tickets) < limit:
|
||||||
|
query = f"SELECT * FROM Cases LIMIT {offset}, {batch_size};"
|
||||||
|
batch = await _vtiger_query_with_retry(vtiger, query)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
tickets.extend(batch)
|
||||||
|
offset += batch_size
|
||||||
|
if len(batch) < batch_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
tickets = tickets[:limit]
|
||||||
|
|
||||||
|
logger.info(f"🔍 Importing {len(tickets)} archived tickets from vTiger")
|
||||||
|
|
||||||
|
account_cache: dict[str, Optional[str]] = {}
|
||||||
|
contact_cache: dict[str, Optional[str]] = {}
|
||||||
|
contact_account_cache: dict[str, Optional[str]] = {}
|
||||||
|
|
||||||
|
for ticket in tickets:
|
||||||
|
try:
|
||||||
|
external_id = _get_first_value(ticket, ["id", "ticketid", "ticket_id"])
|
||||||
|
if not external_id:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
data_hash = _calculate_hash(ticket)
|
||||||
|
existing = execute_query_single(
|
||||||
|
"""SELECT id, sync_hash
|
||||||
|
FROM tticket_archived_tickets
|
||||||
|
WHERE source_system = %s AND external_id = %s""",
|
||||||
|
("vtiger", external_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket_no_value = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["ticket_no", "ticketnumber", "ticket_number", "case_no", "casenumber", "id"]
|
||||||
|
)
|
||||||
|
title = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["title", "subject", "ticket_title", "tickettitle", "summary"]
|
||||||
|
)
|
||||||
|
contact_name = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["contactname", "contact_name", "contact", "firstname", "lastname"]
|
||||||
|
)
|
||||||
|
organization_name = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["accountname", "account_name", "organization", "company"]
|
||||||
|
)
|
||||||
|
|
||||||
|
account_id = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["parent_id", "account_id", "accountid", "account"]
|
||||||
|
)
|
||||||
|
if not organization_name and _looks_like_external_id(account_id):
|
||||||
|
if account_id not in account_cache:
|
||||||
|
account_rows = await _vtiger_query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;"
|
||||||
|
)
|
||||||
|
account_cache[account_id] = _get_first_value(
|
||||||
|
account_rows[0] if account_rows else {},
|
||||||
|
["accountname", "account_name", "name"]
|
||||||
|
)
|
||||||
|
organization_name = account_cache.get(account_id)
|
||||||
|
|
||||||
|
contact_id = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["contact_id", "contactid"]
|
||||||
|
)
|
||||||
|
if _looks_like_external_id(contact_id):
|
||||||
|
if contact_id not in contact_cache or contact_id not in contact_account_cache:
|
||||||
|
contact_rows = await _vtiger_query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Contacts WHERE id='{contact_id}' LIMIT 1;"
|
||||||
|
)
|
||||||
|
contact_data = contact_rows[0] if contact_rows else {}
|
||||||
|
first_name = _get_first_value(contact_data, ["firstname", "first_name", "first"])
|
||||||
|
last_name = _get_first_value(contact_data, ["lastname", "last_name", "last"])
|
||||||
|
combined_name = " ".join([name for name in [first_name, last_name] if name]).strip()
|
||||||
|
contact_cache[contact_id] = combined_name or _get_first_value(
|
||||||
|
contact_data,
|
||||||
|
["contactname", "contact_name", "name"]
|
||||||
|
)
|
||||||
|
related_account_id = _get_first_value(
|
||||||
|
contact_data,
|
||||||
|
["account_id", "accountid", "account", "parent_id"]
|
||||||
|
)
|
||||||
|
contact_account_cache[contact_id] = related_account_id if _looks_like_external_id(related_account_id) else None
|
||||||
|
|
||||||
|
if not contact_name:
|
||||||
|
contact_name = contact_cache.get(contact_id)
|
||||||
|
|
||||||
|
if not organization_name:
|
||||||
|
related_account_id = contact_account_cache.get(contact_id)
|
||||||
|
if related_account_id:
|
||||||
|
if related_account_id not in account_cache:
|
||||||
|
account_rows = await _vtiger_query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Accounts WHERE id='{related_account_id}' LIMIT 1;"
|
||||||
|
)
|
||||||
|
account_cache[related_account_id] = _get_first_value(
|
||||||
|
account_rows[0] if account_rows else {},
|
||||||
|
["accountname", "account_name", "name"]
|
||||||
|
)
|
||||||
|
organization_name = account_cache.get(related_account_id)
|
||||||
|
|
||||||
|
email_from = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["email_from", "from_email", "from", "email", "email_from_address"]
|
||||||
|
)
|
||||||
|
time_spent_hours = _parse_hours(
|
||||||
|
_get_first_value(ticket, ["time_spent", "hours", "time_spent_hours", "spent_time", "cf_time_spent", "cf_tid_brugt"])
|
||||||
|
)
|
||||||
|
description = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["description", "ticket_description", "comments", "issue"]
|
||||||
|
)
|
||||||
|
solution = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["solution", "resolution", "solutiontext", "resolution_text", "answer"]
|
||||||
|
)
|
||||||
|
status = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["status", "casestatus", "ticketstatus", "state"]
|
||||||
|
)
|
||||||
|
priority = _get_first_value(
|
||||||
|
ticket,
|
||||||
|
["priority", "ticketpriorities", "ticketpriority"]
|
||||||
|
)
|
||||||
|
source_created_at = _parse_datetime(
|
||||||
|
_get_first_value(ticket, ["createdtime", "created_at", "createdon", "created_time"])
|
||||||
|
)
|
||||||
|
source_updated_at = _parse_datetime(
|
||||||
|
_get_first_value(ticket, ["modifiedtime", "updated_at", "modified_time", "updatedtime"])
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
hash_matches = existing.get("sync_hash") == data_hash
|
||||||
|
if not force and hash_matches:
|
||||||
|
archived_ticket_id = existing["id"]
|
||||||
|
stats["skipped"] += 1
|
||||||
|
else:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE tticket_archived_tickets
|
||||||
|
SET ticket_number = %s,
|
||||||
|
title = %s,
|
||||||
|
organization_name = %s,
|
||||||
|
contact_name = %s,
|
||||||
|
email_from = %s,
|
||||||
|
time_spent_hours = %s,
|
||||||
|
description = %s,
|
||||||
|
solution = %s,
|
||||||
|
status = %s,
|
||||||
|
priority = %s,
|
||||||
|
source_created_at = %s,
|
||||||
|
source_updated_at = %s,
|
||||||
|
last_synced_at = CURRENT_TIMESTAMP,
|
||||||
|
sync_hash = %s,
|
||||||
|
raw_data = %s::jsonb
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
ticket_no_value,
|
||||||
|
title,
|
||||||
|
organization_name,
|
||||||
|
contact_name,
|
||||||
|
email_from,
|
||||||
|
time_spent_hours,
|
||||||
|
description,
|
||||||
|
solution,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
source_created_at,
|
||||||
|
source_updated_at,
|
||||||
|
data_hash,
|
||||||
|
json.dumps(ticket, default=str),
|
||||||
|
existing["id"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
archived_ticket_id = existing["id"]
|
||||||
|
stats["updated"] += 1
|
||||||
|
else:
|
||||||
|
archived_ticket_id = execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO tticket_archived_tickets (
|
||||||
|
source_system,
|
||||||
|
external_id,
|
||||||
|
ticket_number,
|
||||||
|
title,
|
||||||
|
organization_name,
|
||||||
|
contact_name,
|
||||||
|
email_from,
|
||||||
|
time_spent_hours,
|
||||||
|
description,
|
||||||
|
solution,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
source_created_at,
|
||||||
|
source_updated_at,
|
||||||
|
last_synced_at,
|
||||||
|
sync_hash,
|
||||||
|
raw_data
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||||
|
CURRENT_TIMESTAMP, %s, %s::jsonb
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"vtiger",
|
||||||
|
external_id,
|
||||||
|
ticket_no_value,
|
||||||
|
title,
|
||||||
|
organization_name,
|
||||||
|
contact_name,
|
||||||
|
email_from,
|
||||||
|
time_spent_hours,
|
||||||
|
description,
|
||||||
|
solution,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
source_created_at,
|
||||||
|
source_updated_at,
|
||||||
|
data_hash,
|
||||||
|
json.dumps(ticket, default=str)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats["imported"] += 1
|
||||||
|
|
||||||
|
if include_messages and archived_ticket_id:
|
||||||
|
execute_update(
|
||||||
|
"DELETE FROM tticket_archived_messages WHERE archived_ticket_id = %s",
|
||||||
|
(archived_ticket_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
comments = await _vtiger_query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM ModComments WHERE related_to = '{external_id}';"
|
||||||
|
)
|
||||||
|
emails = await _vtiger_query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Emails WHERE parent_id = '{external_id}';"
|
||||||
|
)
|
||||||
|
|
||||||
|
for comment in comments:
|
||||||
|
execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO tticket_archived_messages (
|
||||||
|
archived_ticket_id,
|
||||||
|
message_type,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
author_name,
|
||||||
|
author_email,
|
||||||
|
source_created_at,
|
||||||
|
raw_data
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
archived_ticket_id,
|
||||||
|
"comment",
|
||||||
|
None,
|
||||||
|
_get_first_value(comment, ["commentcontent", "comment", "content", "description"]),
|
||||||
|
_get_first_value(comment, ["author", "assigned_user_id", "created_by", "creator"]),
|
||||||
|
_get_first_value(comment, ["email", "author_email", "from_email"]),
|
||||||
|
_parse_datetime(_get_first_value(comment, ["createdtime", "created_at", "created_time"])),
|
||||||
|
json.dumps(comment, default=str)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats["messages_imported"] += 1
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO tticket_archived_messages (
|
||||||
|
archived_ticket_id,
|
||||||
|
message_type,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
author_name,
|
||||||
|
author_email,
|
||||||
|
source_created_at,
|
||||||
|
raw_data
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
archived_ticket_id,
|
||||||
|
"email",
|
||||||
|
_get_first_value(email, ["subject", "title"]),
|
||||||
|
_get_first_value(email, ["description", "body", "email_body", "content"]),
|
||||||
|
_get_first_value(email, ["from_name", "sender", "assigned_user_id"]),
|
||||||
|
_get_first_value(email, ["from_email", "email", "sender_email"]),
|
||||||
|
_parse_datetime(_get_first_value(email, ["createdtime", "created_at", "created_time"])),
|
||||||
|
json.dumps(email, default=str)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats["messages_imported"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ vTiger archived ticket import failed: {e}")
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
logger.info(f"✅ vTiger archived ticket import complete: {stats}")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ vTiger archived ticket import failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/archived/simply/modules", tags=["Archived Tickets"])
|
@router.get("/archived/simply/modules", tags=["Archived Tickets"])
|
||||||
async def list_simply_modules():
|
async def list_simply_modules():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -143,7 +143,7 @@
|
|||||||
name="search"
|
name="search"
|
||||||
id="search"
|
id="search"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Ticket nr, titel eller beskrivelse..."
|
placeholder="Ticket nr, titel, løsning eller kommentar..."
|
||||||
value="{{ search_query or '' }}">
|
value="{{ search_query or '' }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -209,6 +209,8 @@
|
|||||||
<th>Organisation</th>
|
<th>Organisation</th>
|
||||||
<th>Kontakt</th>
|
<th>Kontakt</th>
|
||||||
<th>Email From</th>
|
<th>Email From</th>
|
||||||
|
<th>Løsning</th>
|
||||||
|
<th>Kommentarer</th>
|
||||||
<th>Tid brugt</th>
|
<th>Tid brugt</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Oprettet</th>
|
<th>Oprettet</th>
|
||||||
@ -227,6 +229,18 @@
|
|||||||
<td>{{ ticket.organization_name or '-' }}</td>
|
<td>{{ ticket.organization_name or '-' }}</td>
|
||||||
<td>{{ ticket.contact_name or '-' }}</td>
|
<td>{{ ticket.contact_name or '-' }}</td>
|
||||||
<td>{{ ticket.email_from or '-' }}</td>
|
<td>{{ ticket.email_from or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if ticket.solution and ticket.solution.strip() %}
|
||||||
|
{{ ticket.solution[:120] }}{% if ticket.solution|length > 120 %}...{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" style="background: var(--accent-light); color: var(--accent);">
|
||||||
|
{{ ticket.message_count or 0 }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if ticket.time_spent_hours is not none %}
|
{% if ticket.time_spent_hours is not none %}
|
||||||
{{ '%.2f'|format(ticket.time_spent_hours) }} t
|
{{ '%.2f'|format(ticket.time_spent_hours) }} t
|
||||||
|
|||||||
@ -11,6 +11,9 @@
|
|||||||
<p class="text-muted">Oversigt over alle support tickets og aktivitet</p>
|
<p class="text-muted">Oversigt over alle support tickets og aktivitet</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-outline-primary me-2" onclick="window.location.href='/ticket/dashboard/technician'">
|
||||||
|
<i class="bi bi-tools"></i> Tekniker Dashboard (3 forslag)
|
||||||
|
</button>
|
||||||
<button class="btn btn-primary" onclick="window.location.href='/ticket/tickets/new'">
|
<button class="btn btn-primary" onclick="window.location.href='/ticket/tickets/new'">
|
||||||
<i class="bi bi-plus-circle"></i> Ny Ticket
|
<i class="bi bi-plus-circle"></i> Ny Ticket
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
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 import APIRouter, Request, HTTPException, Form
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from typing import Optional
|
from typing import Optional, Dict, Any
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_update, execute_query_single
|
from app.core.database import execute_query, execute_update, execute_query_single
|
||||||
@ -360,12 +360,309 @@ async def new_ticket_page(request: Request):
|
|||||||
return templates.TemplateResponse("ticket/frontend/ticket_new.html", {"request": request})
|
return templates.TemplateResponse("ticket/frontend/ticket_new.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||||
|
"""Collect live data slices for technician-focused dashboard variants."""
|
||||||
|
user_query = """
|
||||||
|
SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
|
||||||
|
FROM users
|
||||||
|
WHERE user_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
user_result = execute_query(user_query, (technician_user_id,))
|
||||||
|
technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}"
|
||||||
|
|
||||||
|
new_cases_query = """
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.status,
|
||||||
|
s.created_at,
|
||||||
|
s.deadline,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND s.status = 'åben'
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 12
|
||||||
|
"""
|
||||||
|
new_cases = execute_query(new_cases_query)
|
||||||
|
|
||||||
|
my_cases_query = """
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.status,
|
||||||
|
s.created_at,
|
||||||
|
s.deadline,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND s.ansvarlig_bruger_id = %s
|
||||||
|
AND s.status <> 'lukket'
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 12
|
||||||
|
"""
|
||||||
|
my_cases = execute_query(my_cases_query, (technician_user_id,))
|
||||||
|
|
||||||
|
today_tasks_query = """
|
||||||
|
SELECT
|
||||||
|
'case' AS item_type,
|
||||||
|
s.id AS item_id,
|
||||||
|
s.titel AS title,
|
||||||
|
s.status,
|
||||||
|
s.deadline AS due_at,
|
||||||
|
s.created_at,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||||
|
NULL::text AS priority,
|
||||||
|
'Sag deadline i dag' AS task_reason
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND s.ansvarlig_bruger_id = %s
|
||||||
|
AND s.status <> 'lukket'
|
||||||
|
AND s.deadline = CURRENT_DATE
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'ticket' AS item_type,
|
||||||
|
t.id AS item_id,
|
||||||
|
t.subject AS title,
|
||||||
|
t.status,
|
||||||
|
NULL::date AS due_at,
|
||||||
|
t.created_at,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||||
|
COALESCE(t.priority, 'normal') AS priority,
|
||||||
|
'Ticket oprettet i dag' AS task_reason
|
||||||
|
FROM tticket_tickets t
|
||||||
|
LEFT JOIN customers c ON c.id = t.customer_id
|
||||||
|
WHERE t.assigned_to_user_id = %s
|
||||||
|
AND t.status IN ('open', 'in_progress', 'pending_customer')
|
||||||
|
AND DATE(t.created_at) = CURRENT_DATE
|
||||||
|
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 12
|
||||||
|
"""
|
||||||
|
today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id))
|
||||||
|
|
||||||
|
urgent_overdue_query = """
|
||||||
|
SELECT
|
||||||
|
'case' AS item_type,
|
||||||
|
s.id AS item_id,
|
||||||
|
s.titel AS title,
|
||||||
|
s.status,
|
||||||
|
s.deadline AS due_at,
|
||||||
|
s.created_at,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||||
|
NULL::text AS priority,
|
||||||
|
'Over deadline' AS attention_reason
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND s.status <> 'lukket'
|
||||||
|
AND s.deadline IS NOT NULL
|
||||||
|
AND s.deadline < CURRENT_DATE
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'ticket' AS item_type,
|
||||||
|
t.id AS item_id,
|
||||||
|
t.subject AS title,
|
||||||
|
t.status,
|
||||||
|
NULL::date AS due_at,
|
||||||
|
t.created_at,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||||
|
COALESCE(t.priority, 'normal') AS priority,
|
||||||
|
CASE
|
||||||
|
WHEN t.priority = 'urgent' THEN 'Urgent prioritet'
|
||||||
|
ELSE 'Høj prioritet'
|
||||||
|
END AS attention_reason
|
||||||
|
FROM tticket_tickets t
|
||||||
|
LEFT JOIN customers c ON c.id = t.customer_id
|
||||||
|
WHERE t.status IN ('open', 'in_progress', 'pending_customer')
|
||||||
|
AND COALESCE(t.priority, '') IN ('urgent', 'high')
|
||||||
|
AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL)
|
||||||
|
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 15
|
||||||
|
"""
|
||||||
|
urgent_overdue = execute_query(urgent_overdue_query, (technician_user_id,))
|
||||||
|
|
||||||
|
opportunities_query = """
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.status,
|
||||||
|
s.pipeline_amount,
|
||||||
|
s.pipeline_probability,
|
||||||
|
ps.name AS pipeline_stage,
|
||||||
|
s.deadline,
|
||||||
|
s.created_at,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND s.ansvarlig_bruger_id = %s
|
||||||
|
AND (
|
||||||
|
s.template_key = 'pipeline'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_tags et
|
||||||
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'case'
|
||||||
|
AND et.entity_id = s.id
|
||||||
|
AND LOWER(t.name) = 'pipeline'
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_tags st
|
||||||
|
WHERE st.sag_id = s.id
|
||||||
|
AND st.deleted_at IS NULL
|
||||||
|
AND LOWER(st.tag_navn) = 'pipeline'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 12
|
||||||
|
"""
|
||||||
|
my_opportunities = execute_query(opportunities_query, (technician_user_id,))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"technician_user_id": technician_user_id,
|
||||||
|
"technician_name": technician_name,
|
||||||
|
"new_cases": new_cases or [],
|
||||||
|
"my_cases": my_cases or [],
|
||||||
|
"today_tasks": today_tasks or [],
|
||||||
|
"urgent_overdue": urgent_overdue or [],
|
||||||
|
"my_opportunities": my_opportunities or [],
|
||||||
|
"kpis": {
|
||||||
|
"new_cases_count": len(new_cases or []),
|
||||||
|
"my_cases_count": len(my_cases or []),
|
||||||
|
"today_tasks_count": len(today_tasks or []),
|
||||||
|
"urgent_overdue_count": len(urgent_overdue or []),
|
||||||
|
"my_opportunities_count": len(my_opportunities or [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_user_in_technician_group(user_id: int) -> bool:
|
||||||
|
groups_query = """
|
||||||
|
SELECT LOWER(g.name) AS group_name
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
"""
|
||||||
|
groups = execute_query(groups_query, (user_id,)) or []
|
||||||
|
return any(
|
||||||
|
"teknik" in (g.get("group_name") or "") or "technician" in (g.get("group_name") or "")
|
||||||
|
for g in groups
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/technician", response_class=HTMLResponse)
|
||||||
|
async def technician_dashboard_selector(
|
||||||
|
request: Request,
|
||||||
|
technician_user_id: int = 1
|
||||||
|
):
|
||||||
|
"""Technician dashboard landing page with 3 variants to choose from."""
|
||||||
|
try:
|
||||||
|
# Always use logged-in user, ignore query param
|
||||||
|
logged_in_user_id = getattr(request.state, "user_id", 1)
|
||||||
|
context = _get_technician_dashboard_data(logged_in_user_id)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"ticket/frontend/technician_dashboard_selector.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
**context
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to load technician dashboard selector: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/technician/v1", response_class=HTMLResponse)
|
||||||
|
async def technician_dashboard_v1(
|
||||||
|
request: Request,
|
||||||
|
technician_user_id: int = 1
|
||||||
|
):
|
||||||
|
"""Variant 1: KPI + card overview layout."""
|
||||||
|
try:
|
||||||
|
# Always use logged-in user, ignore query param
|
||||||
|
logged_in_user_id = getattr(request.state, "user_id", 1)
|
||||||
|
context = _get_technician_dashboard_data(logged_in_user_id)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"ticket/frontend/mockups/tech_v1_overview.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
**context
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to load technician dashboard v1: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/technician/v2", response_class=HTMLResponse)
|
||||||
|
async def technician_dashboard_v2(
|
||||||
|
request: Request,
|
||||||
|
technician_user_id: int = 1
|
||||||
|
):
|
||||||
|
"""Variant 2: Workboard columns by focus areas."""
|
||||||
|
try:
|
||||||
|
# Always use logged-in user, ignore query param
|
||||||
|
logged_in_user_id = getattr(request.state, "user_id", 1)
|
||||||
|
context = _get_technician_dashboard_data(logged_in_user_id)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"ticket/frontend/mockups/tech_v2_workboard.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
**context
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to load technician dashboard v2: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/technician/v3", response_class=HTMLResponse)
|
||||||
|
async def technician_dashboard_v3(
|
||||||
|
request: Request,
|
||||||
|
technician_user_id: int = 1
|
||||||
|
):
|
||||||
|
"""Variant 3: Dense table-first layout for power users."""
|
||||||
|
try:
|
||||||
|
# Always use logged-in user, ignore query param
|
||||||
|
logged_in_user_id = getattr(request.state, "user_id", 1)
|
||||||
|
context = _get_technician_dashboard_data(logged_in_user_id)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"ticket/frontend/mockups/tech_v3_table_focus.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
**context
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to load technician dashboard v3: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard", response_class=HTMLResponse)
|
@router.get("/dashboard", response_class=HTMLResponse)
|
||||||
async def ticket_dashboard(request: Request):
|
async def ticket_dashboard(request: Request):
|
||||||
"""
|
"""
|
||||||
Ticket system dashboard with statistics
|
Ticket system dashboard with statistics
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
current_user_id = getattr(request.state, "user_id", None)
|
||||||
|
if current_user_id and _is_user_in_technician_group(int(current_user_id)):
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/ticket/dashboard/technician/v1?technician_user_id={int(current_user_id)}",
|
||||||
|
status_code=302
|
||||||
|
)
|
||||||
|
|
||||||
# Get ticket statistics
|
# Get ticket statistics
|
||||||
stats_query = """
|
stats_query = """
|
||||||
SELECT
|
SELECT
|
||||||
@ -530,34 +827,53 @@ async def archived_ticket_list_page(
|
|||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
source_created_at,
|
source_created_at,
|
||||||
description
|
description,
|
||||||
FROM tticket_archived_tickets
|
solution,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM tticket_archived_messages m
|
||||||
|
WHERE m.archived_ticket_id = t.id
|
||||||
|
) AS message_count
|
||||||
|
FROM tticket_archived_tickets t
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
query += " AND (ticket_number ILIKE %s OR title ILIKE %s OR description ILIKE %s)"
|
query += """
|
||||||
|
AND (
|
||||||
|
t.ticket_number ILIKE %s
|
||||||
|
OR t.title ILIKE %s
|
||||||
|
OR t.description ILIKE %s
|
||||||
|
OR t.solution ILIKE %s
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tticket_archived_messages m
|
||||||
|
WHERE m.archived_ticket_id = t.id
|
||||||
|
AND m.body ILIKE %s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
search_pattern = f"%{search}%"
|
search_pattern = f"%{search}%"
|
||||||
params.extend([search_pattern] * 3)
|
params.extend([search_pattern] * 5)
|
||||||
|
|
||||||
if organization:
|
if organization:
|
||||||
query += " AND organization_name ILIKE %s"
|
query += " AND t.organization_name ILIKE %s"
|
||||||
params.append(f"%{organization}%")
|
params.append(f"%{organization}%")
|
||||||
|
|
||||||
if contact:
|
if contact:
|
||||||
query += " AND contact_name ILIKE %s"
|
query += " AND t.contact_name ILIKE %s"
|
||||||
params.append(f"%{contact}%")
|
params.append(f"%{contact}%")
|
||||||
|
|
||||||
if date_from:
|
if date_from:
|
||||||
query += " AND source_created_at >= %s"
|
query += " AND t.source_created_at >= %s"
|
||||||
params.append(date_from)
|
params.append(date_from)
|
||||||
|
|
||||||
if date_to:
|
if date_to:
|
||||||
query += " AND source_created_at <= %s"
|
query += " AND t.source_created_at <= %s"
|
||||||
params.append(date_to)
|
params.append(date_to)
|
||||||
|
|
||||||
query += " ORDER BY source_created_at DESC NULLS LAST, imported_at DESC LIMIT 200"
|
query += " ORDER BY t.source_created_at DESC NULLS LAST, t.imported_at DESC LIMIT 200"
|
||||||
|
|
||||||
tickets = execute_query(query, tuple(params)) if params else execute_query(query)
|
tickets = execute_query(query, tuple(params)) if params else execute_query(query)
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,7 @@ from app.services.customer_consistency import CustomerConsistencyService
|
|||||||
from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService
|
from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService
|
||||||
from app.services.vtiger_service import get_vtiger_service
|
from app.services.vtiger_service import get_vtiger_service
|
||||||
from app.ticket.backend.klippekort_service import KlippekortService
|
from app.ticket.backend.klippekort_service import KlippekortService
|
||||||
|
from app.core.auth_dependencies import get_optional_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -1773,7 +1774,10 @@ async def get_time_entries_for_sag(sag_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to fetch time entries")
|
raise HTTPException(status_code=500, detail="Failed to fetch time entries")
|
||||||
|
|
||||||
@router.post("/entries/internal", tags=["Internal"])
|
@router.post("/entries/internal", tags=["Internal"])
|
||||||
async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
|
async def create_internal_time_entry(
|
||||||
|
entry: Dict[str, Any] = Body(...),
|
||||||
|
current_user: Optional[dict] = Depends(get_optional_user)
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Create a time entry manually (Internal/Hub).
|
Create a time entry manually (Internal/Hub).
|
||||||
Requires: sag_id, original_hours
|
Requires: sag_id, original_hours
|
||||||
@ -1786,7 +1790,12 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
|
|||||||
description = entry.get("description")
|
description = entry.get("description")
|
||||||
hours = entry.get("original_hours")
|
hours = entry.get("original_hours")
|
||||||
worked_date = entry.get("worked_date") or datetime.now().date()
|
worked_date = entry.get("worked_date") or datetime.now().date()
|
||||||
user_name = entry.get("user_name", "Hub User")
|
default_user_name = (
|
||||||
|
(current_user or {}).get("username")
|
||||||
|
or (current_user or {}).get("full_name")
|
||||||
|
or "Hub User"
|
||||||
|
)
|
||||||
|
user_name = entry.get("user_name") or default_user_name
|
||||||
prepaid_card_id = entry.get("prepaid_card_id")
|
prepaid_card_id = entry.get("prepaid_card_id")
|
||||||
fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
|
fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
|
||||||
work_type = entry.get("work_type", "support")
|
work_type = entry.get("work_type", "support")
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Compare local dev database schema with production to find missing columns
|
Compare local dev database schema with production to find missing columns
|
||||||
"""
|
"""
|
||||||
|
|||||||
18
main.py
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.telefoni.frontend import views as telefoni_views
|
||||||
from app.modules.calendar.backend import router as calendar_api
|
from app.modules.calendar.backend import router as calendar_api
|
||||||
from app.modules.calendar.frontend import views as calendar_views
|
from app.modules.calendar.frontend import views as calendar_views
|
||||||
|
from app.modules.orders.backend import router as orders_api
|
||||||
|
from app.modules.orders.frontend import views as orders_views
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -118,6 +120,7 @@ async def lifespan(app: FastAPI):
|
|||||||
# Register reminder scheduler job
|
# Register reminder scheduler job
|
||||||
from app.jobs.check_reminders import check_reminders
|
from app.jobs.check_reminders import check_reminders
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
backup_scheduler.scheduler.add_job(
|
backup_scheduler.scheduler.add_job(
|
||||||
func=check_reminders,
|
func=check_reminders,
|
||||||
@ -129,6 +132,19 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
logger.info("✅ Reminder job scheduled (every 5 minutes)")
|
logger.info("✅ Reminder job scheduled (every 5 minutes)")
|
||||||
|
|
||||||
|
# Register subscription invoice processing job
|
||||||
|
from app.jobs.process_subscriptions import process_subscriptions
|
||||||
|
|
||||||
|
backup_scheduler.scheduler.add_job(
|
||||||
|
func=process_subscriptions,
|
||||||
|
trigger=CronTrigger(hour=4, minute=0),
|
||||||
|
id='process_subscriptions',
|
||||||
|
name='Process Subscription Invoices',
|
||||||
|
max_instances=1,
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
logger.info("✅ Subscription invoice job scheduled (daily at 04:00)")
|
||||||
|
|
||||||
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
|
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
|
||||||
from app.jobs.eset_sync import run_eset_sync
|
from app.jobs.eset_sync import run_eset_sync
|
||||||
|
|
||||||
@ -293,6 +309,7 @@ app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"])
|
|||||||
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
|
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
|
||||||
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
||||||
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
||||||
|
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
|
||||||
|
|
||||||
# Frontend Routers
|
# Frontend Routers
|
||||||
app.include_router(dashboard_views.router, tags=["Frontend"])
|
app.include_router(dashboard_views.router, tags=["Frontend"])
|
||||||
@ -320,6 +337,7 @@ app.include_router(locations_views.router, tags=["Frontend"])
|
|||||||
app.include_router(devportal_views.router, tags=["Frontend"])
|
app.include_router(devportal_views.router, tags=["Frontend"])
|
||||||
app.include_router(telefoni_views.router, tags=["Frontend"])
|
app.include_router(telefoni_views.router, tags=["Frontend"])
|
||||||
app.include_router(calendar_views.router, tags=["Frontend"])
|
app.include_router(calendar_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(orders_views.router, tags=["Frontend"])
|
||||||
|
|
||||||
# Serve static files (UI)
|
# Serve static files (UI)
|
||||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||||
|
|||||||
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 number = data.number || '';
|
||||||
const title = contact?.name ? contact.name : 'Ukendt nummer';
|
const title = contact?.name ? contact.name : 'Ukendt nummer';
|
||||||
const company = contact?.company ? contact.company : '';
|
const company = contact?.company ? contact.company : '';
|
||||||
|
const recentCases = data.recent_cases || [];
|
||||||
|
const lastCall = data.last_call;
|
||||||
|
|
||||||
const callId = data.call_id;
|
const callId = data.call_id;
|
||||||
|
|
||||||
@ -78,6 +80,54 @@
|
|||||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
|
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Build recent cases HTML
|
||||||
|
let casesHtml = '';
|
||||||
|
if (recentCases.length > 0) {
|
||||||
|
casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>';
|
||||||
|
recentCases.forEach(c => {
|
||||||
|
casesHtml += `<div class="small"><a href="/sag/${c.id}" class="text-decoration-none" target="_blank">${escapeHtml(c.titel)}</a></div>`;
|
||||||
|
});
|
||||||
|
casesHtml += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build last call HTML
|
||||||
|
let lastCallHtml = '';
|
||||||
|
if (lastCall) {
|
||||||
|
// lastCall can be either a date string (legacy) or an object with started_at and bruger_navn
|
||||||
|
const callDate = lastCall.started_at ? lastCall.started_at : lastCall;
|
||||||
|
const lastCallDate = new Date(callDate);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - lastCallDate;
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
let timeAgo = '';
|
||||||
|
if (diffDays === 0) {
|
||||||
|
timeAgo = 'I dag';
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
timeAgo = 'I går';
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
timeAgo = `${diffDays} dage siden`;
|
||||||
|
} else {
|
||||||
|
timeAgo = lastCallDate.toLocaleDateString('da-DK');
|
||||||
|
}
|
||||||
|
|
||||||
|
const brugerInfo = lastCall.bruger_navn ? ` (${escapeHtml(lastCall.bruger_navn)})` : '';
|
||||||
|
|
||||||
|
// Format duration
|
||||||
|
let durationInfo = '';
|
||||||
|
if (lastCall.duration_sec) {
|
||||||
|
const mins = Math.floor(lastCall.duration_sec / 60);
|
||||||
|
const secs = lastCall.duration_sec % 60;
|
||||||
|
if (mins > 0) {
|
||||||
|
durationInfo = ` - ${mins}m ${secs}s`;
|
||||||
|
} else {
|
||||||
|
durationInfo = ` - ${secs}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCallHtml = `<div class="small text-muted mt-2"><i class="bi bi-clock-history me-1"></i>Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
toastEl.innerHTML = `
|
toastEl.innerHTML = `
|
||||||
<div class="toast-header">
|
<div class="toast-header">
|
||||||
<strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong>
|
<strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong>
|
||||||
@ -88,6 +138,8 @@
|
|||||||
<div class="fw-bold">${escapeHtml(number)}</div>
|
<div class="fw-bold">${escapeHtml(number)}</div>
|
||||||
<div>${escapeHtml(title)}</div>
|
<div>${escapeHtml(title)}</div>
|
||||||
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
|
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
|
||||||
|
${lastCallHtml}
|
||||||
|
${casesHtml}
|
||||||
<div class="d-flex gap-2 mt-3">
|
<div class="d-flex gap-2 mt-3">
|
||||||
${openContactBtn}
|
${openContactBtn}
|
||||||
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
||||||
|
|||||||
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