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:
parent
ee274a939a
commit
abfb945679
@ -221,7 +221,7 @@ class UserProfileUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _allowed_case_types() -> list[str]:
|
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:
|
try:
|
||||||
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
|
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
|
||||||
if rows:
|
if rows:
|
||||||
@ -229,7 +229,7 @@ def _allowed_case_types() -> list[str]:
|
|||||||
configured = json.loads(rows[0].get("value") or "[]")
|
configured = json.loads(rows[0].get("value") or "[]")
|
||||||
values = [str(value).strip().lower() for value in configured if str(value).strip()]
|
values = [str(value).strip().lower() for value in configured if str(value).strip()]
|
||||||
if values:
|
if values:
|
||||||
return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else [])))
|
return list(dict.fromkeys(values))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not load configured case types for profile preference", exc_info=True)
|
logger.warning("Could not load configured case types for profile preference", exc_info=True)
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@ -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=[]),
|
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,
|
case_type: list[str] = Query(default=[]), responsible_id: int | None = None,
|
||||||
department_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",
|
urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority",
|
||||||
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
|
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
|
||||||
_require_view(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(s.estimated_minutes, 0) AS estimated_minutes,
|
||||||
COALESCE(used.used_minutes, 0) AS used_minutes,
|
COALESCE(used.used_minutes, 0) AS used_minutes,
|
||||||
used.latest_registration_at,
|
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
|
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 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
|
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"
|
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 == "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 == "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",
|
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",
|
"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]
|
"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}
|
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")
|
@router.patch("/cases/{sag_id}/estimate")
|
||||||
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
|
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
|
||||||
permissions = _permissions(current_user)
|
permissions = _permissions(current_user)
|
||||||
|
|||||||
@ -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}
|
.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}
|
.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}
|
@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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% 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>
|
<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>
|
</header>
|
||||||
<section class="planner-layout">
|
<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 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>
|
</section>
|
||||||
</main>
|
</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>
|
<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>
|
<div class="toast-stack" id="toastStack"></div>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
@ -108,74 +108,33 @@ def _ensure_case_internet_connection_tag(sag_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_case_status(status_value: Optional[str]) -> str:
|
def _normalize_case_status(status_value: Optional[str]) -> str:
|
||||||
allowed_statuses = []
|
canonical = {
|
||||||
seen = set()
|
"ny": "Ny",
|
||||||
|
"tildelt": "Tildelt",
|
||||||
def _add_status(value: Optional[str]) -> None:
|
"igangværende": "Igangværende",
|
||||||
candidate = str(value or "").strip()
|
"afventer": "Afventer",
|
||||||
if not candidate:
|
"løst": "Løst",
|
||||||
return
|
"kunde svaret": "Kunde svaret",
|
||||||
key = candidate.lower()
|
"lukket": "Lukket",
|
||||||
if key in seen:
|
}
|
||||||
return
|
legacy_aliases = {
|
||||||
seen.add(key)
|
"åben": "Ny",
|
||||||
allowed_statuses.append(candidate)
|
"open": "Ny",
|
||||||
|
"under behandling": "Igangværende",
|
||||||
try:
|
"i_gang": "Igangværende",
|
||||||
setting_row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("case_statuses",))
|
"in progress": "Igangværende",
|
||||||
if setting_row and setting_row.get("value"):
|
"on_hold": "Afventer",
|
||||||
parsed = json.loads(setting_row.get("value") or "[]")
|
"resolved": "Løst",
|
||||||
for item in parsed if isinstance(parsed, list) else []:
|
"closed": "Lukket",
|
||||||
if isinstance(item, str):
|
"afsluttet": "Lukket",
|
||||||
value = item.strip()
|
"done": "Lukket",
|
||||||
elif isinstance(item, dict):
|
}
|
||||||
value = str(item.get("value") or "").strip()
|
normalized = str(status_value or "Ny").strip().lower()
|
||||||
else:
|
if normalized in canonical:
|
||||||
value = ""
|
return canonical[normalized]
|
||||||
_add_status(value)
|
if normalized in legacy_aliases:
|
||||||
except Exception:
|
return legacy_aliases[normalized]
|
||||||
pass
|
raise HTTPException(status_code=400, detail="Ugyldig status. Vælg en af de faste sagsstatuser.")
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
|
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
|
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 = (
|
SAG_LIST_COLUMN_KEYS = (
|
||||||
"id", "company", "contact", "description", "type", "priority", "status",
|
"id", "company", "contact", "description", "type", "priority", "status",
|
||||||
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
|
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
|
||||||
@ -1022,23 +987,49 @@ async def create_sag(request: Request, data: dict):
|
|||||||
try:
|
try:
|
||||||
if not data.get("titel"):
|
if not data.get("titel"):
|
||||||
raise HTTPException(status_code=400, detail="titel is required")
|
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")
|
raise HTTPException(status_code=400, detail="customer_id is required")
|
||||||
|
_validate_customer_id(data.get("customer_id"))
|
||||||
|
|
||||||
status = _normalize_case_status(data.get("status"))
|
status = _normalize_case_status(data.get("status"))
|
||||||
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
||||||
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
|
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
|
||||||
current_user_id = _get_user_id_from_request(request)
|
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
|
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")
|
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")
|
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
|
||||||
|
|
||||||
_validate_user_id(ansvarlig_bruger_id)
|
_validate_user_id(ansvarlig_bruger_id)
|
||||||
_validate_group_id(assigned_group_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")
|
pipeline = data.get("pipeline")
|
||||||
order_items = data.get("order_items", [])
|
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 []
|
raw_contact_ids = data.get("contact_ids") or []
|
||||||
if not isinstance(raw_contact_ids, list):
|
if not isinstance(raw_contact_ids, list):
|
||||||
raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
|
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(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sag_sager
|
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)
|
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 *
|
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"),
|
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"]),
|
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:
|
if not result:
|
||||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
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:
|
if contact_ids:
|
||||||
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (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()}
|
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
|
# 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])
|
@router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
|
||||||
async def list_todo_steps(sag_id: int):
|
async def list_todo_steps(sag_id: int):
|
||||||
try:
|
try:
|
||||||
@ -1725,6 +1821,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
"""Update a case."""
|
"""Update a case."""
|
||||||
try:
|
try:
|
||||||
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
|
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 if case exists
|
||||||
check = execute_query(
|
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"
|
# Backwards compatibility: frontend sends "type", DB stores "template_key"
|
||||||
if "type" in updates and "template_key" not in updates:
|
if "type" in updates and "template_key" not in updates:
|
||||||
updates["template_key"] = updates.get("type")
|
updates["template_key"] = updates.get("type")
|
||||||
|
if "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:
|
if "status" in updates:
|
||||||
updates["status"] = _normalize_case_status(updates.get("status"))
|
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()
|
new_status = str(updates.get("status") or "").strip().lower()
|
||||||
case_type = str(previous_row.get("template_key") or "").strip().lower()
|
case_type = str(previous_row.get("template_key") or "").strip().lower()
|
||||||
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
|
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:
|
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
|
||||||
has_registered_time = execute_query_single(
|
has_registered_time = execute_query_single(
|
||||||
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
|
"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.",
|
"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:
|
if "deadline" in updates:
|
||||||
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
||||||
if "start_date" in updates:
|
if "start_date" in updates:
|
||||||
@ -1805,6 +1954,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
"titel",
|
"titel",
|
||||||
"beskrivelse",
|
"beskrivelse",
|
||||||
"template_key",
|
"template_key",
|
||||||
|
"type",
|
||||||
"status",
|
"status",
|
||||||
"customer_id",
|
"customer_id",
|
||||||
"ansvarlig_bruger_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)
|
set_sql = ", ".join(set_clauses)
|
||||||
query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *"
|
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))
|
result = execute_query(query, tuple(params))
|
||||||
if result:
|
if result:
|
||||||
updated_row = result[0] or {}
|
updated_row = result[0] or {}
|
||||||
@ -1869,6 +2041,12 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
|
|
||||||
type_labels = {
|
type_labels = {
|
||||||
"ticket": "Ticket",
|
"ticket": "Ticket",
|
||||||
|
"salg": "Salg",
|
||||||
|
"bogholderi": "Bogholderi",
|
||||||
|
"admin": "Admin",
|
||||||
|
"postit": "Post-it",
|
||||||
|
"intern_ticket": "Intern ticket",
|
||||||
|
"bug_report": "Bug_report",
|
||||||
"pipeline": "Pipeline",
|
"pipeline": "Pipeline",
|
||||||
"opgave": "Opgave",
|
"opgave": "Opgave",
|
||||||
"ordre": "Ordre",
|
"ordre": "Ordre",
|
||||||
@ -1973,6 +2151,7 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
|||||||
|
|
||||||
class BeskrivelsePatch(BaseModel):
|
class BeskrivelsePatch(BaseModel):
|
||||||
beskrivelse: str
|
beskrivelse: str
|
||||||
|
append: bool = False
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id}/beskrivelse", dependencies=[Depends(case_edit_access)])
|
@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")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
old_beskrivelse = row.get("beskrivelse")
|
old_beskrivelse = row.get("beskrivelse")
|
||||||
new_beskrivelse = body.beskrivelse
|
|
||||||
|
|
||||||
# Resolve acting user (may be None for anonymous)
|
# Resolve acting user (may be None for anonymous)
|
||||||
user_id = _get_user_id_from_request(request)
|
user_id = _get_user_id_from_request(request)
|
||||||
changed_by_name = None
|
changed_by_name = None
|
||||||
@ -2000,6 +2177,18 @@ async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: R
|
|||||||
if u:
|
if u:
|
||||||
changed_by_name = u["name"]
|
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
|
# Write history entry
|
||||||
execute_query(
|
execute_query(
|
||||||
"""INSERT INTO sag_beskrivelse_history
|
"""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")
|
raise HTTPException(status_code=500, detail="Failed to list case customers")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sag/{sag_id}/customer/replace")
|
@router.post("/sag/{sag_id}/customer/replace", dependencies=[Depends(case_edit_access)])
|
||||||
async def replace_case_customer(sag_id: int, payload: dict):
|
async def replace_case_customer(sag_id: int, payload: dict, request: Request):
|
||||||
"""Replace the primary customer on a case and keep relations synchronized."""
|
"""Replace the primary customer on a case and keep relations synchronized."""
|
||||||
try:
|
try:
|
||||||
existing_case = execute_query_single(
|
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,),
|
(sag_id,),
|
||||||
)
|
)
|
||||||
if not existing_case:
|
if not existing_case:
|
||||||
@ -2475,6 +2664,10 @@ async def replace_case_customer(sag_id: int, payload: dict):
|
|||||||
|
|
||||||
_validate_customer_id(customer_id)
|
_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"):
|
if table_has_column("sag_sager", "customer_id"):
|
||||||
execute_query(
|
execute_query(
|
||||||
"UPDATE sag_sager SET customer_id = %s WHERE id = %s",
|
"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"),
|
(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)
|
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}
|
return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id}
|
||||||
except HTTPException:
|
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)"
|
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]")
|
raise HTTPException(status_code=500, detail=f"{failure_detail} [diag:sag-send-v3]")
|
||||||
|
|
||||||
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
|
sender_name = email_service.smtp_config.get("from_name") or "BMC Hub"
|
||||||
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
|
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)
|
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.
|
# Keep replies in the existing case thread when we already know the target thread.
|
||||||
|
|||||||
@ -159,47 +159,7 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_case_status_options() -> list[str]:
|
def _fetch_case_status_options() -> list[str]:
|
||||||
default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
|
return ["Ny", "Tildelt", "Igangværende", "Afventer", "Løst", "Kunde svaret", "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
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_closed_case_statuses() -> list[str]:
|
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)
|
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
|
||||||
async def sag_detaljer(request: Request, sag_id: int):
|
async def sag_detaljer(request: Request, sag_id: int):
|
||||||
"""Redirect legacy case details URL to v3."""
|
"""Redirect the unversioned case URL to the active V4 page."""
|
||||||
return RedirectResponse(url=f"/sag/{sag_id}/v3", status_code=307)
|
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
|
||||||
|
|
||||||
"""Display case details."""
|
"""Display case details."""
|
||||||
try:
|
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}/v3", response_class=HTMLResponse)
|
||||||
|
@router.get("/sag/{sag_id}/v4", response_class=HTMLResponse)
|
||||||
async def sag_detaljer_v3(request: Request, sag_id: int):
|
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:
|
try:
|
||||||
# Fetch main case
|
# Fetch main case
|
||||||
sag_query = """
|
sag_query = """
|
||||||
@ -1339,7 +1300,11 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
|
|||||||
status_options.append(current_status)
|
status_options.append(current_status)
|
||||||
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
|
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,
|
"request": request,
|
||||||
"case": sag,
|
"case": sag,
|
||||||
"customer": customer,
|
"customer": customer,
|
||||||
|
|||||||
@ -45,9 +45,15 @@ class RelationService:
|
|||||||
s.status,
|
s.status,
|
||||||
s.template_key,
|
s.template_key,
|
||||||
COALESCE(s.template_key, 'ticket') AS type,
|
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.title AS next_todo_title,
|
||||||
next_todo.due_date AS next_todo_due_date
|
next_todo.due_date AS next_todo_due_date
|
||||||
FROM sag_sager s
|
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 (
|
LEFT JOIN LATERAL (
|
||||||
SELECT t.title, t.due_date
|
SELECT t.title, t.due_date
|
||||||
FROM sag_todo_steps t
|
FROM sag_todo_steps t
|
||||||
|
|||||||
@ -245,7 +245,10 @@
|
|||||||
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
|
.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 { 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 .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 { 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-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; }
|
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
|
||||||
@ -286,11 +289,6 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<!-- Main Card -->
|
<!-- Main Card -->
|
||||||
<div class="card card-custom">
|
<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 class="card-body p-4">
|
||||||
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
|
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
|
||||||
|
|
||||||
@ -375,6 +373,9 @@
|
|||||||
|
|
||||||
<hr class="my-4 opacity-25">
|
<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: Hardware & AnyDesk -->
|
||||||
<section id="hardwareSection">
|
<section id="hardwareSection">
|
||||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
||||||
@ -437,9 +438,9 @@
|
|||||||
<hr class="my-4 opacity-25">
|
<hr class="my-4 opacity-25">
|
||||||
|
|
||||||
<!-- Section: Metadata -->
|
<!-- Section: Metadata -->
|
||||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Ansvarlig</h5>
|
||||||
<div class="row g-4 mb-4">
|
<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>
|
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="status" required>
|
<select class="form-select" id="status" required>
|
||||||
<option value="åben" selected>🟢 Åben</option>
|
<option value="åben" selected>🟢 Åben</option>
|
||||||
@ -457,7 +458,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
||||||
<select class="form-select" id="assigned_group_id">
|
<select class="form-select" id="assigned_group_id">
|
||||||
<option value="">Ingen</option>
|
<option value="">Ingen</option>
|
||||||
@ -468,7 +469,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4 case-type-extra">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label for="deadline" class="form-label">Deadline</label>
|
<label for="deadline" class="form-label">Deadline</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@ -1329,20 +1330,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const caseTypeLabels = {
|
const caseTypeLabels = {
|
||||||
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
|
ticket: '🎫 Ticket', salg: '💼 Salg', bogholderi: '🧾 Bogholderi',
|
||||||
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service',
|
admin: '⚙️ Admin', projekt: '📁 Projekt', postit: '🗒️ Post-it',
|
||||||
abonnement: '🔁 Abonnement'
|
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';
|
const type = document.getElementById('type')?.value || 'ticket';
|
||||||
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
|
window.caseCreateUI?.typeChanged(type, showTypeFields);
|
||||||
window.caseCreateUI?.typeChanged(type);
|
|
||||||
const help = document.getElementById('caseTypeHelp');
|
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 === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
|
||||||
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
|
: 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() {
|
function renderOrderLinesEmptyState() {
|
||||||
@ -1417,28 +1420,29 @@
|
|||||||
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
||||||
const profile = profileRes.ok ? await profileRes.json() : {};
|
const profile = profileRes.ok ? await profileRes.json() : {};
|
||||||
const configured = JSON.parse(setting.value || '[]');
|
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);
|
const configuredTypes = Array.isArray(configured) ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : [];
|
||||||
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
const supportedTypes = ['ticket', 'salg', 'bogholderi', 'admin', 'projekt', 'postit', 'intern_ticket', 'bug_report'];
|
||||||
if (!types.includes('abonnement')) types.push('abonnement');
|
const finalTypes = supportedTypes;
|
||||||
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
|
|
||||||
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
|
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]);
|
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load case types', err);
|
console.error('Failed to load case types', err);
|
||||||
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
|
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
|
||||||
}
|
}
|
||||||
updateCaseTypeSections();
|
updateCaseTypeSections(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectCurrentUserAsResponsible() {
|
async function selectCurrentUserAsResponsible() {
|
||||||
const select = document.getElementById('ansvarlig_bruger_id');
|
const select = document.getElementById('ansvarlig_bruger_id');
|
||||||
if (!select || select.value) return;
|
if (!select) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const user = await response.json();
|
const user = await response.json();
|
||||||
const userId = user.user_id ?? user.id;
|
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);
|
select.value = String(userId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1450,7 +1454,10 @@
|
|||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initializeSearch();
|
initializeSearch();
|
||||||
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
|
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 applyTelefoniPrefill();
|
||||||
await window.caseCreateUI?.init();
|
await window.caseCreateUI?.init();
|
||||||
});
|
});
|
||||||
@ -1586,5 +1593,5 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/case-create.js?v=5"></script>
|
<script src="/static/js/case-create.js?v=6"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
23886
app/modules/sag/templates/detail_v4.html
Normal file
23886
app/modules/sag/templates/detail_v4.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -34,8 +34,9 @@ from aiohttp import ClientSession, BasicAuth
|
|||||||
import msal
|
import msal
|
||||||
|
|
||||||
from app.core.config import settings
|
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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -60,6 +61,78 @@ class EmailService:
|
|||||||
'client_secret': settings.GRAPH_CLIENT_SECRET,
|
'client_secret': settings.GRAPH_CLIENT_SECRET,
|
||||||
'user_email': settings.GRAPH_USER_EMAIL
|
'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:
|
def _graph_send_available(self) -> bool:
|
||||||
return bool(
|
return bool(
|
||||||
@ -1789,7 +1862,7 @@ class EmailService:
|
|||||||
return False, "aiosmtplib not installed"
|
return False, "aiosmtplib not installed"
|
||||||
|
|
||||||
# Validate SMTP configuration
|
# 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")
|
logger.error("❌ SMTP not configured - cannot send email")
|
||||||
if graph_failure_message:
|
if graph_failure_message:
|
||||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured"
|
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured"
|
||||||
@ -1799,7 +1872,7 @@ class EmailService:
|
|||||||
# Build message
|
# Build message
|
||||||
msg = MIMEMultipart('alternative')
|
msg = MIMEMultipart('alternative')
|
||||||
msg['Subject'] = subject
|
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['To'] = ', '.join(to_addresses)
|
||||||
|
|
||||||
if cc:
|
if cc:
|
||||||
@ -1817,11 +1890,11 @@ class EmailService:
|
|||||||
|
|
||||||
# Send via SMTP
|
# Send via SMTP
|
||||||
async with aiosmtplib.SMTP(
|
async with aiosmtplib.SMTP(
|
||||||
hostname=settings.EMAIL_SMTP_HOST,
|
hostname=self.smtp_config['host'],
|
||||||
port=settings.EMAIL_SMTP_PORT,
|
port=self.smtp_config['port'],
|
||||||
use_tls=settings.EMAIL_SMTP_USE_TLS
|
use_tls=self.smtp_config['use_tls']
|
||||||
) as smtp:
|
) 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
|
# Combine all recipients
|
||||||
all_recipients = to_addresses.copy()
|
all_recipients = to_addresses.copy()
|
||||||
@ -1831,7 +1904,7 @@ class EmailService:
|
|||||||
all_recipients.extend(bcc)
|
all_recipients.extend(bcc)
|
||||||
|
|
||||||
await smtp.sendmail(
|
await smtp.sendmail(
|
||||||
settings.EMAIL_SMTP_FROM_ADDRESS,
|
self.smtp_config['from_address'],
|
||||||
all_recipients,
|
all_recipients,
|
||||||
msg.as_string()
|
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, 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
|
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")
|
logger.error("❌ SMTP not configured - cannot send email")
|
||||||
if graph_failure_message:
|
if graph_failure_message:
|
||||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback unavailable: SMTP not configured", generated_message_id, provider_thread_key
|
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:
|
try:
|
||||||
msg = MIMEMultipart('mixed')
|
msg = MIMEMultipart('mixed')
|
||||||
msg['Subject'] = subject
|
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['To'] = ', '.join(to_addresses)
|
||||||
msg['Message-ID'] = generated_message_id
|
msg['Message-ID'] = generated_message_id
|
||||||
|
|
||||||
@ -1974,11 +2047,11 @@ class EmailService:
|
|||||||
msg.attach(mime_attachment)
|
msg.attach(mime_attachment)
|
||||||
|
|
||||||
async with aiosmtplib.SMTP(
|
async with aiosmtplib.SMTP(
|
||||||
hostname=settings.EMAIL_SMTP_HOST,
|
hostname=self.smtp_config['host'],
|
||||||
port=settings.EMAIL_SMTP_PORT,
|
port=self.smtp_config['port'],
|
||||||
use_tls=settings.EMAIL_SMTP_USE_TLS
|
use_tls=self.smtp_config['use_tls']
|
||||||
) as smtp:
|
) 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()
|
all_recipients = to_addresses.copy()
|
||||||
if cc:
|
if cc:
|
||||||
@ -1987,7 +2060,7 @@ class EmailService:
|
|||||||
all_recipients.extend(bcc)
|
all_recipients.extend(bcc)
|
||||||
|
|
||||||
await smtp.sendmail(
|
await smtp.sendmail(
|
||||||
settings.EMAIL_SMTP_FROM_ADDRESS,
|
self.smtp_config['from_address'],
|
||||||
all_recipients,
|
all_recipients,
|
||||||
msg.as_string()
|
msg.as_string()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,16 +3,18 @@ Settings and User Management API Router
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query
|
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 pydantic import BaseModel
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.core.database import execute_query, execute_query_single
|
from app.core.database import execute_query, execute_query_single
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.auth_dependencies import require_any_permission, require_superadmin
|
from app.core.auth_dependencies import require_any_permission, require_superadmin
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.crypto import decrypt_secret, encrypt_secret
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
|
import imaplib
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
@ -20,6 +22,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -75,6 +78,52 @@ class AIBenchmarkRunRequest(BaseModel):
|
|||||||
test_keys: Optional[List[str]] = None
|
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_SETTING_DEFAULTS = (
|
||||||
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
|
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
|
||||||
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
|
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
|
||||||
@ -273,6 +322,177 @@ async def create_ai_benchmark_run(
|
|||||||
|
|
||||||
|
|
||||||
# Settings Endpoints
|
# 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"])
|
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
|
||||||
async def get_settings(category: Optional[str] = None):
|
async def get_settings(category: Optional[str] = None):
|
||||||
"""Get all settings or filter by category"""
|
"""Get all settings or filter by category"""
|
||||||
@ -375,7 +595,7 @@ async def get_setting(key: str):
|
|||||||
seed_query,
|
seed_query,
|
||||||
(
|
(
|
||||||
"case_types",
|
"case_types",
|
||||||
'["ticket", "pipeline", "opgave", "ordre", "projekt", "service", "abonnement"]',
|
'["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"]',
|
||||||
"system",
|
"system",
|
||||||
"Sags-typer",
|
"Sags-typer",
|
||||||
"json",
|
"json",
|
||||||
|
|||||||
@ -171,6 +171,9 @@
|
|||||||
<a class="nav-link" href="#notifications" data-tab="notifications">
|
<a class="nav-link" href="#notifications" data-tab="notifications">
|
||||||
<i class="bi bi-bell me-2"></i>Notifikationer
|
<i class="bi bi-bell me-2"></i>Notifikationer
|
||||||
</a>
|
</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">
|
<a class="nav-link" href="#users" data-tab="users">
|
||||||
<i class="bi bi-people me-2"></i>Brugere
|
<i class="bi bi-people me-2"></i>Brugere
|
||||||
</a>
|
</a>
|
||||||
@ -604,6 +607,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 & 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 -->
|
<!-- Email Templates -->
|
||||||
<div class="tab-pane fade" id="email-templates">
|
<div class="tab-pane fade" id="email-templates">
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<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);
|
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
|
// Tab navigation
|
||||||
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
||||||
link.addEventListener('click', (e) => {
|
link.addEventListener('click', (e) => {
|
||||||
@ -5534,6 +5714,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
|||||||
loadTaskTemplates();
|
loadTaskTemplates();
|
||||||
} else if (tab === 'telefoni') {
|
} else if (tab === 'telefoni') {
|
||||||
renderTelefoniSettings();
|
renderTelefoniSettings();
|
||||||
|
} else if (tab === 'email') {
|
||||||
|
loadEmailConnection();
|
||||||
} else if (tab === 'mission') {
|
} else if (tab === 'mission') {
|
||||||
renderMissionSettings();
|
renderMissionSettings();
|
||||||
} else if (tab === 'ai-prompts') {
|
} else if (tab === 'ai-prompts') {
|
||||||
|
|||||||
@ -2004,7 +2004,7 @@ if (bmcOriginalFetch) {
|
|||||||
<script src="/static/js/tag-picker.js?v=2.3"></script>
|
<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/task-template-selector.js?v=1.1"></script>
|
||||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=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/sms.js?v=1.1"></script>
|
||||||
<script src="/static/js/bug-report.js?v=1.6"></script>
|
<script src="/static/js/bug-report.js?v=1.6"></script>
|
||||||
{% include "shared/frontend/internal_message.html" %}
|
{% include "shared/frontend/internal_message.html" %}
|
||||||
|
|||||||
@ -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]:
|
def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]:
|
||||||
"""Return valid settlement choices and a deterministic recommendation."""
|
"""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(
|
customer = execute_query_single(
|
||||||
"SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s",
|
"SELECT id, hub_customer_id FROM tmodule_customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,),
|
||||||
@ -2410,11 +2420,6 @@ async def start_live_timer_v1(
|
|||||||
raise HTTPException(status_code=400, detail="sag_id is required")
|
raise HTTPException(status_code=400, detail="sag_id is required")
|
||||||
|
|
||||||
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
|
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"))
|
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id"))
|
||||||
if not bruger_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
|
user_name = payload.get("user_name") or default_user_name
|
||||||
entry_type = payload.get("entry_type") or "manuel"
|
entry_type = payload.get("entry_type") or "manuel"
|
||||||
kilde = payload.get("kilde") 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)
|
round_block_min = int(payload.get("round_block_min") or 30)
|
||||||
|
|
||||||
created = execute_query(
|
created = execute_query(
|
||||||
@ -2880,11 +2885,6 @@ async def create_manual_time_v1(
|
|||||||
raise HTTPException(status_code=400, detail="sag_id is required")
|
raise HTTPException(status_code=400, detail="sag_id is required")
|
||||||
|
|
||||||
customer_id = _resolve_case_customer_id(sag_id, payload.get("customer_id"))
|
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")
|
requested_user_ids = payload.get("medarbejder_ids")
|
||||||
is_bulk_request = requested_user_ids is not None
|
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")
|
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)
|
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")
|
billable_minutes = payload.get("fakturerbar_tid_min")
|
||||||
if billable_minutes is None:
|
if billable_minutes is None:
|
||||||
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
|
billable_minutes = _round_up_minutes(actual_minutes, round_block_min)
|
||||||
billable_minutes = int(billable_minutes)
|
billable_minutes = int(billable_minutes)
|
||||||
|
if not billable:
|
||||||
|
billable_minutes = 0
|
||||||
|
|
||||||
worked_date = payload.get("worked_date")
|
worked_date = payload.get("worked_date")
|
||||||
if not worked_date:
|
if not worked_date:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Sikkert fakturaudtræk med layoutanalyse og AI
|
$# Sikkert fakturaudtræk med layoutanalyse og AI
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|||||||
30
migrations/1035_email_connection_configuration.sql
Normal file
30
migrations/1035_email_connection_configuration.sql
Normal 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();
|
||||||
12
migrations/1036_sag_individual_assignments.sql
Normal file
12
migrations/1036_sag_individual_assignments.sql
Normal 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);
|
||||||
13
migrations/1037_case_type_details.sql
Normal file
13
migrations/1037_case_type_details.sql
Normal 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);
|
||||||
7
migrations/1038_sync_case_type_with_template.sql
Normal file
7
migrations/1038_sync_case_type_with_template.sql
Normal 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;
|
||||||
17
migrations/1039_set_supported_case_types.sql
Normal file
17
migrations/1039_set_supported_case_types.sql
Normal 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;
|
||||||
6
migrations/1040_tmodule_times_optional_customer.sql
Normal file
6
migrations/1040_tmodule_times_optional_customer.sql
Normal 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;
|
||||||
|
|
||||||
22
migrations/1041_seed_internal_case_customer.sql
Normal file
22
migrations/1041_seed_internal_case_customer.sql
Normal 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
|
||||||
|
);
|
||||||
|
|
||||||
@ -3,7 +3,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 = [];
|
let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = [];
|
||||||
async function api(url, options) {
|
async function api(url, options) {
|
||||||
const response = await fetch(url, {credentials: 'include', ...options});
|
const response = await fetch(url, {credentials: 'include', ...options});
|
||||||
@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
|
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,
|
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
|
||||||
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
|
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>`;
|
$('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.'; }
|
} 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)); });
|
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];
|
const visiblePanels = {
|
||||||
if (key && state.panels[key] && !state.restoring) state.panels[key].open = true;
|
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)}"]`));
|
if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`));
|
||||||
scheduleSave();
|
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() {
|
function buildTypes() {
|
||||||
const options = Array.from($('type').options), current = $('type').value;
|
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 keys = [...new Set([current, ...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 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 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');
|
const messageButton = $('caseMessageTypeButton');
|
||||||
$('type').classList.add('d-none'); $('type').removeAttribute('required');
|
$('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; });
|
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');
|
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); }
|
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) {
|
async function restore(draft) {
|
||||||
state.restoring = true;
|
state.restoring = true;
|
||||||
try {
|
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;
|
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; } });
|
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 || [];
|
state.tags = draft.tags || []; state.hardware = draft.hardware || [];
|
||||||
@ -171,7 +259,7 @@
|
|||||||
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
|
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
|
||||||
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
|
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
|
||||||
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
|
$('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`;
|
$('charCount').textContent = `${$('beskrivelse').value.length} tegn`;
|
||||||
} finally { state.restoring = false; }
|
} finally { state.restoring = false; }
|
||||||
state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload();
|
state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload();
|
||||||
@ -182,7 +270,7 @@
|
|||||||
const errors = [];
|
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>'); } };
|
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 (!$('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'); });
|
$('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'); } });
|
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; }
|
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)); });
|
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. */ }
|
} catch { /* Keep the built-in statuses available. */ }
|
||||||
const headings = Array.from(form.querySelectorAll(':scope > h5'));
|
const headings = Array.from(form.querySelectorAll(':scope > h5'));
|
||||||
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true);
|
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], false);
|
||||||
for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
|
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', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]);
|
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]);
|
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);
|
$('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag);
|
||||||
window.setTagPickerContext?.('case', null, 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(); } };
|
$('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));
|
$('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>');
|
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');
|
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));
|
form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0));
|
||||||
$('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); });
|
$('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); });
|
||||||
$('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload);
|
$('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.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []);
|
||||||
state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts();
|
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);
|
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. */ }
|
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. */ }
|
||||||
}};
|
}};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const root=document.querySelector('.planner-page'); if(!root)return;
|
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 $=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 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())}`;
|
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)}
|
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 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()}
|
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 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 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 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>`}}
|
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>`}}
|
||||||
|
|||||||
@ -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 () => {
|
window.addEventListener('focus', async () => {
|
||||||
if (reconnectDisabledForAuth) {
|
if (reconnectDisabledForAuth) {
|
||||||
const sessionOk = await hasActiveSession();
|
const sessionOk = await hasActiveSession();
|
||||||
|
|||||||
@ -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, '_get_user_id_from_request', lambda r: 7)
|
||||||
monkeypatch.setattr(cases, '_validate_user_id', lambda v: None)
|
monkeypatch.setattr(cases, '_validate_user_id', lambda v: None)
|
||||||
monkeypatch.setattr(cases, '_validate_group_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)
|
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}]}
|
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:
|
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'])
|
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)
|
case_args = next(args for query, args in conn.cur.calls if 'INSERT INTO sag_sager' in query)
|
||||||
assert 123.0 in case_args
|
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
|
||||||
|
|||||||
@ -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
|
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):
|
def test_closed_case_is_checked_before_allocation(monkeypatch):
|
||||||
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
|
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
|
||||||
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})
|
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})
|
||||||
|
|||||||
@ -159,9 +159,18 @@ def test_case_create_lists_contacts_for_selected_customer():
|
|||||||
def test_case_create_defaults_responsible_to_current_user():
|
def test_case_create_defaults_responsible_to_current_user():
|
||||||
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
|
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")
|
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 "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 "raw_responsible = data.get" in router
|
||||||
assert 'if "ansvarlig_bruger_id" in data else current_user_id' 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():
|
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
|
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():
|
def test_support_case_close_without_time_requires_explicit_confirmation():
|
||||||
template = Path("app/modules/sag/templates/index.html").read_text()
|
template = Path("app/modules/sag/templates/index.html").read_text()
|
||||||
source = Path("app/modules/sag/backend/router.py").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
|
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():
|
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():
|
||||||
template = Path("app/modules/sag/templates/index.html").read_text()
|
template = Path("app/modules/sag/templates/index.html").read_text()
|
||||||
source = Path("app/modules/sag/backend/router.py").read_text()
|
source = Path("app/modules/sag/backend/router.py").read_text()
|
||||||
|
|||||||
@ -76,6 +76,43 @@ def test_manual_time_bulk_resolves_current_user_and_removes_duplicates(monkeypat
|
|||||||
assert result["count"] == 2
|
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():
|
def test_case_time_form_sends_local_time_and_multiple_employees():
|
||||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||||
manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(
|
manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user