feat(planner): add overdue cases handling and improve case rendering

- Introduced `overdueCases` state to track overdue cases in the planner.
- Enhanced `loadCases` function to fetch and store overdue cases.
- Updated `renderCases` to display overdue cases with appropriate labels.
- Modified case card rendering to highlight overdue cases.

fix(telefoni): ensure WebSocket connection on DOMContentLoaded

- Adjusted WebSocket connection logic to handle cases where the script loads after DOMContentLoaded.

test(tests): add tests for case creation and internal ticket handling

- Added tests to ensure ticket creation commits all optional data.
- Implemented tests for internal ticket creation, ensuring correct customer and user assignments.

feat(migrations): add new tables for email configuration and case assignments

- Created `email_connection_configurations` table for managing email settings.
- Introduced `sag_case_assignees` table to handle individual case assignments.
- Added `sag_case_details` table for structured case type details.
- Updated existing case types to align with new templates and added support for internal tickets.

fix(timetracking): allow manual time creation without case customer

- Modified manual time creation to support entries without a customer.
- Updated settlement options to recommend internal settlement for customerless time.

feat(v4): enhance case detail template with new features

- Added new features to the case detail template, including compact icons and inline relation creation.
- Improved comment actions and time tracking functionalities.
This commit is contained in:
Christian 2026-09-20 03:33:10 +02:00
parent ee274a939a
commit abfb945679
28 changed files with 25317 additions and 211 deletions

View File

@ -221,7 +221,7 @@ class UserProfileUpdate(BaseModel):
def _allowed_case_types() -> list[str]:
fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]
fallback = ["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]
try:
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
if rows:
@ -229,7 +229,7 @@ def _allowed_case_types() -> list[str]:
configured = json.loads(rows[0].get("value") or "[]")
values = [str(value).strip().lower() for value in configured if str(value).strip()]
if values:
return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else [])))
return list(dict.fromkeys(values))
except Exception:
logger.warning("Could not load configured case types for profile preference", exc_info=True)
return fallback

View File

@ -229,7 +229,7 @@ def update_settings(payload: PlannerSettingsInput, current_user: dict = Depends(
def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str] = Query(default=[]),
case_type: list[str] = Query(default=[]), responsible_id: int | None = None,
department_id: int | None = None,
planning: Literal["all", "unplanned", "partial", "planned"] = "all", mine: bool = False,
planning: Literal["all", "unplanned", "partial", "planned", "overdue"] = "all", mine: bool = False,
urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority",
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
_require_view(current_user)
@ -239,7 +239,9 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str
COALESCE(s.estimated_minutes, 0) AS estimated_minutes,
COALESCE(used.used_minutes, 0) AS used_minutes,
used.latest_registration_at,
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes,
BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned,
MAX(a.ends_at) FILTER (WHERE a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE) AS overdue_planned_until
FROM sag_sager s JOIN customers c ON c.id=s.customer_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id
LEFT JOIN groups department ON department.id=s.assigned_group_id
@ -266,6 +268,7 @@ def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str
if planning == "unplanned": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0"
elif planning == "partial": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)<COALESCE(s.estimated_minutes,0)"
elif planning == "planned": sql += " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes"
elif planning == "overdue": sql += " HAVING BOOL_OR(a.deleted_at IS NULL AND a.ends_at < CURRENT_DATE)"
order = {"priority": "CASE s.priority::text WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, s.updated_at DESC",
"remaining": "(COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC",
"updated": "s.updated_at DESC"}[sort]
@ -290,6 +293,65 @@ def case_time_entries(sag_id: int, current_user: dict = Depends(get_current_user
return {"sag_id": sag_id, "total_minutes": sum(int(r.get("minutes") or 0) for r in rows), "items": rows}
@router.get("/cases/{sag_id}/allocations")
def case_allocations(sag_id: int, current_user: dict = Depends(get_current_user)):
"""Return the persisted plan for one case for the embedded case planner."""
_require_view(current_user)
case_row = execute_query_single(
"""SELECT s.id, s.titel, s.estimated_minutes, s.deferred_until,
s.deferred_until_case_id, s.deferred_until_status,
waiting.titel AS deferred_case_title, waiting.status AS deferred_case_current_status
FROM sag_sager s
LEFT JOIN sag_sager waiting ON waiting.id=s.deferred_until_case_id
WHERE s.id=%s AND s.deleted_at IS NULL""",
(sag_id,),
)
if not case_row:
raise HTTPException(404, "Sagen findes ikke")
rows = execute_query(
"""SELECT a.id, a.sag_id, a.user_id, a.starts_at, a.ends_at, a.note,
a.conflict_status, a.locked, a.version,
COALESCE(u.full_name,u.username,'Ukendt') AS user_name
FROM planner_allocations a
LEFT JOIN users u ON u.user_id=a.user_id
WHERE a.sag_id=%s AND a.deleted_at IS NULL
ORDER BY a.starts_at, a.id""",
(sag_id,),
) or []
planned_minutes = sum(_minutes(_aware(row["starts_at"]), _aware(row["ends_at"])) for row in rows)
subcases = execute_query(
"""SELECT child.id, child.titel, child.status, child.type,
child.ansvarlig_bruger_id, child.estimated_minutes,
child.deferred_until, child.deferred_until_case_id, child.deferred_until_status,
COALESCE(owner.full_name,owner.username) AS responsible_name,
waiting.titel AS deferred_case_title,
waiting.status AS deferred_case_current_status
FROM sag_relationer relation
JOIN sag_sager child ON child.id=relation.målsag_id AND child.deleted_at IS NULL
LEFT JOIN users owner ON owner.user_id=child.ansvarlig_bruger_id
LEFT JOIN sag_sager waiting ON waiting.id=child.deferred_until_case_id
WHERE relation.kilde_sag_id=%s AND LOWER(relation.relationstype)='undersag'
AND relation.deleted_at IS NULL
ORDER BY child.created_at, child.id""",
(sag_id,),
) or []
return {
"sag_id": sag_id,
"estimated_minutes": case_row.get("estimated_minutes"),
"planned_minutes": planned_minutes,
"remaining_minutes": max(0, int(case_row.get("estimated_minutes") or 0) - planned_minutes),
"deferred": {
"until": case_row.get("deferred_until"),
"case_id": case_row.get("deferred_until_case_id"),
"case_title": case_row.get("deferred_case_title"),
"case_status": case_row.get("deferred_case_current_status"),
"target_status": case_row.get("deferred_until_status"),
},
"subcases": subcases,
"items": rows,
}
@router.patch("/cases/{sag_id}/estimate")
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
permissions = _permissions(current_user)

View File

@ -7,6 +7,7 @@
.untimed-registers{position:absolute;left:28px;right:4px;bottom:4px;z-index:4;display:flex;flex-wrap:wrap;gap:4px;pointer-events:none}.untimed-register{max-width:100%;border:1px solid #75e0a7;border-left:3px solid #079455;border-radius:6px;background:#ecfdf3;color:#05603a;padding:3px 7px;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.planner-page{width:100%;max-width:2200px;padding:.75rem}.planner-head{margin-bottom:.75rem}.planner-layout{grid-template-columns:minmax(340px,400px) minmax(0,1fr);gap:.75rem;min-height:calc(100vh - 185px)}.calendar-panel{min-width:0}.panel-title{padding:.8rem}.case-list{padding:.6rem;gap:.5rem}.case-card{padding:.68rem}.case-card strong{display:block;line-height:1.3;overflow-wrap:anywhere}.case-card .customer{margin-top:.18rem}.week-grid{min-width:840px}
@media(max-width:1050px){.planner-layout{grid-template-columns:1fr}.case-panel{max-height:390px}.case-panel.collapsed .panel-title>:not(.panel-heading),.case-panel.collapsed .case-list{display:none}.case-panel.collapsed{max-height:58px}.collapse-cases{display:inline-block!important}}.collapse-cases{display:none;border:0;background:transparent}
.quick-filters button[data-quick=overdue]{border-color:#f5b4ae;color:#b42318;font-weight:700}.quick-filters button[data-quick=overdue].active{background:#fef3f2;color:#b42318;border-color:#f04438}.overdue-case-section{border:1px solid #f5b4ae;border-radius:10px;background:#fff7f6;padding:.65rem}.overdue-case-heading{display:flex;align-items:center;gap:.4rem;color:#b42318;font-size:.8rem;font-weight:800;margin-bottom:.5rem}.case-card.overdue-planned{border-color:#f5b4ae;border-left-color:#d92d20;background:#fffafa}.case-overdue-label{display:inline-flex;align-items:center;gap:.25rem;color:#b42318;font-size:.73rem;font-weight:750;margin-top:.4rem}
</style>
{% endblock %}
{% block content %}
@ -15,7 +16,7 @@
<div class="planner-toolbar"><div class="planner-toggle" id="scopeToggle"><button data-scope="mine" class="active">Min plan</button><button data-scope="team">Team</button></div><div class="planner-toggle" id="viewToggle"><button data-view="day">Dag</button><button data-view="week" class="active">Uge</button><button data-view="month">Måned</button></div></div>
</header>
<section class="planner-layout">
<aside class="case-panel" id="casePanel"><div class="panel-title"><div class="panel-heading d-flex justify-content-between"><strong>Sager til planlægning</strong><button class="collapse-cases" aria-label="Fold sagsliste"><i class="bi bi-chevron-up"></i></button></div><div class="case-search mt-2"><input id="caseSearch" placeholder="Sagsnr., kunde, titel eller ansvarlig…"><i class="bi bi-search position-absolute end-0 me-2 mt-2 text-muted"></i></div><div class="filter-row"><select id="statusFilter"><option value="">Alle statusser</option><option>åben</option><option>i_gang</option><option>on_hold</option></select><select id="priorityFilter"><option value="">Alle prioriteter</option><option value="urgent">Haster</option><option value="high">Høj</option><option value="normal">Normal</option><option value="low">Lav</option></select><select id="typeFilter"><option value="">Alle sagstyper</option></select><select id="responsibleFilter"><option value="">Alle ansvarlige</option></select><select id="departmentFilter"><option value="">Alle afdelinger</option></select><select id="sortFilter"><option value="priority">Prioritet</option><option value="remaining">Resterende tid</option><option value="updated">Senest ændret</option></select></div><div class="quick-filters"><button data-quick="unplanned" class="active">Ikke planlagt</button><button data-quick="partial">Delvist planlagt</button><button data-quick="mine">Mine sager</button><button data-quick="urgent">Haster</button></div></div><div class="case-list" id="caseList"><div class="empty">Henter sager…</div></div></aside>
<aside class="case-panel" id="casePanel"><div class="panel-title"><div class="panel-heading d-flex justify-content-between"><strong>Sager til planlægning</strong><button class="collapse-cases" aria-label="Fold sagsliste"><i class="bi bi-chevron-up"></i></button></div><div class="case-search mt-2"><input id="caseSearch" placeholder="Sagsnr., kunde, titel eller ansvarlig…"><i class="bi bi-search position-absolute end-0 me-2 mt-2 text-muted"></i></div><div class="filter-row"><select id="statusFilter"><option value="">Alle statusser</option><option>åben</option><option>i_gang</option><option>on_hold</option></select><select id="priorityFilter"><option value="">Alle prioriteter</option><option value="urgent">Haster</option><option value="high">Høj</option><option value="normal">Normal</option><option value="low">Lav</option></select><select id="typeFilter"><option value="">Alle sagstyper</option></select><select id="responsibleFilter"><option value="">Alle ansvarlige</option></select><select id="departmentFilter"><option value="">Alle afdelinger</option></select><select id="sortFilter"><option value="priority">Prioritet</option><option value="remaining">Resterende tid</option><option value="updated">Senest ændret</option></select></div><div class="quick-filters"><button data-quick="overdue">Overskredet plan</button><button data-quick="unplanned" class="active">Ikke planlagt</button><button data-quick="partial">Delvist planlagt</button><button data-quick="mine">Mine sager</button><button data-quick="urgent">Haster</button></div></div><div class="case-list" id="caseList"><div class="empty">Henter sager…</div></div></aside>
<section class="calendar-panel"><div class="calendar-top"><div class="planner-toolbar"><button id="prevPeriod" aria-label="Forrige"><i class="bi bi-chevron-left"></i></button><button id="todayBtn">I dag</button><button id="nextPeriod" aria-label="Næste"><i class="bi bi-chevron-right"></i></button><span class="period-label" id="periodLabel"></span></div><div class="capacity-legend"><span><i class="dot green"></i>Ledig</span><span><i class="dot yellow"></i>Næsten fuld</span><span><i class="dot red"></i>Fuld/overbooket</span><span><i class="dot gray"></i>Fri/fravær</span></div></div><div class="timeline" id="timeline"></div></section>
</section>
</main>
@ -23,4 +24,4 @@
<dialog class="allocation-dialog" id="timeDialog"><form method="dialog"><div class="dialog-body"><small class="text-muted">Faktisk registreret tid</small><h5 id="timeDialogTitle"></h5><div id="timeDialogTotal" class="mb-3 fw-semibold"></div><div id="timeEntryList" class="d-flex flex-column gap-2"></div></div><div class="dialog-actions"><button value="cancel" class="btn btn-light">Luk</button><a id="timeDialogCaseLink" class="btn btn-primary">Åbn sag</a></div></form></dialog>
<div class="toast-stack" id="toastStack"></div>
{% endblock %}
{% block extra_js %}<script src="/static/js/planner.js?v=5"></script>{% endblock %}
{% block extra_js %}<script src="/static/js/planner.js?v=7"></script>{% endblock %}

View File

@ -108,74 +108,33 @@ def _ensure_case_internet_connection_tag(sag_id: int) -> None:
def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = []
seen = set()
def _add_status(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
allowed_statuses.append(candidate)
try:
setting_row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("case_statuses",))
if setting_row and setting_row.get("value"):
parsed = json.loads(setting_row.get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
else:
value = ""
_add_status(value)
except Exception:
pass
# Include historical/current DB statuses so legacy values remain valid
try:
rows = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in rows:
_add_status(row.get("status"))
except Exception:
pass
if not allowed_statuses:
allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
allowed_map = {s.lower(): s for s in allowed_statuses}
open_aliases = {"åben", "open", "under behandling", "afventer", "i_gang", "on_hold"}
closed_aliases = {"lukket", "closed", "løst", "afsluttet", "resolved", "done"}
open_default = allowed_map.get("åben", allowed_statuses[0])
closed_default = allowed_map.get("lukket", allowed_map.get("løst", open_default))
if not status_value:
return open_default
normalized = str(status_value).strip().lower()
if normalized in allowed_map:
return allowed_map[normalized]
if normalized in open_aliases:
return open_default
if normalized in closed_aliases:
return closed_default
# Backward compatibility for legacy mapping
if normalized == "afventer" and "åben" in allowed_map:
return open_default
# Do not force unknown values back to default; preserve user-entered/custom DB values
raw_value = str(status_value).strip()
if raw_value:
return raw_value
return open_default
canonical = {
"ny": "Ny",
"tildelt": "Tildelt",
"igangværende": "Igangværende",
"afventer": "Afventer",
"løst": "Løst",
"kunde svaret": "Kunde svaret",
"lukket": "Lukket",
}
legacy_aliases = {
"åben": "Ny",
"open": "Ny",
"under behandling": "Igangværende",
"i_gang": "Igangværende",
"in progress": "Igangværende",
"on_hold": "Afventer",
"resolved": "Løst",
"closed": "Lukket",
"afsluttet": "Lukket",
"done": "Lukket",
}
normalized = str(status_value or "Ny").strip().lower()
if normalized in canonical:
return canonical[normalized]
if normalized in legacy_aliases:
return legacy_aliases[normalized]
raise HTTPException(status_code=400, detail="Ugyldig status. Vælg en af de faste sagsstatuser.")
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
@ -442,6 +401,12 @@ class SagListPreferencesUpdate(BaseModel):
hidden_columns: Optional[List[str]] = None
class CaseAssignmentsUpdate(BaseModel):
group_id: Optional[int] = None
user_ids: List[int] = Field(default_factory=list)
primary_user_id: Optional[int] = None
SAG_LIST_COLUMN_KEYS = (
"id", "company", "contact", "description", "type", "priority", "status",
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
@ -1022,23 +987,49 @@ async def create_sag(request: Request, data: dict):
try:
if not data.get("titel"):
raise HTTPException(status_code=400, detail="titel is required")
if not data.get("customer_id"):
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
customer_required_types = {"ticket", "salg", "bogholderi", "projekt", "bug_report", "service", "pipeline", "ordre", "opgave", "abonnement"}
if case_type == "intern_ticket":
# Internal work always belongs to BMC Networks and is owned by its creator.
data["customer_id"] = 8
data["customer_id"] = _coerce_optional_int(data.get("customer_id"), "customer_id")
if case_type in customer_required_types and not data.get("customer_id"):
raise HTTPException(status_code=400, detail="customer_id is required")
_validate_customer_id(data.get("customer_id"))
status = _normalize_case_status(data.get("status"))
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
current_user_id = _get_user_id_from_request(request)
raw_responsible = data.get("ansvarlig_bruger_id") if "ansvarlig_bruger_id" in data else current_user_id
if case_type == "intern_ticket":
raw_responsible = current_user_id
ansvarlig_bruger_id = _coerce_optional_int(raw_responsible, "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)
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
pipeline = data.get("pipeline")
order_items = data.get("order_items", [])
type_details = data.get("type_details") or {}
if not isinstance(type_details, dict):
raise HTTPException(status_code=400, detail="type_details skal være et objekt")
if len(type_details) > 20:
raise HTTPException(status_code=400, detail="For mange typefelter")
normalized_type_details = {}
for key, value in type_details.items():
if not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", str(key)):
raise HTTPException(status_code=400, detail="Ugyldigt typefeltnavn")
if value is None:
continue
if not isinstance(value, (str, int, float, bool)):
raise HTTPException(status_code=400, detail="Ugyldig værdi i typefelt")
normalized = str(value).strip()
if len(normalized) > 10000:
raise HTTPException(status_code=400, detail="Et typefelt er for langt")
if normalized:
normalized_type_details[str(key)] = normalized
raw_contact_ids = data.get("contact_ids") or []
if not isinstance(raw_contact_ids, list):
raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
@ -1132,12 +1123,12 @@ async def create_sag(request: Request, data: dict):
cursor.execute(
"""
INSERT INTO sag_sager
(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,
(titel, beskrivelse, template_key, type, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status,
pipeline_amount, pipeline_probability, pipeline_stage_id, pipeline_description)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
(data.get("titel"), data.get("beskrivelse", ""), case_type, case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
assigned_group_id, current_user_id, deadline, deferred_until, data.get("deferred_until_case_id"),
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
)
@ -1145,6 +1136,34 @@ async def create_sag(request: Request, data: dict):
if not result:
raise HTTPException(status_code=500, detail="Failed to create case")
if normalized_type_details:
cursor.execute(
"""INSERT INTO sag_case_details (sag_id, details, created_by_user_id, updated_by_user_id)
VALUES (%s, %s::jsonb, %s, %s)""",
(result["id"], json.dumps(normalized_type_details), current_user_id, current_user_id),
)
anydesk_id = normalized_type_details.get("anydesk_id")
if case_type == "ticket" and anydesk_id:
if len(anydesk_id) > 64 or not re.fullmatch(r"[0-9][0-9 .-]*", anydesk_id):
raise HTTPException(status_code=400, detail="Ugyldigt AnyDesk-ID")
cursor.execute(
"""INSERT INTO sag_anydesk_ids (sag_id, anydesk_id, is_primary, created_by_user_id)
VALUES (%s, %s, TRUE, %s)
ON CONFLICT (sag_id, anydesk_id) DO UPDATE SET deleted_at=NULL, is_primary=TRUE, updated_at=NOW()""",
(result["id"], anydesk_id, current_user_id),
)
cursor.execute("UPDATE sag_anydesk_ids SET is_primary=FALSE WHERE sag_id=%s AND anydesk_id<>%s AND deleted_at IS NULL", (result["id"], anydesk_id))
solution = normalized_type_details.get("solution")
if case_type == "ticket" and solution:
cursor.execute(
"""INSERT INTO sag_solutions (sag_id, title, description, solution_type, result, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (sag_id) DO NOTHING""",
(result["id"], data.get("titel"), solution, "case_solution", "draft", current_user_id),
)
if contact_ids:
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
@ -1555,6 +1574,83 @@ async def set_case_module_pref(sag_id: int, data: dict):
# SAG TODO STEPS
# =========================================================================
@router.get("/sag/{sag_id}/assignments", dependencies=[Depends(case_edit_access)])
async def get_case_assignments(sag_id: int, group_id: Optional[int] = Query(None)):
"""Return selected employees and, when a group is chosen, its available members."""
case_row = execute_query_single(
"SELECT id, assigned_group_id, ansvarlig_bruger_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL",
(sag_id,),
)
if not case_row:
raise HTTPException(status_code=404, detail="Case not found")
effective_group_id = group_id if group_id is not None else case_row.get("assigned_group_id")
selected = execute_query(
"""
SELECT u.user_id, COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS display_name
FROM sag_case_assignees a JOIN users u ON u.user_id=a.user_id
WHERE a.sag_id=%s ORDER BY display_name
""",
(sag_id,),
) or []
members = []
if effective_group_id:
_validate_group_id(effective_group_id)
members = execute_query(
"""
SELECT u.user_id, COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS display_name
FROM user_groups ug JOIN users u ON u.user_id=ug.user_id
WHERE ug.group_id=%s AND u.is_active=true ORDER BY display_name
""",
(effective_group_id,),
) or []
return {
"group_id": effective_group_id,
"primary_user_id": case_row.get("ansvarlig_bruger_id"),
"selected_user_ids": [row["user_id"] for row in selected],
"members": members,
}
@router.put("/sag/{sag_id}/assignments", dependencies=[Depends(case_edit_access)])
async def save_case_assignments(sag_id: int, payload: CaseAssignmentsUpdate, request: Request):
"""Persist every selected employee as its own assignment row."""
case_row = execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,))
if not case_row:
raise HTTPException(status_code=404, detail="Case not found")
user_ids = list(dict.fromkeys(payload.user_ids))
if payload.group_id is None:
if user_ids:
raise HTTPException(status_code=400, detail="Vælg en gruppe før medarbejdere tildeles")
else:
_validate_group_id(payload.group_id)
group_members = execute_query(
"SELECT user_id FROM user_groups WHERE group_id=%s", (payload.group_id,)
) or []
permitted_user_ids = {row["user_id"] for row in group_members}
invalid_user_ids = [user_id for user_id in user_ids if user_id not in permitted_user_ids]
if invalid_user_ids:
raise HTTPException(status_code=400, detail="Alle valgte medarbejdere skal være medlem af den valgte gruppe")
primary_user_id = payload.primary_user_id
if primary_user_id is not None and primary_user_id not in user_ids:
raise HTTPException(status_code=400, detail="Primær ansvarlig skal være blandt de valgte medarbejdere")
if primary_user_id is None and user_ids:
primary_user_id = user_ids[0]
user_id = _get_user_id_from_request(request)
execute_query("DELETE FROM sag_case_assignees WHERE sag_id=%s", (sag_id,))
for assignee_id in user_ids:
execute_query(
"INSERT INTO sag_case_assignees (sag_id, user_id, assigned_by_user_id) VALUES (%s, %s, %s)",
(sag_id, assignee_id, user_id),
)
execute_query(
"UPDATE sag_sager SET assigned_group_id=%s, ansvarlig_bruger_id=%s WHERE id=%s",
(payload.group_id, primary_user_id, sag_id),
)
return await get_case_assignments(sag_id, payload.group_id)
@router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
async def list_todo_steps(sag_id: int):
try:
@ -1725,6 +1821,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"""Update a case."""
try:
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
awaiting_todo = updates.pop("awaiting_todo", None)
# Check if case exists
check = execute_query(
"""
@ -1743,13 +1840,45 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
# Backwards compatibility: frontend sends "type", DB stores "template_key"
if "type" in updates and "template_key" not in updates:
updates["template_key"] = updates.get("type")
if "template_key" in updates:
updates["type"] = updates["template_key"]
if str(updates.get("template_key") or "").strip().lower() == "intern_ticket":
# Keep the same invariant when an existing case is changed to Internal ticket.
updates["customer_id"] = 8
updates["ansvarlig_bruger_id"] = _get_user_id_from_request(request)
if "status" in updates:
updates["status"] = _normalize_case_status(updates.get("status"))
closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"}
closing_statuses = {"lukket"}
new_status = str(updates.get("status") or "").strip().lower()
case_type = str(previous_row.get("template_key") or "").strip().lower()
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
if new_status in closing_statuses and previous_status not in closing_statuses:
unfinished_time = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM tmodule_times
WHERE sag_id = %s
AND (
slut_tid IS NULL
OR COALESCE(
entry_status,
CASE WHEN status IN ('approved', 'billed') THEN 'godkendt' ELSE 'afventer' END
) <> 'godkendt'
)
""",
(sag_id,),
) or {}
unfinished_count = int(unfinished_time.get("count") or 0)
if unfinished_count:
raise HTTPException(
status_code=409,
detail={
"code": "case_close_blocked_unfinished_time",
"message": f"Sagen kan ikke lukkes, fordi {unfinished_count} tidsregistrering(er) stadig er aktive, i kladde eller afventer godkendelse.",
},
)
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
has_registered_time = execute_query_single(
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
@ -1763,6 +1892,26 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
},
)
is_awaiting_transition = new_status.startswith("afventer") and not previous_status.startswith("afventer")
if is_awaiting_transition:
if not isinstance(awaiting_todo, dict):
raise HTTPException(
status_code=400,
detail="En TODO med hvad der afventes er påkrævet, før sagen kan sættes til Afventer",
)
todo_title = str(awaiting_todo.get("title") or "").strip()
if len(todo_title) < 3:
raise HTTPException(
status_code=400,
detail="TODO-titlen skal beskrive, hvad der afventes",
)
awaiting_todo = {
"title": todo_title,
"description": str(awaiting_todo.get("description") or "").strip() or None,
"due_date": awaiting_todo.get("due_date") or None,
}
else:
is_awaiting_transition = False
if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates:
@ -1805,6 +1954,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"titel",
"beskrivelse",
"template_key",
"type",
"status",
"customer_id",
"ansvarlig_bruger_id",
@ -1833,6 +1983,28 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
set_sql = ", ".join(set_clauses)
query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *"
# Create the follow-up before changing status: an awaiting case must never
# be persisted without its required next action.
if is_awaiting_transition and awaiting_todo:
try:
todo_user_id = _get_user_id_from_request(request)
execute_query(
"""
INSERT INTO sag_todo_steps (sag_id, title, description, due_date, created_by_user_id, is_next)
VALUES (%s, %s, %s, %s, %s, true)
""",
(
sag_id,
awaiting_todo["title"],
awaiting_todo["description"],
awaiting_todo["due_date"],
todo_user_id,
),
)
except Exception as todo_error:
logger.error("Could not create mandatory awaiting TODO for case %s: %s", sag_id, todo_error)
raise HTTPException(status_code=500, detail="TODO kunne ikke oprettes; status er ikke ændret")
result = execute_query(query, tuple(params))
if result:
updated_row = result[0] or {}
@ -1869,6 +2041,12 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
type_labels = {
"ticket": "Ticket",
"salg": "Salg",
"bogholderi": "Bogholderi",
"admin": "Admin",
"postit": "Post-it",
"intern_ticket": "Intern ticket",
"bug_report": "Bug_report",
"pipeline": "Pipeline",
"opgave": "Opgave",
"ordre": "Ordre",
@ -1973,6 +2151,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
class BeskrivelsePatch(BaseModel):
beskrivelse: str
append: bool = False
@router.patch("/sag/{sag_id}/beskrivelse", dependencies=[Depends(case_edit_access)])
@ -1987,8 +2166,6 @@ async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: R
raise HTTPException(status_code=404, detail="Case not found")
old_beskrivelse = row.get("beskrivelse")
new_beskrivelse = body.beskrivelse
# Resolve acting user (may be None for anonymous)
user_id = _get_user_id_from_request(request)
changed_by_name = None
@ -2000,6 +2177,18 @@ async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: R
if u:
changed_by_name = u["name"]
if body.append:
existing_beskrivelse = str(old_beskrivelse or "").rstrip()
addition = body.beskrivelse.strip()
name_parts = re.findall(r"[A-Za-zÆØÅæøåÀ-ÖØ-öø-ÿ0-9]+", str(changed_by_name or "Bruger"))
initials = "".join(part[0].upper() for part in name_parts[:2]) or "BR"
append_date = datetime.now().strftime("%d/%m")
safe_addition = html.escape(addition).replace("\n", "<br>")
append_fragment = f"<hr><h6>{initials}/{append_date}</h6><p>{safe_addition}</p>"
new_beskrivelse = f"{existing_beskrivelse}\n{append_fragment}" if existing_beskrivelse else append_fragment
else:
new_beskrivelse = body.beskrivelse
# Write history entry
execute_query(
"""INSERT INTO sag_beskrivelse_history
@ -2458,12 +2647,12 @@ async def list_case_customers(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to list case customers")
@router.post("/sag/{sag_id}/customer/replace")
async def replace_case_customer(sag_id: int, payload: dict):
@router.post("/sag/{sag_id}/customer/replace", dependencies=[Depends(case_edit_access)])
async def replace_case_customer(sag_id: int, payload: dict, request: Request):
"""Replace the primary customer on a case and keep relations synchronized."""
try:
existing_case = execute_query_single(
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
"SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not existing_case:
@ -2475,6 +2664,10 @@ async def replace_case_customer(sag_id: int, payload: dict):
_validate_customer_id(customer_id)
old_customer_id = existing_case.get("customer_id")
old_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (old_customer_id,)) if old_customer_id else None
new_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (customer_id,))
if table_has_column("sag_sager", "customer_id"):
execute_query(
"UPDATE sag_sager SET customer_id = %s WHERE id = %s",
@ -2517,6 +2710,32 @@ async def replace_case_customer(sag_id: int, payload: dict):
(sag_id, customer_id, "Kunde"),
)
if old_customer_id != customer_id:
try:
changed_by = "System"
try:
user_id = _get_user_id_from_request(request)
user = execute_query_single(
"SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''), CONCAT('Bruger #', user_id::text)) AS name FROM users WHERE user_id = %s",
(user_id,),
)
changed_by = str((user or {}).get("name") or changed_by)
except Exception:
pass
execute_query(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, true)
""",
(
sag_id,
changed_by,
f"🏢 Firma ændret: {(old_customer or {}).get('name') or 'Ingen kunde'} → {(new_customer or {}).get('name') or f'Kunde #{customer_id}'}",
),
)
except Exception as exc:
logger.warning("Could not write customer change history for case %s: %s", sag_id, exc)
logger.info("✅ Primary customer replaced for case %s -> customer %s", sag_id, customer_id)
return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id}
except HTTPException:
@ -5708,8 +5927,12 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest, request: Req
failure_detail = "Email send failed (provider returned generic error; check Graph app permissions and API logs)"
raise HTTPException(status_code=500, detail=f"{failure_detail} [diag:sag-send-v3]")
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
sender_name = email_service.smtp_config.get("from_name") or "BMC Hub"
sender_email = (
email_service.graph_config.get("user_email")
if email_service._graph_send_available()
else email_service.smtp_config.get("from_address")
) or ""
provider_thread_key_normalized = _normalize_message_id_token(provider_thread_key)
# Keep replies in the existing case thread when we already know the target thread.

View File

@ -159,47 +159,7 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
def _fetch_case_status_options() -> list[str]:
default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
values = []
seen = set()
def _add(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
values.append(candidate)
setting_row = execute_query(
"SELECT value FROM settings WHERE key = %s",
("case_statuses",)
)
if setting_row and setting_row[0].get("value"):
try:
parsed = json.loads(setting_row[0].get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
value = ""
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
_add(value)
except Exception:
pass
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in statuses:
_add(row.get("status"))
for default in default_statuses:
_add(default)
return values
return ["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "Lukket"]
def _fetch_closed_case_statuses() -> list[str]:
@ -571,8 +531,8 @@ async def sag_varekob_salg(request: Request):
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
async def sag_detaljer(request: Request, sag_id: int):
"""Redirect legacy case details URL to v3."""
return RedirectResponse(url=f"/sag/{sag_id}/v3", status_code=307)
"""Redirect the unversioned case URL to the active V4 page."""
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
"""Display case details."""
try:
@ -1000,8 +960,9 @@ async def sag_detaljer(request: Request, sag_id: int):
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
@router.get("/sag/{sag_id}/v4", response_class=HTMLResponse)
async def sag_detaljer_v3(request: Request, sag_id: int):
"""Display case details."""
"""Display the active V4 case page; /v3 remains as a compatible alias."""
try:
# Fetch main case
sag_query = """
@ -1339,7 +1300,11 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
response = templates.TemplateResponse("modules/sag/templates/detail_v3.html", {
# V4 is the only active case design. Keep /v3 routable because many
# existing links and bookmarks still use it, but render the same V4
# template so users cannot accidentally fall back to the old design.
template_name = "modules/sag/templates/detail_v4.html"
response = templates.TemplateResponse(template_name, {
"request": request,
"case": sag,
"customer": customer,

View File

@ -45,9 +45,15 @@ class RelationService:
s.status,
s.template_key,
COALESCE(s.template_key, 'ticket') AS type,
s.ansvarlig_bruger_id,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
s.assigned_group_id,
g.name AS gruppe_navn,
next_todo.title AS next_todo_title,
next_todo.due_date AS next_todo_due_date
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
LEFT JOIN LATERAL (
SELECT t.title, t.due_date
FROM sag_todo_steps t

View File

@ -245,7 +245,10 @@
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
.cc-page #caseTypeHelp { display:none; }
.cc-page #caseTypeHelp { display:block; }
.cc-page .card-custom.cc-postit-mode { background:#fff5a8; border:1px solid #e6c74d; box-shadow:8px 10px 22px rgba(112,87,8,.2); }
.cc-page .cc-postit-panel { background:#fffbe0; border-color:#dfbd37; }
.cc-page .cc-postit-panel summary { background:#fff1a8; }
.cc-page #cc-types { gap:7px; }
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
@ -286,11 +289,6 @@
<div class="col-12">
<!-- Main Card -->
<div class="card card-custom">
<div class="card-header-custom">
<div><span class="cc-eyebrow">ET GODT STED AT STARTE</span><h1>Opret en ny sag</h1><p class="mb-0">Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.</p></div>
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span>
</div>
<div class="card-body p-4">
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
@ -375,6 +373,9 @@
<hr class="my-4 opacity-25">
<!-- Fields that belong to the selected case type are rendered here. -->
<section id="typeDetailsSection" class="d-none"><div id="typeDetailsFields"></div></section>
<!-- Section: Hardware & AnyDesk -->
<section id="hardwareSection">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
@ -437,9 +438,9 @@
<hr class="my-4 opacity-25">
<!-- 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">Ansvarlig</h5>
<div class="row g-4 mb-4">
<div class="col-md-4">
<div class="col-md-4 case-type-extra">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" id="status" required>
<option value="åben" selected>🟢 Åben</option>
@ -457,7 +458,7 @@
</select>
</div>
<div class="col-md-4">
<div class="col-md-4 case-type-extra">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id">
<option value="">Ingen</option>
@ -468,7 +469,7 @@
</div>
</div>
<div class="row g-4 mb-4">
<div class="row g-4 mb-4 case-type-extra">
<div class="col-md-4">
<label for="deadline" class="form-label">Deadline</label>
<div class="input-group">
@ -1329,20 +1330,22 @@
}
const caseTypeLabels = {
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service',
abonnement: '🔁 Abonnement'
ticket: '🎫 Ticket', salg: '💼 Salg', bogholderi: '🧾 Bogholderi',
admin: '⚙️ Admin', projekt: '📁 Projekt', postit: '🗒️ Post-it',
intern_ticket: '🔒 Intern ticket', bug_report: '🐞 Bug_report',
pipeline: '📈 Pipeline', opgave: '🧩 Opgave', ordre: '🧾 Ordre',
service: '🛠️ Service', abonnement: '🔁 Abonnement'
};
function updateCaseTypeSections() {
function updateCaseTypeSections(showTypeFields = false) {
const type = document.getElementById('type')?.value || 'ticket';
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
window.caseCreateUI?.typeChanged(type);
window.caseCreateUI?.typeChanged(type, showTypeFields);
const help = document.getElementById('caseTypeHelp');
if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.'
if (help && !showTypeFields) help.textContent = 'Vælg sagstype for at åbne de relevante felter.';
else if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket.'
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
: 'Tilføj hardware, pipeline, tags og køb/salg efter behov.';
: 'Tilføj de relevante oplysninger til sagen.';
}
function renderOrderLinesEmptyState() {
@ -1417,28 +1420,29 @@
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
const profile = profileRes.ok ? await profileRes.json() : {};
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) && configured.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels);
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
if (!types.includes('abonnement')) types.push('abonnement');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
const configuredTypes = Array.isArray(configured) ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : [];
const supportedTypes = ['ticket', 'salg', 'bogholderi', 'admin', 'projekt', 'postit', 'intern_ticket', 'bug_report'];
const finalTypes = supportedTypes;
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
} catch (err) {
console.error('Failed to load case types', err);
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
}
updateCaseTypeSections();
updateCaseTypeSections(false);
}
async function selectCurrentUserAsResponsible() {
const select = document.getElementById('ansvarlig_bruger_id');
if (!select || select.value) return;
if (!select) return;
try {
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!response.ok) return;
const user = await response.json();
const userId = user.user_id ?? user.id;
if (userId && Array.from(select.options).some(option => option.value === String(userId))) {
window.caseCreateCurrentUserId = userId || null;
const isInternalTicket = document.getElementById('type')?.value === 'intern_ticket';
if (userId && (isInternalTicket || !select.value) && Array.from(select.options).some(option => option.value === String(userId))) {
select.value = String(userId);
}
} catch (error) {
@ -1450,7 +1454,10 @@
document.addEventListener('DOMContentLoaded', async () => {
initializeSearch();
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
// Both the selected type and current user are now known. Reapply the
// type defaults so Intern ticket cannot display a stale assignee.
updateCaseTypeSections(false);
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(true));
await applyTelefoniPrefill();
await window.caseCreateUI?.init();
});
@ -1586,5 +1593,5 @@
}
});
</script>
<script src="/static/js/case-create.js?v=5"></script>
<script src="/static/js/case-create.js?v=6"></script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -34,8 +34,9 @@ from aiohttp import ClientSession, BasicAuth
import msal
from app.core.config import settings
from app.core.crypto import decrypt_secret
from app.services.system_event_logger import log_system_event
from app.core.database import execute_query, execute_insert, execute_update
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
logger = logging.getLogger(__name__)
@ -60,6 +61,78 @@ class EmailService:
'client_secret': settings.GRAPH_CLIENT_SECRET,
'user_email': settings.GRAPH_USER_EMAIL
}
self.smtp_config = {
'host': settings.EMAIL_SMTP_HOST,
'port': settings.EMAIL_SMTP_PORT,
'username': settings.EMAIL_SMTP_USER,
'password': settings.EMAIL_SMTP_PASSWORD,
'use_tls': settings.EMAIL_SMTP_USE_TLS,
'from_address': settings.EMAIL_SMTP_FROM_ADDRESS,
'from_name': settings.EMAIL_SMTP_FROM_NAME,
}
self._apply_saved_connection()
def _apply_saved_connection(self) -> None:
"""Use the one Settings mailbox when enabled, otherwise keep environment compatibility."""
try:
row = execute_query_single(
"SELECT * FROM email_connection_configurations WHERE is_enabled = true ORDER BY id DESC LIMIT 1"
)
except Exception as exc:
# Older installations can run before migration 1035. Their .env setup remains authoritative.
logger.debug("Email connection settings unavailable; using environment configuration: %s", exc)
return
if not row:
return
raw_secrets = decrypt_secret(str(row.get('secrets_encrypted') or ''))
if not raw_secrets:
logger.error("Enabled email connection has no readable credentials; using environment configuration")
return
try:
saved_secrets = json.loads(raw_secrets)
except (TypeError, json.JSONDecodeError):
logger.error("Enabled email connection has invalid encrypted credentials; using environment configuration")
return
mailbox = str(row.get('mailbox_address') or '').strip()
if row.get('provider') == 'microsoft365_graph':
required = ('graph_tenant_id', 'graph_client_id', 'graph_client_secret')
if not all((row.get('graph_tenant_id'), row.get('graph_client_id'), saved_secrets.get('graph_client_secret'))):
logger.error("Enabled Microsoft 365 email connection is incomplete; using environment configuration")
return
self.use_graph = True
self.graph_config = {
'tenant_id': row.get('graph_tenant_id'),
'client_id': row.get('graph_client_id'),
'client_secret': saved_secrets['graph_client_secret'],
'user_email': mailbox,
}
return
if not all((row.get('imap_host'), row.get('smtp_host'), saved_secrets.get('imap_password'), saved_secrets.get('smtp_password'))):
logger.error("Enabled IMAP/SMTP email connection is incomplete; using environment configuration")
return
self.use_graph = False
self.imap_config = {
'server': row.get('imap_host'),
'port': row.get('imap_port') or 993,
'username': mailbox,
'password': saved_secrets['imap_password'],
'use_ssl': bool(row.get('imap_use_ssl')),
'folder': row.get('imap_folder') or 'INBOX',
'readonly': True,
}
# Outbound SMTP is read by the sender further down via settings. Keep config local as well.
self.smtp_config = {
'host': row.get('smtp_host'),
'port': row.get('smtp_port') or 587,
'username': mailbox,
'password': saved_secrets['smtp_password'],
'use_tls': bool(row.get('smtp_use_tls')),
'from_address': mailbox,
'from_name': row.get('sender_name') or 'BMC Hub',
}
def _graph_send_available(self) -> bool:
return bool(
@ -1789,7 +1862,7 @@ class EmailService:
return False, "aiosmtplib not installed"
# Validate SMTP configuration
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
if not all([self.smtp_config['host'], self.smtp_config['username'], self.smtp_config['password']]):
logger.error("❌ SMTP not configured - cannot send email")
if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured"
@ -1799,7 +1872,7 @@ class EmailService:
# Build message
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>"
msg['From'] = f"{self.smtp_config['from_name']} <{self.smtp_config['from_address']}>"
msg['To'] = ', '.join(to_addresses)
if cc:
@ -1817,11 +1890,11 @@ class EmailService:
# Send via SMTP
async with aiosmtplib.SMTP(
hostname=settings.EMAIL_SMTP_HOST,
port=settings.EMAIL_SMTP_PORT,
use_tls=settings.EMAIL_SMTP_USE_TLS
hostname=self.smtp_config['host'],
port=self.smtp_config['port'],
use_tls=self.smtp_config['use_tls']
) as smtp:
await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD)
await smtp.login(self.smtp_config['username'], self.smtp_config['password'])
# Combine all recipients
all_recipients = to_addresses.copy()
@ -1831,7 +1904,7 @@ class EmailService:
all_recipients.extend(bcc)
await smtp.sendmail(
settings.EMAIL_SMTP_FROM_ADDRESS,
self.smtp_config['from_address'],
all_recipients,
msg.as_string()
)
@ -1928,7 +2001,7 @@ class EmailService:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: aiosmtplib not installed", generated_message_id, provider_thread_key
return False, "aiosmtplib not installed", generated_message_id, provider_thread_key
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
if not all([self.smtp_config['host'], self.smtp_config['username'], self.smtp_config['password']]):
logger.error("❌ SMTP not configured - cannot send email")
if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured", generated_message_id, provider_thread_key
@ -1937,7 +2010,7 @@ class EmailService:
try:
msg = MIMEMultipart('mixed')
msg['Subject'] = subject
msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>"
msg['From'] = f"{self.smtp_config['from_name']} <{self.smtp_config['from_address']}>"
msg['To'] = ', '.join(to_addresses)
msg['Message-ID'] = generated_message_id
@ -1974,11 +2047,11 @@ class EmailService:
msg.attach(mime_attachment)
async with aiosmtplib.SMTP(
hostname=settings.EMAIL_SMTP_HOST,
port=settings.EMAIL_SMTP_PORT,
use_tls=settings.EMAIL_SMTP_USE_TLS
hostname=self.smtp_config['host'],
port=self.smtp_config['port'],
use_tls=self.smtp_config['use_tls']
) as smtp:
await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD)
await smtp.login(self.smtp_config['username'], self.smtp_config['password'])
all_recipients = to_addresses.copy()
if cc:
@ -1987,7 +2060,7 @@ class EmailService:
all_recipients.extend(bcc)
await smtp.sendmail(
settings.EMAIL_SMTP_FROM_ADDRESS,
self.smtp_config['from_address'],
all_recipients,
msg.as_string()
)

View File

@ -3,16 +3,18 @@ Settings and User Management API Router
"""
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query
from typing import List, Optional, Dict
from typing import List, Optional, Dict, Literal
from pydantic import BaseModel
from datetime import datetime, timezone
from app.core.database import execute_query, execute_query_single
from app.core.config import settings
from app.core.auth_dependencies import require_any_permission, require_superadmin
from app.core.auth_service import AuthService
from app.core.crypto import decrypt_secret, encrypt_secret
import argparse
import asyncio
import httpx
import imaplib
import time
import logging
import json
@ -20,6 +22,7 @@ import os
import re
import threading
from pathlib import Path
from urllib.parse import quote
logger = logging.getLogger(__name__)
router = APIRouter()
@ -75,6 +78,52 @@ class AIBenchmarkRunRequest(BaseModel):
test_keys: Optional[List[str]] = None
class EmailConnectionUpdate(BaseModel):
provider: Literal["microsoft365_graph", "imap_smtp"]
is_enabled: bool = False
mailbox_address: str
sender_name: str = "BMC Hub"
imap_host: Optional[str] = ""
imap_port: int = 993
imap_folder: str = "INBOX"
imap_use_ssl: bool = True
smtp_host: Optional[str] = ""
smtp_port: int = 587
smtp_use_tls: bool = True
graph_tenant_id: Optional[str] = ""
graph_client_id: Optional[str] = ""
imap_password: Optional[str] = None
smtp_password: Optional[str] = None
graph_client_secret: Optional[str] = None
def _email_connection_public(row: Optional[Dict]) -> Dict:
"""Return connection metadata only; encrypted credentials never leave the server."""
if not row:
return {"configured": False, "source": "environment"}
encrypted = str(row.get("secrets_encrypted") or "").strip()
return {
"configured": True,
"source": "settings",
"provider": row["provider"],
"is_enabled": bool(row["is_enabled"]),
"mailbox_address": row["mailbox_address"],
"sender_name": row.get("sender_name") or "BMC Hub",
"imap_host": row.get("imap_host") or "",
"imap_port": row.get("imap_port") or 993,
"imap_folder": row.get("imap_folder") or "INBOX",
"imap_use_ssl": bool(row.get("imap_use_ssl")),
"smtp_host": row.get("smtp_host") or "",
"smtp_port": row.get("smtp_port") or 587,
"smtp_use_tls": bool(row.get("smtp_use_tls")),
"graph_tenant_id": row.get("graph_tenant_id") or "",
"graph_client_id": row.get("graph_client_id") or "",
"has_saved_credentials": bool(encrypted),
"inbound_processing_enabled": bool(settings.EMAIL_TO_TICKET_ENABLED),
"updated_at": row.get("updated_at"),
}
MATTERMOST_SETTING_DEFAULTS = (
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
@ -273,6 +322,177 @@ async def create_ai_benchmark_run(
# Settings Endpoints
@router.get("/settings/email-connection", tags=["Settings"])
async def get_email_connection(current_user: dict = Depends(require_superadmin)):
"""Read mailbox setup metadata without exposing any password or client secret."""
row = execute_query_single(
"SELECT * FROM email_connection_configurations ORDER BY id DESC LIMIT 1"
)
if row:
return _email_connection_public(row)
return {
"configured": False,
"source": "environment",
"provider": "microsoft365_graph" if settings.USE_GRAPH_API else "imap_smtp",
"is_enabled": bool(
settings.GRAPH_USER_EMAIL if settings.USE_GRAPH_API else settings.IMAP_USERNAME
),
"mailbox_address": settings.GRAPH_USER_EMAIL if settings.USE_GRAPH_API else settings.IMAP_USERNAME,
"sender_name": settings.EMAIL_SMTP_FROM_NAME or "BMC Hub",
"has_saved_credentials": bool(
settings.GRAPH_CLIENT_SECRET if settings.USE_GRAPH_API else settings.IMAP_PASSWORD
),
"inbound_processing_enabled": bool(settings.EMAIL_TO_TICKET_ENABLED),
}
@router.put("/settings/email-connection", tags=["Settings"])
async def save_email_connection(
payload: EmailConnectionUpdate,
current_user: dict = Depends(require_superadmin),
):
"""Save one encrypted mailbox configuration used for both inbound and outbound mail."""
mailbox = payload.mailbox_address.strip().lower()
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", mailbox):
raise HTTPException(status_code=400, detail="Indtast en gyldig postkasseadresse")
if not (1 <= payload.imap_port <= 65535 and 1 <= payload.smtp_port <= 65535):
raise HTTPException(status_code=400, detail="Port skal være mellem 1 og 65535")
existing = execute_query_single(
"SELECT * FROM email_connection_configurations ORDER BY id DESC LIMIT 1"
)
prior_secrets = (existing or {}).get("secrets_encrypted")
saved_secrets = {}
if prior_secrets:
decoded = decrypt_secret(str(prior_secrets))
if decoded:
try:
saved_secrets = json.loads(decoded)
except (TypeError, json.JSONDecodeError):
raise HTTPException(status_code=500, detail="De gemte e-mailadgangsoplysninger kan ikke læses. Indtast dem igen.")
supplied_secrets = {
key: value.strip()
for key, value in {
"imap_password": payload.imap_password,
"smtp_password": payload.smtp_password,
"graph_client_secret": payload.graph_client_secret,
}.items()
if value is not None and value.strip()
}
saved_secrets.update(supplied_secrets)
secrets_encrypted = prior_secrets
if supplied_secrets or (prior_secrets and not saved_secrets):
try:
secrets_encrypted = encrypt_secret(json.dumps(saved_secrets))
except ValueError:
raise HTTPException(
status_code=503,
detail="Sikker lagring er ikke klar. Sæt NEXTCLOUD_ENCRYPTION_KEY på serveren før adgangsoplysninger gemmes.",
)
if payload.provider == "microsoft365_graph":
if payload.is_enabled and not (payload.graph_tenant_id.strip() and payload.graph_client_id.strip() and saved_secrets.get("graph_client_secret")):
raise HTTPException(status_code=400, detail="Microsoft 365 kræver Tenant ID, Application ID og client secret før aktivering")
elif payload.is_enabled and not (
payload.imap_host.strip() and payload.smtp_host.strip()
and saved_secrets.get("imap_password") and saved_secrets.get("smtp_password")
):
raise HTTPException(status_code=400, detail="IMAP/SMTP kræver begge servere og adgangskoder før aktivering")
user_id = current_user.get("id") or current_user.get("user_id")
values = (
payload.provider, payload.is_enabled, mailbox, payload.sender_name.strip() or "BMC Hub",
payload.imap_host.strip() or None, payload.imap_port, payload.imap_folder.strip() or "INBOX", payload.imap_use_ssl,
payload.smtp_host.strip() or None, payload.smtp_port, payload.smtp_use_tls,
payload.graph_tenant_id.strip() or None, payload.graph_client_id.strip() or None,
secrets_encrypted, user_id,
)
if existing:
query = """
UPDATE email_connection_configurations SET
provider=%s, is_enabled=%s, mailbox_address=%s, sender_name=%s,
imap_host=%s, imap_port=%s, imap_folder=%s, imap_use_ssl=%s,
smtp_host=%s, smtp_port=%s, smtp_use_tls=%s,
graph_tenant_id=%s, graph_client_id=%s, secrets_encrypted=%s,
updated_by_user_id=%s, updated_at=CURRENT_TIMESTAMP
WHERE id=%s RETURNING *
"""
result = execute_query(query, (*values, existing["id"]))
else:
query = """
INSERT INTO email_connection_configurations
(provider, is_enabled, mailbox_address, sender_name, imap_host, imap_port, imap_folder, imap_use_ssl,
smtp_host, smtp_port, smtp_use_tls, graph_tenant_id, graph_client_id, secrets_encrypted, updated_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *
"""
result = execute_query(query, values)
if not result:
raise HTTPException(status_code=500, detail="Kunne ikke gemme e-mailopsætningen")
return _email_connection_public(result[0])
@router.post("/settings/email-connection/test", tags=["Settings"])
async def test_email_connection(current_user: dict = Depends(require_superadmin)):
"""Verify authentication and inbox access without sending or changing any email."""
from app.services.email_service import EmailService, HAS_AIOSMTPLIB, aiosmtplib
configured = execute_query_single(
"SELECT provider, is_enabled FROM email_connection_configurations ORDER BY id DESC LIMIT 1"
)
if not configured or not configured.get("is_enabled"):
raise HTTPException(status_code=400, detail="Gem og aktivér en e-mailopsætning før du tester")
service = EmailService()
if configured["provider"] == "microsoft365_graph":
token = await service._get_graph_access_token()
if not token:
raise HTTPException(status_code=502, detail="Microsoft 365 kunne ikke udstede et adgangstoken. Tjek Tenant ID, Application ID og client secret.")
mailbox = service.graph_config.get("user_email")
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(
f"https://graph.microsoft.com/v1.0/users/{quote(mailbox)}/mailFolders/inbox?$top=1",
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code >= 400:
raise HTTPException(status_code=502, detail="Microsoft 365-token virker, men postkassen kan ikke læses. Tjek Mail.ReadWrite og admin consent.")
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Kunne ikke kontakte Microsoft Graph: {exc}")
return {"ok": True, "provider": "Microsoft 365 / Graph", "message": "Token og læseadgang til Indbakke virker. Der er ikke sendt en e-mail."}
def test_imap() -> None:
config = service.imap_config
connection = imaplib.IMAP4_SSL(config["server"], config["port"], timeout=15) if config["use_ssl"] else imaplib.IMAP4(config["server"], config["port"], timeout=15)
try:
connection.login(config["username"], config["password"])
status, _ = connection.select(config["folder"], readonly=True)
if status != "OK":
raise RuntimeError(f"kunne ikke åbne mappen {config['folder']}")
finally:
try:
connection.logout()
except Exception:
pass
try:
await asyncio.to_thread(test_imap)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"IMAP-test fejlede: {exc}")
if not HAS_AIOSMTPLIB:
raise HTTPException(status_code=503, detail="IMAP virker, men SMTP-test er ikke mulig fordi aiosmtplib mangler på serveren")
try:
config = service.smtp_config
async with aiosmtplib.SMTP(
hostname=config["host"], port=config["port"], use_tls=config["use_tls"], timeout=15
) as smtp:
await smtp.login(config["username"], config["password"])
except Exception as exc:
raise HTTPException(status_code=502, detail=f"SMTP-test fejlede: {exc}")
return {"ok": True, "provider": "IMAP + SMTP", "message": "IMAP-indbakke og SMTP-login virker. Der er ikke sendt en e-mail."}
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
async def get_settings(category: Optional[str] = None):
"""Get all settings or filter by category"""
@ -375,7 +595,7 @@ async def get_setting(key: str):
seed_query,
(
"case_types",
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service", "abonnement"]',
'["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]',
"system",
"Sags-typer",
"json",

View File

@ -171,6 +171,9 @@
<a class="nav-link" href="#notifications" data-tab="notifications">
<i class="bi bi-bell me-2"></i>Notifikationer
</a>
<a class="nav-link" href="#email" data-tab="email">
<i class="bi bi-envelope-at me-2"></i>E-mail
</a>
<a class="nav-link" href="#users" data-tab="users">
<i class="bi bi-people me-2"></i>Brugere
</a>
@ -604,6 +607,84 @@
</div>
</div>
<!-- Email connection -->
<div class="tab-pane fade" id="email">
<div class="card p-4 mb-4 border-0 shadow-sm">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<h5 class="fw-bold mb-1">Postkasse til sager</h5>
<p class="text-muted mb-0">Den valgte postkasse bruges både til at hente indgående mails og sende svar fra BMC Hub.</p>
</div>
<span class="badge text-bg-secondary" id="emailConnectionState">Indlæser…</span>
</div>
<div class="alert alert-info small mt-4 mb-0">
<i class="bi bi-shield-lock me-2"></i>Adgangskoder og client secrets krypteres på serveren og vises aldrig igen. Lad et hemmeligt felt stå tomt for at beholde den gemte værdi.
</div>
<div class="alert alert-warning small mt-3 mb-0 d-none" id="emailInboundDisabled">
<i class="bi bi-exclamation-triangle me-2"></i>Automatisk hentning af indgående mails er slået fra på serveren. Opsætningen kan gemmes, men <code>EMAIL_TO_TICKET_ENABLED=true</code> skal aktiveres ved deployment, før nye mails hentes automatisk.
</div>
</div>
<div class="card p-4 mb-4">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label" for="emailProvider">Udbyder</label>
<select class="form-select" id="emailProvider" onchange="toggleEmailProviderFields()">
<option value="microsoft365_graph">Microsoft 365 / Office 365 (Graph – anbefalet)</option>
<option value="imap_smtp">IMAP + SMTP (Google, webhotel eller anden udbyder)</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="emailMailboxAddress">Postkasseadresse</label>
<input type="email" class="form-control" id="emailMailboxAddress" placeholder="support@firma.dk" autocomplete="email">
</div>
<div class="col-md-3">
<label class="form-label" for="emailSenderName">Afsendernavn</label>
<input type="text" class="form-control" id="emailSenderName" placeholder="BMC Support">
</div>
</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" id="emailConnectionEnabled">
<label class="form-check-label" for="emailConnectionEnabled">Brug denne postkasse nu</label>
</div>
</div>
<div class="card p-4 mb-4" id="emailGraphFields">
<h6 class="fw-bold mb-1">Microsoft 365 via Microsoft Graph</h6>
<p class="small text-muted mb-3">Robust løsning til en delt postkasse eller en rigtig support-postkasse. BMC Hub bruger den samme adresse ind og ud.</p>
<div class="row g-3">
<div class="col-md-6"><label class="form-label" for="emailGraphTenantId">Directory (tenant) ID</label><input class="form-control" id="emailGraphTenantId" autocomplete="off"></div>
<div class="col-md-6"><label class="form-label" for="emailGraphClientId">Application (client) ID</label><input class="form-control" id="emailGraphClientId" autocomplete="off"></div>
<div class="col-md-6"><label class="form-label" for="emailGraphClientSecret">Client secret</label><input type="password" class="form-control" id="emailGraphClientSecret" autocomplete="new-password"><div class="form-text">Vises kun ved indtastning; en gemt secret kan ikke læses ud igen.</div></div>
</div>
</div>
<div class="card p-4 mb-4 d-none" id="emailImapFields">
<h6 class="fw-bold mb-3">IMAP til modtagelse og SMTP til afsendelse</h6>
<div class="row g-3">
<div class="col-md-5"><label class="form-label" for="emailImapHost">IMAP-server</label><input class="form-control" id="emailImapHost" placeholder="imap.firma.dk"></div>
<div class="col-md-2"><label class="form-label" for="emailImapPort">Port</label><input type="number" min="1" max="65535" class="form-control" id="emailImapPort" value="993"></div>
<div class="col-md-3"><label class="form-label" for="emailImapFolder">Mappe</label><input class="form-control" id="emailImapFolder" value="INBOX"></div>
<div class="col-md-2 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" id="emailImapSsl" checked><label class="form-check-label" for="emailImapSsl">SSL/TLS</label></div></div>
<div class="col-md-6"><label class="form-label" for="emailImapPassword">IMAP-adgangskode / app-adgangskode</label><input type="password" class="form-control" id="emailImapPassword" autocomplete="new-password"></div>
<div class="col-md-5"><label class="form-label" for="emailSmtpHost">SMTP-server</label><input class="form-control" id="emailSmtpHost" placeholder="smtp.firma.dk"></div>
<div class="col-md-2"><label class="form-label" for="emailSmtpPort">Port</label><input type="number" min="1" max="65535" class="form-control" id="emailSmtpPort" value="587"></div>
<div class="col-md-3 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" id="emailSmtpTls" checked><label class="form-check-label" for="emailSmtpTls">STARTTLS/TLS</label></div></div>
<div class="col-md-6"><label class="form-label" for="emailSmtpPassword">SMTP-adgangskode / app-adgangskode</label><input type="password" class="form-control" id="emailSmtpPassword" autocomplete="new-password"></div>
</div>
</div>
<div class="d-flex align-items-center gap-3 mb-5 flex-wrap"><button class="btn btn-primary" type="button" onclick="saveEmailConnection(this)"><i class="bi bi-shield-check me-2"></i>Gem e-mailopsætning</button><button class="btn btn-outline-primary" type="button" onclick="testEmailConnection(this)"><i class="bi bi-plug me-2"></i>Test forbindelse</button><span class="small" id="emailConnectionFeedback"></span></div>
<div class="card p-4">
<h5 class="fw-bold mb-3">Opsætningsguide</h5>
<div class="accordion" id="emailSetupGuide">
<div class="accordion-item"><h2 class="accordion-header"><button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#emailGuideM365">Microsoft 365 / Office 365 (anbefalet)</button></h2><div id="emailGuideM365" class="accordion-collapse collapse show" data-bs-parent="#emailSetupGuide"><div class="accordion-body small"><ol class="mb-0"><li>Opret eller vælg postkassen, fx <code>support@firma.dk</code>. Ved delt postkasse skal den kunne sende/modtage som adressen.</li><li>Gå til <strong>Microsoft Entra ID → App registrations → New registration</strong> og opret appen.</li><li>Kopiér <strong>Directory (tenant) ID</strong> og <strong>Application (client) ID</strong> ind her.</li><li>Under <strong>Certificates &amp; secrets</strong> opret en client secret og indsæt dens <em>Value</em> med det samme.</li><li>Under <strong>API permissions → Microsoft Graph → Application permissions</strong>: tilføj <code>Mail.ReadWrite</code> og <code>Mail.Send</code>, og vælg <strong>Grant admin consent</strong>.</li><li>Gem og aktivér først derefter postkassen her. Begræns gerne appen til den valgte postkasse med Exchange application access policy.</li></ol></div></div></div>
<div class="accordion-item"><h2 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#emailGuideGoogle">Google Workspace / Gmail</button></h2><div id="emailGuideGoogle" class="accordion-collapse collapse" data-bs-parent="#emailSetupGuide"><div class="accordion-body small"><ol class="mb-0"><li>Vælg <strong>IMAP + SMTP</strong> ovenfor.</li><li>Aktivér IMAP på postkassen i Gmail/Workspace, hvis administratoren tillader det.</li><li>Slå 2-trinsbekræftelse til på postkassen og opret en <strong>app-adgangskode</strong> til Mail. Brug aldrig den normale Google-adgangskode.</li><li>Indtast <code>imap.gmail.com</code>, port <code>993</code>, SSL/TLS og <code>smtp.gmail.com</code>, port <code>587</code>, STARTTLS.</li><li>Brug postkasseadressen som afsender og samme app-adgangskode i begge hemmelige felter. Gem og aktivér.</li></ol></div></div></div>
<div class="accordion-item"><h2 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#emailGuideImap">Anden IMAP/SMTP-udbyder</button></h2><div id="emailGuideImap" class="accordion-collapse collapse" data-bs-parent="#emailSetupGuide"><div class="accordion-body small"><ol class="mb-0"><li>Find IMAP- og SMTP-server, port og sikkerhedskrav hos udbyderen.</li><li>Typiske værdier er IMAP <code>993</code> med SSL/TLS og SMTP <code>587</code> med STARTTLS.</li><li>Opret helst en særskilt app-adgangskode eller servicepostkasse med kun nødvendig adgang.</li><li>Indtast oplysningerne, gem og aktivér. BMC Hub ændrer ikke eller sletter mails via denne opsætning.</li></ol></div></div></div>
</div>
</div>
</div>
<!-- Email Templates -->
<div class="tab-pane fade" id="email-templates">
<div class="card border-0 shadow-sm mb-4">
@ -5501,6 +5582,105 @@ async function openAIBenchmarkRun(runId) {
if (!['completed','failed'].includes(run.status)) pollAIBenchmarkRun(run.id);
}
function emailField(id) { return document.getElementById(id); }
function toggleEmailProviderFields() {
const graph = emailField('emailProvider').value === 'microsoft365_graph';
emailField('emailGraphFields').classList.toggle('d-none', !graph);
emailField('emailImapFields').classList.toggle('d-none', graph);
}
function setEmailConnectionFeedback(message, type = 'muted') {
const root = emailField('emailConnectionFeedback');
root.className = `small text-${type}`;
root.textContent = message;
}
async function loadEmailConnection() {
try {
const response = await fetch('/api/v1/settings/email-connection', {credentials: 'include'});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke hente e-mailopsætningen'));
const data = await response.json();
emailField('emailProvider').value = data.provider || 'microsoft365_graph';
emailField('emailConnectionEnabled').checked = Boolean(data.is_enabled);
emailField('emailMailboxAddress').value = data.mailbox_address || '';
emailField('emailSenderName').value = data.sender_name || 'BMC Hub';
emailField('emailGraphTenantId').value = data.graph_tenant_id || '';
emailField('emailGraphClientId').value = data.graph_client_id || '';
emailField('emailImapHost').value = data.imap_host || '';
emailField('emailImapPort').value = data.imap_port || 993;
emailField('emailImapFolder').value = data.imap_folder || 'INBOX';
emailField('emailImapSsl').checked = data.imap_use_ssl !== false;
emailField('emailSmtpHost').value = data.smtp_host || '';
emailField('emailSmtpPort').value = data.smtp_port || 587;
emailField('emailSmtpTls').checked = data.smtp_use_tls !== false;
emailField('emailConnectionState').className = `badge text-bg-${data.is_enabled ? 'success' : 'secondary'}`;
emailField('emailConnectionState').textContent = data.is_enabled ? `Aktiv: ${data.mailbox_address}` : (data.configured ? 'Gemt, ikke aktiv' : 'Miljøopsætning / ikke konfigureret');
setEmailConnectionFeedback(data.has_saved_credentials ? 'Adgangsoplysninger er gemt sikkert.' : 'Ingen adgangsoplysninger gemt i Indstillinger.', 'muted');
emailField('emailInboundDisabled').classList.toggle('d-none', Boolean(data.inbound_processing_enabled));
toggleEmailProviderFields();
} catch (error) {
setEmailConnectionFeedback(error.message || 'Kunne ikke hente e-mailopsætningen', 'danger');
emailField('emailConnectionState').textContent = 'Kunne ikke indlæse';
}
}
async function saveEmailConnection(button) {
const provider = emailField('emailProvider').value;
const payload = {
provider,
is_enabled: emailField('emailConnectionEnabled').checked,
mailbox_address: emailField('emailMailboxAddress').value.trim(),
sender_name: emailField('emailSenderName').value.trim(),
graph_tenant_id: emailField('emailGraphTenantId').value.trim(),
graph_client_id: emailField('emailGraphClientId').value.trim(),
graph_client_secret: emailField('emailGraphClientSecret').value,
imap_host: emailField('emailImapHost').value.trim(),
imap_port: Number(emailField('emailImapPort').value),
imap_folder: emailField('emailImapFolder').value.trim(),
imap_use_ssl: emailField('emailImapSsl').checked,
imap_password: emailField('emailImapPassword').value,
smtp_host: emailField('emailSmtpHost').value.trim(),
smtp_port: Number(emailField('emailSmtpPort').value),
smtp_use_tls: emailField('emailSmtpTls').checked,
smtp_password: emailField('emailSmtpPassword').value,
};
button.disabled = true;
setEmailConnectionFeedback('Gemmer …', 'muted');
try {
const response = await fetch('/api/v1/settings/email-connection', {
method: 'PUT', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke gemme e-mailopsætningen'));
emailField('emailGraphClientSecret').value = '';
emailField('emailImapPassword').value = '';
emailField('emailSmtpPassword').value = '';
setEmailConnectionFeedback('E-mailopsætningen er gemt. Nye mails bruger den aktive postkasse.', 'success');
await loadEmailConnection();
} catch (error) {
setEmailConnectionFeedback(error.message || 'Kunne ikke gemme e-mailopsætningen', 'danger');
} finally {
button.disabled = false;
}
}
async function testEmailConnection(button) {
button.disabled = true;
setEmailConnectionFeedback('Tester login og læseadgang — der sendes ingen e-mail …', 'muted');
try {
const response = await fetch('/api/v1/settings/email-connection/test', {
method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: '{}',
});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Forbindelsestesten fejlede'));
const result = await response.json();
setEmailConnectionFeedback(result.message || 'Forbindelsen virker.', 'success');
} catch (error) {
setEmailConnectionFeedback(error.message || 'Forbindelsestesten fejlede', 'danger');
} finally {
button.disabled = false;
}
}
// Tab navigation
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
link.addEventListener('click', (e) => {
@ -5534,6 +5714,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
loadTaskTemplates();
} else if (tab === 'telefoni') {
renderTelefoniSettings();
} else if (tab === 'email') {
loadEmailConnection();
} else if (tab === 'mission') {
renderMissionSettings();
} else if (tab === 'ai-prompts') {

View File

@ -2004,7 +2004,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/tag-picker.js?v=2.3"></script>
<script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.5"></script>
<script src="/static/js/telefoni.js?v=2.6"></script>
<script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.6"></script>
{% include "shared/frontend/internal_message.html" %}

View File

@ -194,6 +194,16 @@ def _assert_prepaid_entry_editable(entry: Dict[str, Any]) -> None:
def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
"""Return valid settlement choices and a deterministic recommendation."""
if customer_id is None:
return {
"recommended": {
"method": "internal",
"reason": "Sagen har ingen kunde og registreres derfor som intern tid",
},
"prepaid_cards": [],
"agreements": [],
}
customer = execute_query_single(
"SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s",
(customer_id,),
@ -2410,11 +2420,6 @@ async def start_live_timer_v1(
raise HTTPException(status_code=400, detail="sag_id is required")
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
if customer_id is None:
raise HTTPException(
status_code=400,
detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.",
)
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
if not bruger_id:
@ -2453,7 +2458,7 @@ async def start_live_timer_v1(
user_name = payload.get("user_name") or default_user_name
entry_type = payload.get("entry_type") or "manuel"
kilde = payload.get("kilde") or "manuel"
billable = bool(payload.get("fakturerbar", True))
billable = customer_id is not None and bool(payload.get("fakturerbar", True))
round_block_min = int(payload.get("round_block_min") or 30)
created = execute_query(
@ -2880,11 +2885,6 @@ async def create_manual_time_v1(
raise HTTPException(status_code=400, detail="sag_id is required")
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
if customer_id is None:
raise HTTPException(
status_code=400,
detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.",
)
requested_user_ids = payload.get("medarbejder_ids")
is_bulk_request = requested_user_ids is not None
@ -2921,11 +2921,13 @@ async def create_manual_time_v1(
raise HTTPException(status_code=400, detail="faktisk_tid_min or original_hours must be > 0")
round_block_min = int(payload.get("round_block_min") or 30)
billable = bool(payload.get("fakturerbar", True))
billable = customer_id is not None and bool(payload.get("fakturerbar", True))
billable_minutes = payload.get("fakturerbar_tid_min")
if billable_minutes is None:
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
billable_minutes = int(billable_minutes)
if not billable:
billable_minutes = 0
worked_date = payload.get("worked_date")
if not worked_date:

View File

@ -1,4 +1,4 @@
# Sikkert fakturaudtræk med layoutanalyse og AI
$# Sikkert fakturaudtræk med layoutanalyse og AI
## Status

View File

@ -0,0 +1,30 @@
-- Central, encrypted configuration for the mailbox BMC Hub reads and sends from.
-- Secrets are stored as a Fernet-encrypted JSON blob and are never returned by the API.
CREATE TABLE IF NOT EXISTS email_connection_configurations (
id SERIAL PRIMARY KEY,
provider VARCHAR(30) NOT NULL CHECK (provider IN ('microsoft365_graph', 'imap_smtp')),
is_enabled BOOLEAN NOT NULL DEFAULT false,
mailbox_address VARCHAR(320) NOT NULL,
sender_name VARCHAR(255) NOT NULL DEFAULT 'BMC Hub',
imap_host VARCHAR(255),
imap_port INTEGER NOT NULL DEFAULT 993,
imap_folder VARCHAR(255) NOT NULL DEFAULT 'INBOX',
imap_use_ssl BOOLEAN NOT NULL DEFAULT true,
smtp_host VARCHAR(255),
smtp_port INTEGER NOT NULL DEFAULT 587,
smtp_use_tls BOOLEAN NOT NULL DEFAULT true,
graph_tenant_id VARCHAR(255),
graph_client_id VARCHAR(255),
secrets_encrypted TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by_user_id INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_connection_singleton
ON email_connection_configurations ((1));
DROP TRIGGER IF EXISTS email_connection_configurations_updated_at_trigger ON email_connection_configurations;
CREATE TRIGGER email_connection_configurations_updated_at_trigger
BEFORE UPDATE ON email_connection_configurations
FOR EACH ROW EXECUTE FUNCTION update_settings_updated_at();

View File

@ -0,0 +1,12 @@
-- Explicit employee assignments for a case. A group is only a picker/context;
-- each selected employee has a separate row.
CREATE TABLE IF NOT EXISTS sag_case_assignees (
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
assigned_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
PRIMARY KEY (sag_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_sag_case_assignees_user
ON sag_case_assignees(user_id, sag_id);

View File

@ -0,0 +1,13 @@
-- Stores the structured fields shown by the dynamic new-case templates.
-- Kept separately so legacy case payloads and sag_sager remain unchanged.
CREATE TABLE IF NOT EXISTS sag_case_details (
sag_id INTEGER PRIMARY KEY REFERENCES sag_sager(id) ON DELETE CASCADE,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
updated_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sag_case_details_details
ON sag_case_details USING GIN (details);

View File

@ -0,0 +1,7 @@
-- Keep the legacy type column aligned with template_key.
-- Some V4 components still consume type while newer flows use template_key.
UPDATE sag_sager
SET type = template_key
WHERE template_key IS NOT NULL
AND BTRIM(template_key) <> ''
AND type IS DISTINCT FROM template_key;

View File

@ -0,0 +1,17 @@
-- Canonical case types shown when creating and editing V4 cases.
-- Existing cases keep their stored type values for backwards compatibility.
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (
'case_types',
'["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]',
'system',
'Sags-typer',
'json',
true
)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
description = EXCLUDED.description,
value_type = EXCLUDED.value_type,
is_public = EXCLUDED.is_public,
updated_at = CURRENT_TIMESTAMP;

View File

@ -0,0 +1,6 @@
-- Internal work, including Post-it cases, may be registered before a customer
-- is attached. Customer-specific settlement is selected only after a customer
-- exists, so the time row itself must allow a null customer reference.
ALTER TABLE tmodule_times
ALTER COLUMN customer_id DROP NOT NULL;

View File

@ -0,0 +1,22 @@
-- Internal tickets have a fixed, canonical customer identity.
-- Never overwrite another customer if an installation already uses ID 8.
DO $$
DECLARE
existing_name TEXT;
BEGIN
SELECT name INTO existing_name FROM customers WHERE id = 8;
IF existing_name IS NULL THEN
INSERT INTO customers (id, name, email, email_domain, country, is_active)
VALUES (8, 'BMC Networks', 'info@bmcnetworks.dk', 'bmcnetworks.dk', 'DK', TRUE);
ELSIF LOWER(TRIM(existing_name)) <> LOWER('BMC Networks') THEN
RAISE EXCEPTION 'Customer ID 8 is already used by %, expected BMC Networks', existing_name;
END IF;
END $$;
SELECT setval(
pg_get_serial_sequence('customers', 'id'),
GREATEST((SELECT COALESCE(MAX(id), 1) FROM customers), 1),
TRUE
);

View File

@ -3,7 +3,7 @@
'use strict';
const $ = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state = {tags: [], hardware: [], tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
const state = {tags: [], hardware: [], typeDetails: {}, tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = [];
async function api(url, options) {
const response = await fetch(url, {credentials: 'include', ...options});
@ -38,7 +38,7 @@
}
function snapshot() {
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
tags: state.tags, hardware: state.hardware, orders: collectOrderItems(), prefill: telefoniPrefill,
tags: state.tags, hardware: state.hardware, typeDetails: state.typeDetails, orders: collectOrderItems(), prefill: telefoniPrefill,
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
}
@ -128,17 +128,105 @@
$('cc-workload').innerHTML = `<details><summary>${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}</summary>${data.items.map(r => `<div class="cc-work-item"><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small>${esc(r.customer_name || '')} · ${esc(r.status)} <span class="${r.deadline && new Date(r.deadline) < new Date() ? 'text-danger' : ''}">${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}</span></small></div>`).join('') || '<p class="text-success">Ingen åbne sager.</p>'}${data.total > 10 ? `<a href="/sag?ansvarlig_bruger_id=${id}" target="_blank" rel="noopener">Se alle ${data.total} sager</a>` : ''}</details>`;
} catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; }
}
function typeChanged(type) {
function typeChanged(type, showTypeFields = false) {
if (type === 'intern_ticket') {
selectedCustomer = {id: 8, name: 'BMC Networks'};
$('customer_id').value = '8';
$('customerSearch').value = 'BMC Networks';
const creatorId = Number(window.caseCreateCurrentUserId || 0);
if (creatorId && Array.from($('ansvarlig_bruger_id').options).some(option => Number(option.value) === creatorId)) {
$('ansvarlig_bruger_id').value = String(creatorId);
}
window.renderSelections?.();
}
captureTypeDetails();
renderTypeDetails(type);
$('createForm').closest('.card-custom')?.classList.toggle('cc-postit-mode', type === 'postit');
state.panels['type-details']?.classList.toggle('cc-postit-panel', type === 'postit');
document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); });
const key = {ticket:'hardware', pipeline:'pipeline', ordre:'order'}[type];
if (key && state.panels[key] && !state.restoring) state.panels[key].open = true;
const visiblePanels = {
ticket: ['relations', 'type-details', 'hardware'],
salg: ['relations', 'type-details', 'pipeline', 'order'],
bogholderi: ['relations', 'type-details', 'order'],
admin: ['type-details'],
projekt: ['relations', 'type-details'],
postit: ['type-details'],
intern_ticket: ['type-details'],
bug_report: ['relations', 'type-details', 'hardware'],
service: ['relations', 'hardware'],
pipeline: ['relations', 'pipeline'],
ordre: ['relations', 'order'],
opgave: ['relations'],
abonnement: ['relations'],
}[type] || ['relations'];
['relations', 'type-details', 'hardware', 'pipeline', 'order'].forEach(key => state.panels[key]?.classList.toggle('d-none', !showTypeFields || !visiblePanels.includes(key)));
const sectionIds = {hardware: 'hardwareSection', pipeline: 'pipelineSection', order: 'orderSection'};
Object.entries(sectionIds).forEach(([key, id]) => $(id)?.classList.toggle('d-none', !showTypeFields || !visiblePanels.includes(key)));
document.querySelectorAll('.case-type-extra').forEach(el => el.classList.toggle('d-none', !showTypeFields));
$('cc-template')?.closest('.cc-template-toolbar')?.classList.toggle('d-none', !showTypeFields);
if (showTypeFields && !state.restoring) visiblePanels.forEach(panelKey => { if (state.panels[panelKey]) state.panels[panelKey].open = true; });
if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`));
scheduleSave();
}
const typeFieldSets = {
ticket: [
['anydesk_id', 'AnyDesk-ID', 'text', '123-456-789'],
['hardware_information', 'Hardwareinformation', 'textarea', 'Enhed, model, serienummer eller fejl'],
['solution', 'Løsning', 'textarea', 'Den aftalte eller udførte løsning'],
],
salg: [
['customer_need', 'Kundebehov', 'textarea', 'Hvad ønsker kunden?'],
['expected_close_date', 'Forventet lukkedato', 'date', ''],
],
bogholderi: [
['voucher_reference', 'Bilags- eller fakturanummer', 'text', 'F.eks. F-12345'],
['supplier', 'Leverandør', 'text', 'Leverandørnavn'],
['due_date', 'Forfaldsdato', 'date', ''],
],
admin: [
['area', 'Område eller system', 'text', 'F.eks. brugeradministration'],
['requested_action', 'Ønsket handling', 'textarea', 'Hvad skal ændres eller undersøges?'],
],
projekt: [
['project_goal', 'Projektmål', 'textarea', 'Hvad skal projektet levere?'],
['start_date', 'Startdato', 'date', ''],
['end_date', 'Slutdato', 'date', ''],
],
postit: [
['note', 'Notat', 'textarea', 'Kort huskeseddel'],
['remind_date', 'Påmindelsesdato', 'date', ''],
],
intern_ticket: [
['system', 'System', 'text', 'Berørt system eller modul'],
['purpose', 'Formål', 'textarea', 'Hvad skal den interne opgave føre til?'],
['next_step', 'Næste skridt', 'textarea', 'Hvad skal der ske nu?'],
],
bug_report: [
['affected_area', 'Berørt område', 'text', 'System, side eller modul'],
['steps_to_reproduce', 'Trin til at genskabe', 'textarea', 'Beskriv trin for trin hvordan fejlen opstår'],
['expected_result', 'Forventet resultat', 'textarea', 'Hvad skulle der være sket?'],
['actual_result', 'Faktisk resultat', 'textarea', 'Hvad skete der i stedet?'],
],
};
function captureTypeDetails() {
const currentType = $('type')?.value;
if (!currentType) return;
const values = {};
document.querySelectorAll('[data-case-detail]').forEach(field => { values[field.dataset.caseDetail] = field.value; });
if (Object.keys(values).length) state.typeDetails[currentType] = values;
}
function renderTypeDetails(type) {
const section = $('typeDetailsSection'), box = $('typeDetailsFields');
if (!section || !box) return;
const fields = typeFieldSets[type] || [];
const values = state.typeDetails[type] || {};
box.innerHTML = fields.map(([key, label, kind, placeholder]) => `<div class="mb-3"><label class="form-label" for="case-detail-${esc(key)}">${esc(label)}</label>${kind === 'textarea' ? `<textarea class="form-control" rows="3" id="case-detail-${esc(key)}" data-case-detail="${esc(key)}" placeholder="${esc(placeholder)}">${esc(values[key] || '')}</textarea>` : `<input class="form-control" type="${esc(kind)}" id="case-detail-${esc(key)}" data-case-detail="${esc(key)}" value="${esc(values[key] || '')}" placeholder="${esc(placeholder)}">`}</div>`).join('');
section.classList.toggle('d-none', !fields.length);
}
function buildTypes() {
const options = Array.from($('type').options), current = $('type').value;
const keys = [...new Set([current, 'ticket','pipeline','ordre','opgave', ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
const hints = {ticket:'Support og hjælp',pipeline:'Muligheder og tilbud',ordre:'Indkøb og salg',opgave:'Noget der skal løses',projekt:'Større forløb',service:'Service og vedligehold',abonnement:'Løbende aftaler'};
const keys = [...new Set([current, ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
const hints = {ticket:'Support og hjælp',salg:'Tilbud og salg',bogholderi:'Bilag og økonomi',admin:'Intern administration',projekt:'Større forløb',postit:'Huskeseddel',intern_ticket:'Intern opgave',bug_report:'Rapportér en fejl'};
const button = key => `<button type="button" class="btn btn-outline-primary" data-case-type="${esc(key)}" aria-pressed="false"><span class="cc-type-name">${esc(options.find(o => o.value === key).text)}</span><small class="cc-type-hint">${esc(hints[key] || 'Opret sag')}</small></button>`;
const messageButton = $('caseMessageTypeButton');
$('type').classList.add('d-none'); $('type').removeAttribute('required');
@ -156,12 +244,12 @@
Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; });
const tags = await api('/api/v1/tags?is_active=true');
for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); }
updateCaseTypeSections(); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates();
window.updateCaseTypeSections?.(true); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates();
}
async function restore(draft) {
state.restoring = true;
try {
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {};
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; state.typeDetails = draft.typeDetails || {};
telefoniPrefill = draft.prefill || telefoniPrefill;
Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } });
state.tags = draft.tags || []; state.hardware = draft.hardware || [];
@ -171,7 +259,7 @@
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
updateCaseTypeSections(); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
window.updateCaseTypeSections?.(true); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
$('charCount').textContent = `${$('beskrivelse').value.length} tegn`;
} finally { state.restoring = false; }
state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload();
@ -182,7 +270,7 @@
const errors = [];
const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<div class="text-danger small cc-error">${esc(text)}</div>`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend','<span class="text-danger small cc-error"> · Kontrollér felter</span>'); } };
if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet');
if (!selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma');
if (['ticket', 'salg', 'bogholderi', 'projekt', 'service', 'pipeline', 'ordre', 'opgave', 'abonnement'].includes($('type').value) && !selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma');
$('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); });
document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } });
if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; }
@ -196,10 +284,10 @@
if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); });
} catch { /* Keep the built-in statuses available. */ }
const headings = Array.from(form.querySelectorAll(':scope > h5'));
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true);
for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
const metadata = headings[1]; panel('metadata', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]);
const tags = document.createElement('section'); tags.innerHTML = '<div id="cc-tag-chips"></div><button type="button" id="cc-tag-add" class="btn btn-sm btn-outline-primary">Tilføj tag</button><div id="cc-tag-suggestions" class="cc-tag-suggestions"></div>'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags]);
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], false);
for (const [key,id,title] of [['type-details','typeDetailsSection','Oplysninger til sagstypen'],['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
const metadata = headings[1]; panel('metadata', 'Ansvarlig', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling], true);
const tags = document.createElement('section'); tags.innerHTML = '<div id="cc-tag-chips"></div><button type="button" id="cc-tag-add" class="btn btn-sm btn-outline-primary">Tilføj tag</button><div id="cc-tag-suggestions" class="cc-tag-suggestions"></div>'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags], true);
$('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag);
window.setTagPickerContext?.('case', null, addTag);
$('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } };
@ -225,7 +313,7 @@
$('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message));
const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>');
toolbar.classList.add('cc-template-toolbar');
buildTypes(); updateCaseTypeSections();
buildTypes(); updateCaseTypeSections(false);
form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0));
$('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); });
$('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload);
@ -249,7 +337,7 @@
state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []);
state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts();
}
window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => ({tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id)}),created: result => {
window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => { captureTypeDetails(); return {tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id),type_details:state.typeDetails[$('type').value] || {}}; },created: result => {
state.completed = true; clearTimeout(saveTimer);
try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ }
}};

View File

@ -1,6 +1,6 @@
(() => {
const root=document.querySelector('.planner-page'); if(!root)return;
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],quick:'unplanned',selected:null,caseRefresh:0};
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],overdueCases:[],quick:'unplanned',selected:null,caseRefresh:0};
const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0');
const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
@ -12,9 +12,10 @@
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{state.cases=(await api('/api/v1/planner/cases?'+p)).items;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const current=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===current?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial','overdue'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{const current=await api('/api/v1/planner/cases?'+p);let overdueItems=[];try{overdueItems=(await api('/api/v1/planner/cases?planning=overdue&sort=priority')).items||[]}catch(error){console.warn('Kunne ikke hente overskredne planer',error)}state.cases=current.items;state.overdueCases=overdueItems;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const currentType=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===currentType?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)}
function renderCases(){$('#caseList').innerHTML=state.cases.length?state.cases.map(c=>`<article class="case-card" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div><div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
function caseCardHtml(c){const overdue=Boolean(c.overdue_planned),overdueDate=overdue&&c.overdue_planned_until?new Date(c.overdue_planned_until).toLocaleDateString('da-DK'):'';return `<article class="case-card ${overdue?'overdue-planned':''}" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div>${overdue?`<div class="case-overdue-label"><i class="bi bi-exclamation-triangle-fill"></i>Planlagt afsluttet ${esc(overdueDate)} · stadig åben</div>`:''}<div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`}
function renderCases(){const overdueHtml=state.quick!=='overdue'&&state.overdueCases.length?`<section class="overdue-case-section"><div class="overdue-case-heading"><i class="bi bi-exclamation-triangle-fill"></i>${state.overdueCases.length} åben${state.overdueCases.length===1?' sag er':' sager er'} planlagt før i dag</div>${state.overdueCases.map(caseCardHtml).join('')}</section>`:'';const casesHtml=state.cases.length?state.cases.map(caseCardHtml).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';$('#caseList').innerHTML=overdueHtml+casesHtml;document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}}
async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `<div class="border rounded p-2"><div class="d-flex justify-content-between"><strong>${esc(x.employee_name)}</strong><span>${fmtMin(x.minutes)}</span></div><small class="text-muted">${esc(when)}${x.slut_tid?' – '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}</small>${x.description?`<div class="small mt-1">${esc(x.description)}</div>`:''}</div>`}).join(''):'<div class="text-muted">Ingen tid registreret endnu.</div>'}catch(e){$('#timeDialogTotal').textContent=e.message}}
async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='<div class="empty">Henter plan…</div>';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}

View File

@ -968,7 +968,14 @@
};
}
document.addEventListener('DOMContentLoaded', connect);
// The script is included globally and may be loaded after DOMContentLoaded
// during internal navigation. In that case the old listener never ran and
// incoming-call popups silently stopped because no WebSocket was opened.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', connect, { once: true });
} else {
connect();
}
window.addEventListener('focus', async () => {
if (reconnectDisabledForAuth) {
const sessionOk = await hasActiveSession();

View File

@ -111,6 +111,7 @@ def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, mi
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda r: 7)
monkeypatch.setattr(cases, '_validate_user_id', lambda v: None)
monkeypatch.setattr(cases, '_validate_group_id', lambda v: None)
monkeypatch.setattr(cases, '_validate_customer_id', lambda v: None)
monkeypatch.setattr(cases, 'table_has_column', lambda *a: True)
data = {'titel': 'Test', 'customer_id': 3, 'type': 'ticket', 'pipeline': {'amount': 123}, 'hardware_ids': [4], 'tag_ids': [9], 'order_items': [{'description': 'Router', 'amount': 50}]}
if missing_hardware:
@ -123,3 +124,53 @@ def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, mi
assert all(table in sql for table in ['sag_sager','sag_salgsvarer','sag_hardware','entity_tags'])
case_args = next(args for query, args in conn.cur.calls if 'INSERT INTO sag_sager' in query)
assert 123.0 in case_args
def test_internal_ticket_forces_bmc_customer_and_creator_as_responsible(monkeypatch):
import asyncio
from app.modules.sag.backend import router as cases
captured = {}
class Cursor:
def __enter__(self): return self
def __exit__(self, *_): pass
def execute(self, sql, args):
if 'INSERT INTO sag_sager' in sql:
captured['case_args'] = args
self._row = {'id': 21, 'customer_id': args[5], 'ansvarlig_bruger_id': args[6]}
else:
self._row = None
def fetchone(self): return self._row
def fetchall(self): return []
class Connection:
def __init__(self): self.cur = Cursor(); self.committed = False
def cursor(self, **_kwargs): return self.cur
def commit(self): self.committed = True
def rollback(self): pass
conn = Connection()
monkeypatch.setattr(cases, 'get_db_connection', lambda: conn)
monkeypatch.setattr(cases, 'release_db_connection', lambda _conn: None)
monkeypatch.setattr(cases, '_normalize_case_status', lambda _value: 'åben')
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda _request: 7)
monkeypatch.setattr(cases, '_validate_user_id', lambda value: captured.setdefault('validated_user', value))
monkeypatch.setattr(cases, '_validate_group_id', lambda _value: None)
monkeypatch.setattr(cases, '_validate_customer_id', lambda value: captured.setdefault('validated_customer', value))
monkeypatch.setattr(cases, 'table_has_column', lambda *_args: True)
result = asyncio.run(cases.create_sag(None, {
'titel': 'Intern opgave',
'type': 'intern_ticket',
'customer_id': 1662,
'ansvarlig_bruger_id': 99,
}))
assert result['id'] == 21
assert captured['validated_customer'] == 8
assert captured['validated_user'] == 7
assert captured['case_args'][5] == 8
assert captured['case_args'][6] == 7
assert captured['case_args'][8] == 7
assert conn.committed is True

View File

@ -95,6 +95,17 @@ def test_planner_ui_exposes_views_filters_capacity_used_time_and_undo():
assert "green" in html and "yellow" in html and "red" in html and "gray" in html
def test_case_planner_exposes_case_specific_allocations_endpoint():
source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8")
assert '@router.get("/cases/{sag_id}/allocations")' in source
assert "def case_allocations(" in source
assert "WHERE a.sag_id=%s AND a.deleted_at IS NULL" in source
assert '"remaining_minutes"' in source
assert '"deferred"' in source
assert '"subcases"' in source
def test_closed_case_is_checked_before_allocation(monkeypatch):
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})

View File

@ -159,9 +159,18 @@ def test_case_create_lists_contacts_for_selected_customer():
def test_case_create_defaults_responsible_to_current_user():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
create_script = Path("static/js/case-create.js").read_text(encoding="utf-8")
assert "selectCurrentUserAsResponsible()" in template
assert "if (!select || select.value) return" not in template
assert "updateCaseTypeSections(false);" in template
assert "raw_responsible = data.get" in router
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
assert 'if case_type == "intern_ticket":' in router
assert 'data["customer_id"] = 8' in router
assert "raw_responsible = current_user_id" in router
assert '_validate_customer_id(data.get("customer_id"))' in router
assert "selectedCustomer = {id: 8, name: 'BMC Networks'}" in create_script
assert "window.caseCreateCurrentUserId" in create_script
def test_case_create_sends_relations_in_atomic_create_payload():
@ -457,6 +466,158 @@ def test_sag_list_status_dropdown_receives_options_and_links_are_styled():
assert "function applyStatusSelectTone(control)" in template
def test_v4_compact_time_module_is_moved_below_tags_and_creates_drafts():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'id="caseQuickTimeMinutes"' in template
assert 'id="caseQuickTimeType"' in template
assert 'id="caseQuickTimeDescription"' in template
assert 'onclick="startQuickTimerV4()"' in template
assert "tagsModule.insertAdjacentElement('afterend', timeModule);" in template
assert "entry_status: 'kladde'" in template
assert "medarbejder_ids: [null]" in template
def test_postit_keeps_tags_time_and_full_time_tab_available():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert ':not([data-module="tags"]):not([data-module="time"])' in template
assert "target !== '#details' && target !== '#timetracking' && target !== '#planner'" in template
assert "moduleName !== 'tags' && moduleName !== 'time'" in template
def test_v4_timeline_date_menus_escape_hero_stacking_context():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert template.count("data-v4-date-dropdown") >= 2
assert "case-v4-floating-date-menu" in template
assert "document.body.appendChild(menu);" in template
assert "toggle.insertAdjacentElement('afterend', menu);" in template
def test_v4_group_assignment_loader_is_available_to_page_initialization():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "window.loadCaseAssignments = loadCaseAssignments;" in template
assert "window.persistCaseAssignees = persistCaseAssignees;" in template
assert "window.loadCaseAssignments?.().catch" in template
assert "await persistCaseAssignees([], null, null);" in template
assert "await saveCaseAssignees([], null, null);" not in template
def test_v4_deferred_modal_is_compact_and_saves_atomically():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'class="modal fade deferred-v4-modal" id="deferredModal"' in template
assert "bootstrap.Modal.getOrCreateInstance(modalElement).show();" in template
assert "async function persistDeferredSettings" in template
assert "await persistDeferredSettings(input.value || null, caseSelect.value || null, statuses);" in template
assert "deferred_until_status: relatedCaseId && normalizedStatuses.length" in template
assert "updateDeferredUntil(input.value);" not in template
def test_v4_comment_actions_use_compact_icons_and_can_append_to_case():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
router = Path("app/modules/sag/backend/router.py").read_text()
assert 'action-time is-icon-only' in template
assert 'action-close is-icon-only' in template
assert 'action-wait is-icon-only' in template
assert '<span>Log tid</span>' not in template
assert '<span>Luk sag</span>' not in template
assert '<span>Afventer kunde</span>' not in template
assert 'id="commentActionAppendCase"' in template
assert "actions.push('append_case')" in template
assert "body: JSON.stringify({ beskrivelse: content, append: true })" in template
assert "await appendCommentToCaseDescription(content);" in template
assert "append: bool = False" in router
assert 'append_date = datetime.now().strftime("%d/%m")' in router
assert 'append_fragment = f"<hr><h6>{initials}/{append_date}</h6><p>{safe_addition}</p>"' in router
def test_v4_comment_composer_prioritizes_writing_space():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'min-height: 160px;' in template
assert 'rows="6"' in template
assert '.comment-composer textarea:focus' in template
assert 'align-items: flex-end;' in template
def test_v4_relations_are_compact_and_created_inline():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'Relationer til #{{ case.id }}' in template
assert 'id="relationInlineCreate"' in template
assert 'onclick="showInlineRelationRow()"' in template
assert 'id="inlineRelatedCaseType"' in template
assert 'id="inlineRelatedCaseTitle"' in template
assert 'id="inlineRelationType"' in template
assert 'id="inlineRelatedCaseUser"' in template
assert 'id="inlineRelatedCaseGroup"' in template
assert 'async function saveInlineRelation()' in template
assert "const caseResponse = await fetch('/api/v1/sag'" in template
assert 'målsag_id: newCase.id' in template
assert 'placeholder="Sag #"' not in template
assert 'Skriv et gyldigt sagsnummer.' not in template
assert 'ansvarlig_bruger_id: responsibleUserId' in template
assert 'assigned_group_id: assignedGroupId' in template
assert "node.case.ansvarlig_navn or 'Ingen ansvarlig'" in template
assert "node.case.gruppe_navn or 'Ingen gruppe'" in template
assert 'class="btn btn-outline-primary btn-rel-action rel-related-add-btn"' in template
assert 'onclick=\'openRelQaMenu({{ node.case.id }}' in template
assert '.compact-relations-table .btn-rel-action.rel-related-add-btn' in template
assert 'href="/sag/{{ node.case.id }}/v4" class="compact-relation-id"' in template
assert '<tr class="{{ \'d-none\' if node.is_current else \'\' }}">' in template
def test_v4_embeds_case_planner_with_direct_subcase_creation():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'id="planner-tab" data-bs-target="#planner"' in template
assert 'id="casePlannerAllocationForm"' in template
assert 'id="casePlannerSubcaseForm"' in template
assert "async function createEmbeddedCaseAllocation" in template
assert "async function createPlannerSubcase" in template
assert "relationstype:'undersag'" in template
assert "`/api/v1/planner/cases/${caseIds}/allocations`" in template
assert 'id="casePlannerDeferredNotice"' in template
assert "Venter på sag #${deferred.case_id}" in template
assert "subcase.deferred_until_case_id" in template
assert "window.loadEmbeddedCasePlanner?.();" in template
assert 'id="casePlannerWeek"' in template
assert "function renderEmbeddedPlannerCalendar()" in template
assert "function shiftEmbeddedPlannerWeek(direction)" in template
assert "Klik på en dag for at allokere tid" in template
assert 'id="casePlannerSlotModal"' in template
assert "function openEmbeddedPlannerSlot(dateKey)" in template
assert "async function saveEmbeddedPlannerSlot(event)" in template
assert "Dette er planlagt tid" in template
assert "Der oprettes ingen tidsregistrering" in template
assert "konfliktadvarsel før overbooking" in template
def test_v4_comment_source_filters_start_off_and_use_compact_icons():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
for filter_id in ("Email", "Sms", "Call", "Module"):
marker = f'id="commentSourceFilter{filter_id}"'
line = next(line for line in template.splitlines() if marker in line)
assert "checked" not in line
for icon in ("bi-envelope", "bi-chat-dots", "bi-telephone", "bi-grid"):
assert icon in template
assert ".comment-category-check:has(input:checked)" in template
assert "const sourceFilterIsActive = selectedSources.size > 0;" in template
assert "const matchesSource = !sourceFilterIsActive || selectedSources.has(source);" in template
def test_legacy_v3_route_renders_the_active_v4_template():
views = Path("app/modules/sag/frontend/views.py").read_text()
assert 'template_name = "modules/sag/templates/detail_v4.html"' in views
assert 'else "modules/sag/templates/detail_v3.html"' not in views
def test_support_case_close_without_time_requires_explicit_confirmation():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
@ -470,6 +631,22 @@ def test_support_case_close_without_time_requires_explicit_confirmation():
assert "await confirmCloseCaseWithoutTime(caseId, detail.message)" in template
def test_v4_uses_fixed_case_statuses_and_blocks_unfinished_time_on_close():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
views = Path("app/modules/sag/frontend/views.py").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()
expected = '["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "Lukket"]'
assert f"return {expected}" in views
assert "case_close_blocked_unfinished_time" in source
assert "entry_status" in source
assert "<> 'godkendt'" in source
assert "slut_tid IS NULL" in source
assert "const closesCase = nextStatus.toLowerCase() === 'lukket';" in template
assert "Sagen kan kun lukkes, når alle tidsregistreringer er færdigbehandlede" in template
assert "showToast(e.message || 'Kunne ikke gemme status', 'danger');" in template
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():
template = Path("app/modules/sag/templates/index.html").read_text()
source = Path("app/modules/sag/backend/router.py").read_text()

View File

@ -76,6 +76,43 @@ def test_manual_time_bulk_resolves_current_user_and_removes_duplicates(monkeypat
assert result["count"] == 2
def test_manual_internal_time_can_be_created_without_case_customer(monkeypatch):
captured = {}
monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: None)
def fake_execute_query(_query, params):
captured["params"] = params
return [{"id": 1, "customer_id": None, "billable": False}]
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
result = asyncio.run(
timetracking_router.create_manual_time_v1(
{
"sag_id": 177,
"medarbejder_ids": [None],
"faktisk_tid_min": 15,
"entry_status": "kladde",
},
current_user={"id": 9, "username": "tester"},
)
)
assert result["count"] == 1
assert captured["params"][2] is None
assert captured["params"][8] is False
assert captured["params"][9] == "internal"
assert captured["params"][13] == 0
def test_customerless_time_recommends_internal_settlement():
result = timetracking_router._settlement_options_for_customer(None)
assert result["recommended"]["method"] == "internal"
assert result["prepaid_cards"] == []
assert result["agreements"] == []
def test_case_time_form_sends_local_time_and_multiple_employees():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(