+
+
+
+ {% for user in assignment_users or [] %}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% for group in assignment_groups or [] %}
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
@@ -925,6 +964,13 @@
+
+
Hvad betyder relationstyper?
+
Relateret til: Faglig kobling uden direkte afhængighed.
+
Afledt af: Denne sag er opstået på baggrund af en anden sag.
+
Årsag til: Denne sag er årsagen til en anden sag.
+
Blokkerer: Arbejde i en sag stopper fremdrift i den anden.
+
{% macro render_tree(nodes) %}
{% for node in nodes %}
@@ -936,16 +982,23 @@
{% if node.relation_type %}
{% set rel_icon = 'bi-link-45deg' %}
{% set rel_color = 'text-muted' %}
+ {% set rel_help = 'Faglig kobling uden direkte afhængighed' %}
{% if node.relation_type == 'Afledt af' %}
{% set rel_icon = 'bi-arrow-return-right' %}
{% set rel_color = 'text-info' %}
+ {% set rel_help = 'Denne sag er opstået på baggrund af en anden sag' %}
+ {% elif node.relation_type == 'Årsag til' %}
+ {% set rel_icon = 'bi-arrow-right-circle' %}
+ {% set rel_color = 'text-primary' %}
+ {% set rel_help = 'Denne sag er årsag til en anden sag' %}
{% elif node.relation_type == 'Blokkerer' %}
{% set rel_icon = 'bi-slash-circle' %}
{% set rel_color = 'text-danger' %}
+ {% set rel_help = 'Arbejdet i denne sag blokerer den anden' %}
{% endif %}
-
+ {{ node.relation_type }}
@@ -1244,16 +1297,24 @@
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Betydning i praksis
+
Relateret til: Bruges når sager hænger sammen, men ingen af dem afhænger direkte af den anden.
+
Afledt af: Bruges når denne sag er afledt af et tidligere problem/arbejde.
+
Årsag til: Bruges når denne sag skaber behovet for den anden.
+
Blokkerer: Bruges når løsning i én sag er nødvendig før den anden kan videre.
+
@@ -1350,6 +1411,37 @@
+
+
+
+
+
+
Deadline
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1465,6 +1557,8 @@
setupContactSearch();
setupCustomerSearch();
setupRelationSearch();
+ updateRelationTypeHint();
+ updateNewCaseRelationTypeHint();
// Render Global Tags
if (window.renderEntityTags) {
@@ -1521,6 +1615,7 @@
function showRelationModal() {
relationModal.show();
+ updateRelationTypeHint();
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
}
@@ -1585,6 +1680,67 @@
function showCreateRelatedModal() {
createRelatedCaseModalInstance.show();
+ updateNewCaseRelationTypeHint();
+ }
+
+ function relationTypeMeaning(type) {
+ const map = {
+ 'Relateret til': {
+ icon: '🔗',
+ text: 'Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.'
+ },
+ 'Afledt af': {
+ icon: '↪',
+ text: 'Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).'
+ },
+ 'Årsag til': {
+ icon: '➡',
+ text: 'Denne sag er årsag til den anden sag (du peger frem mod en konsekvens/opfølgning).'
+ },
+ 'Blokkerer': {
+ icon: '⛔',
+ text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.'
+ }
+ };
+ return map[type] || null;
+ }
+
+ function updateRelationTypeHint() {
+ const select = document.getElementById('relationTypeSelect');
+ const hint = document.getElementById('relationTypeHint');
+ if (!select || !hint) return;
+
+ const meaning = relationTypeMeaning(select.value);
+ if (!meaning) {
+ hint.style.display = 'none';
+ hint.innerHTML = '';
+ return;
+ }
+
+ hint.style.display = 'block';
+ hint.innerHTML = `${meaning.icon} Betydning: ${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 = '↪ Effekt: Nuværende sag markeres som afledt af den nye sag.';
+ return;
+ }
+ if (selected === 'Årsag til') {
+ hint.innerHTML = '➡ Effekt: Nuværende sag markeres som årsag til den nye sag.';
+ return;
+ }
+ if (selected === 'Blokkerer') {
+ hint.innerHTML = '⛔ Effekt: Nuværende sag markeres som blokering for den nye sag.';
+ return;
+ }
+
+ hint.innerHTML = '🔗 Effekt: Sagerne kobles fagligt uden direkte afhængighed.';
}
async function createRelatedCase() {
@@ -2118,7 +2274,7 @@
${l.name}
${l.location_type || '-'}
-
@@ -2459,10 +2615,14 @@
async function unlinkLocation(locId) {
if(!confirm("Fjern link til denne lokation?")) return;
try {
- await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
+ const res = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.detail || 'Kunne ikke fjerne lokation');
+ }
loadCaseLocations();
} catch (e) {
- alert("Fejl ved sletning");
+ alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
}
}
@@ -2505,6 +2665,121 @@
}
}
+
+
+
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
+
+ {{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
+
+
+ {{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
+
{{ sag.status }}
@@ -369,7 +398,7 @@
{% if related_sag and rel.target_id not in seen_targets %}
{% set _ = seen_targets.append(rel.target_id) %}
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
-
+
#{{ related_sag.id }}
@@ -383,7 +412,7 @@
{% endif %}
- {{ related_sag.type or 'ticket' }}
+ {{ related_sag.template_key or related_sag.type or 'ticket' }}
+{% endblock %}
diff --git a/app/ticket/frontend/views.py b/app/ticket/frontend/views.py
index 168fbb3..b104c89 100644
--- a/app/ticket/frontend/views.py
+++ b/app/ticket/frontend/views.py
@@ -7,7 +7,7 @@ import logging
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
-from typing import Optional
+from typing import Optional, Dict, Any
from datetime import date
from app.core.database import execute_query, execute_update, execute_query_single
@@ -360,12 +360,309 @@ async def new_ticket_page(request: Request):
return templates.TemplateResponse("ticket/frontend/ticket_new.html", {"request": request})
+def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
+ """Collect live data slices for technician-focused dashboard variants."""
+ user_query = """
+ SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
+ FROM users
+ WHERE user_id = %s
+ LIMIT 1
+ """
+ user_result = execute_query(user_query, (technician_user_id,))
+ technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}"
+
+ new_cases_query = """
+ SELECT
+ s.id,
+ s.titel,
+ s.status,
+ s.created_at,
+ s.deadline,
+ COALESCE(c.name, 'Ukendt kunde') AS customer_name
+ FROM sag_sager s
+ LEFT JOIN customers c ON c.id = s.customer_id
+ WHERE s.deleted_at IS NULL
+ AND s.status = 'åben'
+ ORDER BY s.created_at DESC
+ LIMIT 12
+ """
+ new_cases = execute_query(new_cases_query)
+
+ my_cases_query = """
+ SELECT
+ s.id,
+ s.titel,
+ s.status,
+ s.created_at,
+ s.deadline,
+ COALESCE(c.name, 'Ukendt kunde') AS customer_name
+ FROM sag_sager s
+ LEFT JOIN customers c ON c.id = s.customer_id
+ WHERE s.deleted_at IS NULL
+ AND s.ansvarlig_bruger_id = %s
+ AND s.status <> 'lukket'
+ ORDER BY s.created_at DESC
+ LIMIT 12
+ """
+ my_cases = execute_query(my_cases_query, (technician_user_id,))
+
+ today_tasks_query = """
+ SELECT
+ 'case' AS item_type,
+ s.id AS item_id,
+ s.titel AS title,
+ s.status,
+ s.deadline AS due_at,
+ s.created_at,
+ COALESCE(c.name, 'Ukendt kunde') AS customer_name,
+ NULL::text AS priority,
+ 'Sag deadline i dag' AS task_reason
+ FROM sag_sager s
+ LEFT JOIN customers c ON c.id = s.customer_id
+ WHERE s.deleted_at IS NULL
+ AND s.ansvarlig_bruger_id = %s
+ AND s.status <> 'lukket'
+ AND s.deadline = CURRENT_DATE
+
+ UNION ALL
+
+ SELECT
+ 'ticket' AS item_type,
+ t.id AS item_id,
+ t.subject AS title,
+ t.status,
+ NULL::date AS due_at,
+ t.created_at,
+ COALESCE(c.name, 'Ukendt kunde') AS customer_name,
+ COALESCE(t.priority, 'normal') AS priority,
+ 'Ticket oprettet i dag' AS task_reason
+ FROM tticket_tickets t
+ LEFT JOIN customers c ON c.id = t.customer_id
+ WHERE t.assigned_to_user_id = %s
+ AND t.status IN ('open', 'in_progress', 'pending_customer')
+ AND DATE(t.created_at) = CURRENT_DATE
+
+ ORDER BY created_at DESC
+ LIMIT 12
+ """
+ today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id))
+
+ urgent_overdue_query = """
+ SELECT
+ 'case' AS item_type,
+ s.id AS item_id,
+ s.titel AS title,
+ s.status,
+ s.deadline AS due_at,
+ s.created_at,
+ COALESCE(c.name, 'Ukendt kunde') AS customer_name,
+ NULL::text AS priority,
+ 'Over deadline' AS attention_reason
+ FROM sag_sager s
+ LEFT JOIN customers c ON c.id = s.customer_id
+ WHERE s.deleted_at IS NULL
+ AND s.status <> 'lukket'
+ AND s.deadline IS NOT NULL
+ AND s.deadline < CURRENT_DATE
+
+ UNION ALL
+
+ SELECT
+ 'ticket' AS item_type,
+ t.id AS item_id,
+ t.subject AS title,
+ t.status,
+ NULL::date AS due_at,
+ t.created_at,
+ COALESCE(c.name, 'Ukendt kunde') AS customer_name,
+ COALESCE(t.priority, 'normal') AS priority,
+ CASE
+ WHEN t.priority = 'urgent' THEN 'Urgent prioritet'
+ ELSE 'Høj prioritet'
+ END AS attention_reason
+ FROM tticket_tickets t
+ LEFT JOIN customers c ON c.id = t.customer_id
+ WHERE t.status IN ('open', 'in_progress', 'pending_customer')
+ AND COALESCE(t.priority, '') IN ('urgent', 'high')
+ AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL)
+
+ ORDER BY created_at DESC
+ LIMIT 15
+ """
+ urgent_overdue = execute_query(urgent_overdue_query, (technician_user_id,))
+
+ opportunities_query = """
+ SELECT
+ s.id,
+ s.titel,
+ s.status,
+ s.pipeline_amount,
+ s.pipeline_probability,
+ ps.name AS pipeline_stage,
+ s.deadline,
+ s.created_at,
+ COALESCE(c.name, 'Ukendt kunde') AS customer_name
+ FROM sag_sager s
+ LEFT JOIN customers c ON c.id = s.customer_id
+ LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
+ WHERE s.deleted_at IS NULL
+ AND s.ansvarlig_bruger_id = %s
+ AND (
+ s.template_key = 'pipeline'
+ OR EXISTS (
+ SELECT 1
+ FROM entity_tags et
+ JOIN tags t ON t.id = et.tag_id
+ WHERE et.entity_type = 'case'
+ AND et.entity_id = s.id
+ AND LOWER(t.name) = 'pipeline'
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM sag_tags st
+ WHERE st.sag_id = s.id
+ AND st.deleted_at IS NULL
+ AND LOWER(st.tag_navn) = 'pipeline'
+ )
+ )
+ ORDER BY s.created_at DESC
+ LIMIT 12
+ """
+ my_opportunities = execute_query(opportunities_query, (technician_user_id,))
+
+ return {
+ "technician_user_id": technician_user_id,
+ "technician_name": technician_name,
+ "new_cases": new_cases or [],
+ "my_cases": my_cases or [],
+ "today_tasks": today_tasks or [],
+ "urgent_overdue": urgent_overdue or [],
+ "my_opportunities": my_opportunities or [],
+ "kpis": {
+ "new_cases_count": len(new_cases or []),
+ "my_cases_count": len(my_cases or []),
+ "today_tasks_count": len(today_tasks or []),
+ "urgent_overdue_count": len(urgent_overdue or []),
+ "my_opportunities_count": len(my_opportunities or [])
+ }
+ }
+
+
+def _is_user_in_technician_group(user_id: int) -> bool:
+ groups_query = """
+ SELECT LOWER(g.name) AS group_name
+ FROM user_groups ug
+ JOIN groups g ON g.id = ug.group_id
+ WHERE ug.user_id = %s
+ """
+ groups = execute_query(groups_query, (user_id,)) or []
+ return any(
+ "teknik" in (g.get("group_name") or "") or "technician" in (g.get("group_name") or "")
+ for g in groups
+ )
+
+
+@router.get("/dashboard/technician", response_class=HTMLResponse)
+async def technician_dashboard_selector(
+ request: Request,
+ technician_user_id: int = 1
+):
+ """Technician dashboard landing page with 3 variants to choose from."""
+ try:
+ # Always use logged-in user, ignore query param
+ logged_in_user_id = getattr(request.state, "user_id", 1)
+ context = _get_technician_dashboard_data(logged_in_user_id)
+ return templates.TemplateResponse(
+ "ticket/frontend/technician_dashboard_selector.html",
+ {
+ "request": request,
+ **context
+ }
+ )
+ except Exception as e:
+ logger.error(f"❌ Failed to load technician dashboard selector: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/dashboard/technician/v1", response_class=HTMLResponse)
+async def technician_dashboard_v1(
+ request: Request,
+ technician_user_id: int = 1
+):
+ """Variant 1: KPI + card overview layout."""
+ try:
+ # Always use logged-in user, ignore query param
+ logged_in_user_id = getattr(request.state, "user_id", 1)
+ context = _get_technician_dashboard_data(logged_in_user_id)
+ return templates.TemplateResponse(
+ "ticket/frontend/mockups/tech_v1_overview.html",
+ {
+ "request": request,
+ **context
+ }
+ )
+ except Exception as e:
+ logger.error(f"❌ Failed to load technician dashboard v1: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/dashboard/technician/v2", response_class=HTMLResponse)
+async def technician_dashboard_v2(
+ request: Request,
+ technician_user_id: int = 1
+):
+ """Variant 2: Workboard columns by focus areas."""
+ try:
+ # Always use logged-in user, ignore query param
+ logged_in_user_id = getattr(request.state, "user_id", 1)
+ context = _get_technician_dashboard_data(logged_in_user_id)
+ return templates.TemplateResponse(
+ "ticket/frontend/mockups/tech_v2_workboard.html",
+ {
+ "request": request,
+ **context
+ }
+ )
+ except Exception as e:
+ logger.error(f"❌ Failed to load technician dashboard v2: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/dashboard/technician/v3", response_class=HTMLResponse)
+async def technician_dashboard_v3(
+ request: Request,
+ technician_user_id: int = 1
+):
+ """Variant 3: Dense table-first layout for power users."""
+ try:
+ # Always use logged-in user, ignore query param
+ logged_in_user_id = getattr(request.state, "user_id", 1)
+ context = _get_technician_dashboard_data(logged_in_user_id)
+ return templates.TemplateResponse(
+ "ticket/frontend/mockups/tech_v3_table_focus.html",
+ {
+ "request": request,
+ **context
+ }
+ )
+ except Exception as e:
+ logger.error(f"❌ Failed to load technician dashboard v3: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.get("/dashboard", response_class=HTMLResponse)
async def ticket_dashboard(request: Request):
"""
Ticket system dashboard with statistics
"""
try:
+ current_user_id = getattr(request.state, "user_id", None)
+ if current_user_id and _is_user_in_technician_group(int(current_user_id)):
+ return RedirectResponse(
+ url=f"/ticket/dashboard/technician/v1?technician_user_id={int(current_user_id)}",
+ status_code=302
+ )
+
# Get ticket statistics
stats_query = """
SELECT
@@ -530,34 +827,53 @@ async def archived_ticket_list_page(
status,
priority,
source_created_at,
- description
- FROM tticket_archived_tickets
+ description,
+ solution,
+ (
+ SELECT COUNT(*)
+ FROM tticket_archived_messages m
+ WHERE m.archived_ticket_id = t.id
+ ) AS message_count
+ FROM tticket_archived_tickets t
WHERE 1=1
"""
params = []
if search:
- query += " AND (ticket_number ILIKE %s OR title ILIKE %s OR description ILIKE %s)"
+ query += """
+ AND (
+ t.ticket_number ILIKE %s
+ OR t.title ILIKE %s
+ OR t.description ILIKE %s
+ OR t.solution ILIKE %s
+ OR EXISTS (
+ SELECT 1
+ FROM tticket_archived_messages m
+ WHERE m.archived_ticket_id = t.id
+ AND m.body ILIKE %s
+ )
+ )
+ """
search_pattern = f"%{search}%"
- params.extend([search_pattern] * 3)
+ params.extend([search_pattern] * 5)
if organization:
- query += " AND organization_name ILIKE %s"
+ query += " AND t.organization_name ILIKE %s"
params.append(f"%{organization}%")
if contact:
- query += " AND contact_name ILIKE %s"
+ query += " AND t.contact_name ILIKE %s"
params.append(f"%{contact}%")
if date_from:
- query += " AND source_created_at >= %s"
+ query += " AND t.source_created_at >= %s"
params.append(date_from)
if date_to:
- query += " AND source_created_at <= %s"
+ query += " AND t.source_created_at <= %s"
params.append(date_to)
- query += " ORDER BY source_created_at DESC NULLS LAST, imported_at DESC LIMIT 200"
+ query += " ORDER BY t.source_created_at DESC NULLS LAST, t.imported_at DESC LIMIT 200"
tickets = execute_query(query, tuple(params)) if params else execute_query(query)
diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py
index 77d266c..a92cb24 100644
--- a/app/timetracking/backend/router.py
+++ b/app/timetracking/backend/router.py
@@ -45,6 +45,7 @@ from app.services.customer_consistency import CustomerConsistencyService
from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService
from app.services.vtiger_service import get_vtiger_service
from app.ticket.backend.klippekort_service import KlippekortService
+from app.core.auth_dependencies import get_optional_user
logger = logging.getLogger(__name__)
@@ -1773,7 +1774,10 @@ async def get_time_entries_for_sag(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to fetch time entries")
@router.post("/entries/internal", tags=["Internal"])
-async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
+async def create_internal_time_entry(
+ entry: Dict[str, Any] = Body(...),
+ current_user: Optional[dict] = Depends(get_optional_user)
+):
"""
Create a time entry manually (Internal/Hub).
Requires: sag_id, original_hours
@@ -1786,7 +1790,12 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
description = entry.get("description")
hours = entry.get("original_hours")
worked_date = entry.get("worked_date") or datetime.now().date()
- user_name = entry.get("user_name", "Hub User")
+ default_user_name = (
+ (current_user or {}).get("username")
+ or (current_user or {}).get("full_name")
+ or "Hub User"
+ )
+ user_name = entry.get("user_name") or default_user_name
prepaid_card_id = entry.get("prepaid_card_id")
fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
work_type = entry.get("work_type", "support")
diff --git a/compare_schemas.py b/compare_schemas.py
index 5cee00a..ec9f661 100755
--- a/compare_schemas.py
+++ b/compare_schemas.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+ #!/usr/bin/env python3
"""
Compare local dev database schema with production to find missing columns
"""
diff --git a/main.py b/main.py
index 8a6fd97..f0d536b 100644
--- a/main.py
+++ b/main.py
@@ -90,6 +90,8 @@ from app.modules.telefoni.backend import router as telefoni_api
from app.modules.telefoni.frontend import views as telefoni_views
from app.modules.calendar.backend import router as calendar_api
from app.modules.calendar.frontend import views as calendar_views
+from app.modules.orders.backend import router as orders_api
+from app.modules.orders.frontend import views as orders_views
# Configure logging
logging.basicConfig(
@@ -118,6 +120,7 @@ async def lifespan(app: FastAPI):
# Register reminder scheduler job
from app.jobs.check_reminders import check_reminders
from apscheduler.triggers.interval import IntervalTrigger
+ from apscheduler.triggers.cron import CronTrigger
backup_scheduler.scheduler.add_job(
func=check_reminders,
@@ -129,6 +132,19 @@ async def lifespan(app: FastAPI):
)
logger.info("✅ Reminder job scheduled (every 5 minutes)")
+ # Register subscription invoice processing job
+ from app.jobs.process_subscriptions import process_subscriptions
+
+ backup_scheduler.scheduler.add_job(
+ func=process_subscriptions,
+ trigger=CronTrigger(hour=4, minute=0),
+ id='process_subscriptions',
+ name='Process Subscription Invoices',
+ max_instances=1,
+ replace_existing=True
+ )
+ logger.info("✅ Subscription invoice job scheduled (daily at 04:00)")
+
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
from app.jobs.eset_sync import run_eset_sync
@@ -293,6 +309,7 @@ app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"])
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
+app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
# Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"])
@@ -320,6 +337,7 @@ app.include_router(locations_views.router, tags=["Frontend"])
app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(telefoni_views.router, tags=["Frontend"])
app.include_router(calendar_views.router, tags=["Frontend"])
+app.include_router(orders_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
diff --git a/migrations/130_user_dashboard_preferences.sql b/migrations/130_user_dashboard_preferences.sql
new file mode 100644
index 0000000..c038caf
--- /dev/null
+++ b/migrations/130_user_dashboard_preferences.sql
@@ -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);
diff --git a/migrations/131_sag_assignment_group.sql b/migrations/131_sag_assignment_group.sql
new file mode 100644
index 0000000..e665dfd
--- /dev/null
+++ b/migrations/131_sag_assignment_group.sql
@@ -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);
diff --git a/migrations/132_subscription_cancellation.sql b/migrations/132_subscription_cancellation.sql
new file mode 100644
index 0000000..432327f
--- /dev/null
+++ b/migrations/132_subscription_cancellation.sql
@@ -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';
diff --git a/migrations/133_ordre_drafts.sql b/migrations/133_ordre_drafts.sql
new file mode 100644
index 0000000..77f3ffa
--- /dev/null
+++ b/migrations/133_ordre_drafts.sql
@@ -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();
diff --git a/migrations/134_subscription_billing_dates.sql b/migrations/134_subscription_billing_dates.sql
new file mode 100644
index 0000000..f514e82
--- /dev/null
+++ b/migrations/134_subscription_billing_dates.sql
@@ -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';
+
diff --git a/migrations/135_subscription_extended_intervals.sql b/migrations/135_subscription_extended_intervals.sql
new file mode 100644
index 0000000..cb855a4
--- /dev/null
+++ b/migrations/135_subscription_extended_intervals.sql
@@ -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';
diff --git a/migrations/136_simply_subscription_staging.sql b/migrations/136_simply_subscription_staging.sql
new file mode 100644
index 0000000..78a8de6
--- /dev/null
+++ b/migrations/136_simply_subscription_staging.sql
@@ -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();
diff --git a/move_time_section.py b/move_time_section.py
new file mode 100644
index 0000000..a7a89a9
--- /dev/null
+++ b/move_time_section.py
@@ -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
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 that closes left column)
+insert_index = None
+for i, line in enumerate(lines):
+ if i >= 2660 and i <= 2670:
+ if '' 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 '' 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 = '''
+
+
+
+
Tid & Fakturering
+
+ Åbn Fuld Formular
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ :
+
+
+
+
+
+
+
+
+
+ Tilføj Tid
+
+
+
+
+
+
+
+
+
+
+
Dato
+
Beskrivelse
+
Bruger
+
Timer
+
+
+
+ {% for entry in time_entries %}
+
+
{{ entry.worked_date }}
+
{{ entry.description or '-' }}
+
{{ entry.user_name }}
+
{{ entry.original_hours }}
+
+ {% else %}
+
+
Ingen tid registreret
+
+ {% endfor %}
+
+
+
+
+
+ {% if prepaid_cards %}
+
+
Klippekort
+
+ {% for card in prepaid_cards %}
+
+
+ #{{ card.card_number or card.id }}
+ {{ '%.2f' % card.remaining_hours }}t tilbage
+
+
+ {% endfor %}
+
+
+ {% endif%}
+
+
+
+
+
+'''
+
+# 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)")
diff --git a/static/js/telefoni.js b/static/js/telefoni.js
index 8b754fc..18038cc 100644
--- a/static/js/telefoni.js
+++ b/static/js/telefoni.js
@@ -65,6 +65,8 @@
const number = data.number || '';
const title = contact?.name ? contact.name : 'Ukendt nummer';
const company = contact?.company ? contact.company : '';
+ const recentCases = data.recent_cases || [];
+ const lastCall = data.last_call;
const callId = data.call_id;
@@ -78,6 +80,54 @@
? `Åbn kontakt`
: '';
+ // Build recent cases HTML
+ let casesHtml = '';
+ if (recentCases.length > 0) {
+ casesHtml = '