feat: enhance solution management with customer selection and improve call logging URLs

- Added customer search functionality in the solution management editor, allowing users to select a customer.
- Updated the API endpoint for incoming call logging to use the local recipient extension.
- Implemented a new case number navigation feature in the global search.
- Modified case creation logic to include relations and adjust module visibility.
- Added tests for new features including customer selection in solutions and call logging URL changes.
This commit is contained in:
Christian 2026-10-03 14:06:51 +02:00
parent 4058451565
commit 3387f6c475
28 changed files with 2820 additions and 93 deletions

View File

@ -752,7 +752,7 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
state = 'ringing',
ended_at = NULL,
answered_at = NULL,
started_at = LEAST(mission_call_state.started_at, EXCLUDED.started_at),
started_at = EXCLUDED.started_at,
updated_at = NOW(),
last_payload = jsonb_set(
EXCLUDED.last_payload,

View File

@ -27,9 +27,9 @@ class MissionService:
@staticmethod
def get_ring_timeout_seconds() -> int:
# A Yealink "Incoming call" event has no matching event when the
# caller abandons the call. Keep it briefly, then remove it from the
# live boards so stale ringing phones cannot remain visible.
return 30
# caller abandons the call. Phones may not emit another event while
# ringing, so allow a full ring window before clearing a stale entry.
return 120
@staticmethod
def expire_stale_ringing_calls() -> None:
@ -44,7 +44,7 @@ class MissionService:
ended_at = COALESCE(ended_at, NOW()),
updated_at = NOW()
WHERE state = 'ringing'
AND started_at < (NOW() - make_interval(secs => %s))
AND updated_at < (NOW() - make_interval(secs => %s))
""",
(timeout_seconds,),
)
@ -211,7 +211,6 @@ class MissionService:
logger.warning("Mission table missing: mission_call_state (active calls unavailable)")
return []
MissionService.expire_stale_ringing_calls()
rows = execute_query(
"""
SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at,
@ -1475,7 +1474,7 @@ class MissionService:
return {
"kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default),
"active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, []),
"active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, None),
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),

View File

@ -1546,7 +1546,8 @@ async def get_location_audit(
@router.get("/locations/search", response_model=LocationSearchResponse)
async def search_locations(
q: str = Query(..., min_length=1, max_length=255, description="Search query (name or address)"),
limit: int = Query(10, ge=1, le=100)
limit: int = Query(10, ge=1, le=100),
customer_id: Optional[int] = Query(None, ge=1),
):
"""
Search for locations by name or address.
@ -1562,31 +1563,38 @@ async def search_locations(
search_term = f"%{q}%"
# Search locations_locations (case-insensitive match on name, street, city)
search_query = """
customer_filter = " AND customer_id = %s" if customer_id is not None else ""
search_query = f"""
SELECT * FROM locations_locations
WHERE deleted_at IS NULL
{customer_filter}
AND (
name ILIKE %s
OR address_street ILIKE %s
OR address_postal_code ILIKE %s
OR address_city ILIKE %s
)
ORDER BY name ASC
LIMIT %s
"""
results = execute_query(search_query, (search_term, search_term, search_term, limit))
params = ([customer_id] if customer_id is not None else []) + [search_term] * 4 + [limit]
results = execute_query(search_query, tuple(params))
# Get total count (without limit) for the search
count_query = """
count_query = f"""
SELECT COUNT(*) as total FROM locations_locations
WHERE deleted_at IS NULL
{customer_filter}
AND (
name ILIKE %s
OR address_street ILIKE %s
OR address_postal_code ILIKE %s
OR address_city ILIKE %s
)
"""
count_result = execute_query(count_query, (search_term, search_term, search_term))
count_params = ([customer_id] if customer_id is not None else []) + [search_term] * 4
count_result = execute_query(count_query, tuple(count_params))
total_count = count_result[0]['total'] if count_result else 0
locations = [Location(**row) for row in results]

View File

@ -588,6 +588,29 @@ def detail_location_view(id: int = Path(..., gt=0)):
location = location[0] # Get first result
related_cases = execute_query(
"""
WITH RECURSIVE location_tree AS (
SELECT id, parent_location_id, ARRAY[id] AS path
FROM locations_locations
WHERE id = %s AND deleted_at IS NULL
UNION ALL
SELECT child.id, child.parent_location_id, parent.path || child.id
FROM locations_locations child
JOIN location_tree parent ON child.parent_location_id = parent.id
WHERE child.deleted_at IS NULL AND NOT child.id = ANY(parent.path)
)
SELECT DISTINCT s.id, s.titel, s.status, s.template_key, s.updated_at,
s.customer_id, ll.id AS matched_location_id, ll.name AS matched_location_name
FROM location_tree tree
JOIN sag_lokationer sl ON sl.location_id = tree.id AND sl.deleted_at IS NULL
JOIN sag_sager s ON s.id = sl.sag_id AND s.deleted_at IS NULL
JOIN locations_locations ll ON ll.id = tree.id
ORDER BY s.updated_at DESC NULLS LAST, s.id DESC
""",
(id,),
) or []
hierarchy = []
current_parent_id = location.get("parent_location_id")
while current_parent_id:
@ -907,6 +930,7 @@ def detail_location_view(id: int = Path(..., gt=0)):
location["hierarchy"] = hierarchy
location["children"] = children
location["relation_tree"] = relation_tree
location["related_cases"] = related_cases
location["direct_child_nodes"] = direct_child_nodes
location["related_location_count"] = max(len(relation_rows) - 1, 0)
location["same_customer_locations"] = same_customer_locations

View File

@ -506,6 +506,12 @@
<i class="bi bi-diagram-3 me-2"></i>Relationer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="casesTab" data-bs-toggle="tab" data-bs-target="#casesContent" type="button" role="tab" aria-controls="casesContent" aria-selected="false">
<i class="bi bi-ticket-detailed me-2"></i>Sager
<span class="location-tab-count-badge ms-1">{{ location.related_cases|length if location.related_cases else 0 }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="hardwareTab" data-bs-toggle="tab" data-bs-target="#hardwareContent" type="button" role="tab" aria-controls="hardwareContent" aria-selected="false">
<i class="bi bi-hdd-stack me-2"></i>Hardware på lokation
@ -521,6 +527,39 @@
<!-- Tab Content -->
<div class="tab-content">
<!-- Cases for this location and descendant locations -->
<div class="tab-pane fade" id="casesContent" role="tabpanel" aria-labelledby="casesTab">
<div class="card border-0 mb-4">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div>
<h5 class="card-title mb-0">Sager på lokationen</h5>
<div class="small text-muted">Sager knyttet til denne lokation eller en af dens underlokationer.</div>
</div>
<span class="badge text-bg-primary">{{ location.related_cases|length if location.related_cases else 0 }} sager</span>
</div>
<div class="card-body">
{% if location.related_cases %}
<div class="list-group list-group-flush">
{% for case in location.related_cases %}
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3 flex-wrap" href="/sag/{{ case.id }}/v4">
<span class="d-flex align-items-center gap-3">
<span class="tree-card-icon"><i class="bi bi-ticket-detailed"></i></span>
<span><strong>SAG-{{ case.id }} · {{ case.titel }}</strong><span class="d-block small text-muted">Lokation: {{ case.matched_location_name }}</span></span>
</span>
<span class="badge {{ 'text-bg-success' if case.status == 'åben' else 'text-bg-secondary' }}">{{ case.status or 'Ukendt status' }}</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
Der er ingen sager knyttet til denne lokation eller dens underlokationer.
</div>
{% endif %}
</div>
</div>
</div>
<!-- Tab 1: Information -->
<div class="tab-pane fade show active" id="infoContent" role="tabpanel" aria-labelledby="infoTab">
<section class="card border-0 mb-4">

View File

@ -325,11 +325,26 @@ def case_allocations(sag_id: int, current_user: dict = Depends(get_current_user)
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
waiting.status AS deferred_case_current_status,
COALESCE(allocation_summary.planned_minutes, 0)::int AS planned_minutes,
COALESCE(allocation_summary.items, '[]'::json) AS allocations
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
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(EXTRACT(EPOCH FROM (allocation.ends_at-allocation.starts_at))/60), 0) AS planned_minutes,
json_agg(json_build_object(
'starts_at', allocation.starts_at,
'ends_at', allocation.ends_at,
'note', allocation.note,
'user_name', COALESCE(allocation_user.full_name, allocation_user.username, 'Ukendt')
) ORDER BY allocation.starts_at, allocation.id) AS items
FROM planner_allocations allocation
LEFT JOIN users allocation_user ON allocation_user.user_id=allocation.user_id
WHERE allocation.sag_id=child.id AND allocation.deleted_at IS NULL
) allocation_summary ON TRUE
WHERE relation.kilde_sag_id=%s AND LOWER(relation.relationstype)='undersag'
AND relation.deleted_at IS NULL
ORDER BY child.created_at, child.id""",

View File

@ -407,6 +407,13 @@ class CaseAssignmentsUpdate(BaseModel):
primary_user_id: Optional[int] = None
class CaseAssignmentWithReason(BaseModel):
group_id: int = Field(gt=0)
primary_user_id: Optional[int] = Field(default=None, gt=0)
reason: str = Field(min_length=1, max_length=2000)
content: str = Field(min_length=1, max_length=20000)
SAG_LIST_COLUMN_KEYS = (
"id", "company", "contact", "description", "type", "priority", "status",
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
@ -1678,6 +1685,78 @@ async def save_case_assignments(sag_id: int, payload: CaseAssignmentsUpdate, req
)
return await get_case_assignments(sag_id, payload.group_id)
@router.post("/sag/{sag_id}/assign-with-reason", dependencies=[Depends(case_edit_access)])
async def assign_case_with_reason(sag_id: int, payload: CaseAssignmentWithReason, request: Request,
current_user: dict = Depends(get_current_user)):
"""V5: save assignment and its internal explanation in one transaction."""
reason, content = payload.reason.strip(), payload.content.strip()
if not reason or not content:
raise HTTPException(status_code=400, detail="Kommentar og begrundelse skal udfyldes")
if not table_has_column("sag_kommentarer", "er_intern"):
raise HTTPException(status_code=409, detail="Interne kommentarer skal være understøttet før tildeling med begrundelse")
actor_id = int(current_user["id"])
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SELECT id, ansvarlig_bruger_id, assigned_group_id FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE", (sag_id,))
previous = cursor.fetchone()
if not previous:
raise HTTPException(status_code=404, detail="Case not found")
cursor.execute("SELECT name FROM groups WHERE id=%s", (payload.group_id,))
group = cursor.fetchone()
if not group:
raise HTTPException(status_code=400, detail="Gruppen findes ikke")
target_name = "Ingen primær ansvarlig"
if payload.primary_user_id is not None:
cursor.execute("""SELECT COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS name
FROM users u JOIN user_groups ug ON ug.user_id=u.user_id
WHERE u.user_id=%s AND ug.group_id=%s AND u.is_active=true""",
(payload.primary_user_id, payload.group_id))
target = cursor.fetchone()
if not target:
raise HTTPException(status_code=400, detail="Medarbejderen skal være aktiv og medlem af gruppen")
target_name = target["name"]
cursor.execute("SELECT COALESCE(NULLIF(TRIM(full_name), ''), username) AS name FROM users WHERE user_id=%s", (actor_id,))
actor = cursor.fetchone()
if not actor:
raise HTTPException(status_code=403, detail="Brugeren findes ikke")
cursor.execute("SELECT COALESCE(NULLIF(TRIM(full_name), ''), username) AS name FROM users WHERE user_id=%s", (previous["ansvarlig_bruger_id"],))
old_user = cursor.fetchone()
cursor.execute("SELECT name FROM groups WHERE id=%s", (previous["assigned_group_id"],))
old_group = cursor.fetchone()
note = (f"{content}\n\nTildeling: {old_user['name'] if old_user else 'Ingen ansvarlig'} → {target_name}"
f"\nGruppe: {old_group['name'] if old_group else 'Ingen gruppe'} → {group['name']}"
f"\nBegrundelse: {reason}")
# V4 displays stored comment HTML. V5 assignment notes are plain
# text, including user-entered reasons and employee/group names.
note = html.escape(note)
# Keep other employees in the chosen group; remove incompatible rows
# on a group change rather than discarding all existing colleagues.
cursor.execute("""DELETE FROM sag_case_assignees a WHERE a.sag_id=%s
AND NOT EXISTS (SELECT 1 FROM user_groups ug WHERE ug.user_id=a.user_id AND ug.group_id=%s)""",
(sag_id, payload.group_id))
if payload.primary_user_id is not None:
cursor.execute("""INSERT INTO sag_case_assignees (sag_id, user_id, assigned_by_user_id)
VALUES (%s, %s, %s) ON CONFLICT (sag_id, user_id) DO NOTHING""",
(sag_id, payload.primary_user_id, actor_id))
cursor.execute("UPDATE sag_sager SET assigned_group_id=%s, ansvarlig_bruger_id=%s WHERE id=%s",
(payload.group_id, payload.primary_user_id, sag_id))
cursor.execute("""INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked, er_intern)
VALUES (%s, %s, %s, false, true) RETURNING *""", (sag_id, actor["name"], note))
comment = dict(cursor.fetchone())
conn.commit()
return {"comment": comment, "group_id": payload.group_id, "primary_user_id": payload.primary_user_id}
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
logger.exception("Could not save assignment with reason for case %s", sag_id)
raise HTTPException(status_code=500, detail="Tildeling og kommentar blev ikke gemt")
finally:
release_db_connection(conn)
@router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
async def list_todo_steps(sag_id: int):
try:
@ -3013,6 +3092,48 @@ async def list_case_hardware(sag_id: int):
logger.error("❌ Error listing case hardware: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case hardware")
@router.get("/sag/{sag_id}/locations/{location_id}/hardware")
async def list_case_location_hardware(sag_id: int, location_id: int):
"""List hardware physically assigned to a case location and its descendants."""
try:
case = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not case:
raise HTTPException(status_code=404, detail="Case not found")
linked_location = execute_query(
"SELECT id FROM sag_lokationer WHERE sag_id = %s AND location_id = %s AND deleted_at IS NULL",
(sag_id, location_id),
)
if not linked_location:
raise HTTPException(status_code=404, detail="Location is not linked to this case")
return execute_query(
"""
WITH RECURSIVE location_tree AS (
SELECT id, name, parent_location_id, ARRAY[id] AS path
FROM locations_locations
WHERE id = %s AND deleted_at IS NULL
UNION ALL
SELECT child.id, child.name, child.parent_location_id, parent.path || child.id
FROM locations_locations child
JOIN location_tree parent ON child.parent_location_id = parent.id
WHERE child.deleted_at IS NULL AND NOT child.id = ANY(parent.path)
)
SELECT h.id, h.asset_type, h.brand, h.model, h.serial_number,
h.customer_asset_id, h.internal_asset_id, h.status,
tree.id AS location_id, tree.name AS location_name
FROM location_tree tree
JOIN hardware_assets h ON h.current_location_id = tree.id AND h.deleted_at IS NULL
ORDER BY tree.name, h.asset_type, h.brand, h.model, h.serial_number
""",
(location_id,),
) or []
except HTTPException:
raise
except Exception as exc:
logger.error("❌ Error listing hardware for case %s location %s: %s", sag_id, location_id, exc)
raise HTTPException(status_code=500, detail="Failed to list location hardware")
@router.post("/sag/{sag_id}/hardware")
async def add_case_hardware(sag_id: int, data: dict):
"""Add hardware to case."""

View File

@ -418,7 +418,7 @@ async def list_solutions(
tuple(params),
) or {"total": 0}
items = execute_query(
f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, c.name AS customer_name,
f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, sg.customer_id, c.name AS customer_name,
COALESCE(creator.full_name,creator.username) AS created_by,
COALESCE(updater.full_name,updater.username) AS updated_by,
ka.id AS article_id, ka.status AS article_status

View File

@ -959,6 +959,7 @@ async def sag_detaljer(request: Request, sag_id: int):
raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/v5", response_class=HTMLResponse)
@router.get("/sag/{sag_id}/v4", response_class=HTMLResponse)
async def sag_detaljer_v4(request: Request, sag_id: int):
"""Display the active V4 case page."""
@ -1293,13 +1294,15 @@ async def sag_detaljer_v4(request: Request, sag_id: int):
logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = []
# V4 uses the canonical workflow statuses only. Legacy values are
# still rendered as the case's current value, but must not become a
# selectable transition in its status or trigger menus.
status_options = _fetch_case_status_options()
current_status = str(sag.get("status") or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
template_name = "modules/sag/templates/detail_v4.html"
if request.url.path.rstrip("/").endswith("/v5"):
template_name = "modules/sag/templates/detail_v5.html"
response = templates.TemplateResponse(template_name, {
"request": request,
"case": sag,

View File

@ -307,7 +307,8 @@
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
</div>
<!-- Section: Relations -->
<!-- Selected only when the chosen case type needs relations. -->
<section id="caseRelationsSection" class="d-none">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4">
<!-- Contact Search -->
@ -338,6 +339,7 @@
<input type="hidden" id="customer_id" name="customer_id">
</div>
</div>
</section>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<div class="row g-4 mb-4">
@ -350,7 +352,7 @@
{% endfor %}
</select>
</div>
<div class="col-md-6">
<div class="col-md-6 d-none" id="caseGroupField">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id">
<option value="">Ingen</option>
@ -388,6 +390,15 @@
</div>
</div>
<section id="caseTagsSection" class="mb-4">
<label class="form-label">Tags</label>
<div class="d-flex flex-wrap align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="caseTagsButton"><i class="bi bi-tags me-1"></i>Vælg tags</button>
<div id="cc-tag-chips" class="d-flex flex-wrap gap-1"></div>
</div>
<div id="cc-tag-suggestions"></div>
</section>
<hr class="my-4 opacity-25">
<!-- Fields that belong to the selected case type are rendered here. -->
@ -396,7 +407,7 @@
<div id="typeModuleAction" class="d-flex flex-wrap gap-2 mt-4 mb-4 d-none" aria-label="Tilføj moduler"></div>
<!-- Section: Hardware & AnyDesk -->
<section id="hardwareSection">
<section id="hardwareSection" class="d-none">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4">
<div class="col-12">
@ -1538,7 +1549,7 @@
// 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(false));
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(true));
await applyTelefoniPrefill();
await window.caseCreateUI?.init();
});
@ -1674,5 +1685,5 @@
}
});
</script>
<script src="/static/js/case-create.js?v=13"></script>
<script src="/static/js/case-create.js?v=14"></script>
{% endblock %}

View File

@ -938,6 +938,7 @@
.case-planner-day.is-today { background:#f0f8fc; box-shadow:inset 0 3px #1782aa; }
.case-planner-day-head { display:flex; justify-content:space-between; gap:.35rem; margin-bottom:.45rem; font-size:.73rem; color:#647788; text-transform:capitalize; }
.case-planner-calendar-item { margin-top:.35rem; padding:.38rem .45rem; border-radius:8px; border-left:3px solid #1976a3; background:#e8f4fa; color:#173f58; font-size:.7rem; line-height:1.3; }
.case-planner-calendar-item.is-subcase { border-left-color:#7357b8; background:#f1edff; color:#392968; }
@media (max-width: 900px) { .case-planner-week { min-width:780px; } .case-planner-calendar { overflow-x:auto; } }
@media (max-width: 1050px) { .case-planner-shell { grid-template-columns:1fr; } }
@ -1290,35 +1291,37 @@
margin-top: 0.5rem;
}
.case-relation-explorer { --rt-accent: #126b82; border: 1px solid rgba(18,107,130,.14); border-radius: 20px; overflow: hidden; background: var(--bg-card,#fff); box-shadow: 0 14px 38px rgba(28,61,78,.08); }
.case-relation-hero { display:flex; justify-content:space-between; align-items:center; gap:1rem; padding:1.5rem 1.65rem 1.3rem; color:#fff; background:radial-gradient(circle at 88% 8%,rgba(255,255,255,.2),transparent 24%),linear-gradient(118deg,#123c54,#126b82 58%,#2294a3); }
.case-relation-kicker { font-size:.68rem; letter-spacing:.16em; font-weight:800; opacity:.76; }
.case-relation-explorer { --rt-accent: var(--case-primary, #0f4c75); border: 1px solid var(--case-border, #d8e3eb); border-radius: var(--case-radius, 12px); overflow: hidden; background: var(--bg-card,#fff); box-shadow: 0 6px 18px rgba(15,76,117,.08); }
.case-relation-hero { display:flex; justify-content:space-between; align-items:center; gap:1rem; padding:1.15rem 1.25rem; color:var(--case-primary, #0f4c75); background:var(--case-primary-soft, #eef6fa); border-bottom:1px solid var(--case-border, #d8e3eb); }
.case-relation-kicker { font-size:.68rem; letter-spacing:.16em; font-weight:800; color:var(--case-muted, #587083); }
.case-relation-hero h2 { margin:.25rem 0; font-size:1.35rem; font-weight:760; }
.case-relation-hero p { margin:0; color:rgba(255,255,255,.78); font-size:.88rem; }
.case-relation-status { font-size:.78rem; color:rgba(255,255,255,.82); text-align:right; }
.case-relation-toolbar { display:flex; align-items:end; gap:.7rem; flex-wrap:wrap; padding:1rem 1.2rem; border-bottom:1px solid var(--border-color,#e4eaed); background:color-mix(in srgb,var(--accent,#126b82) 3%,var(--bg-card,#fff)); }
.case-relation-hero p { margin:0; color:var(--case-muted, #587083); font-size:.88rem; }
.case-relation-status { font-size:.78rem; color:var(--case-muted, #587083); text-align:right; }
.case-relation-toolbar { display:flex; align-items:end; gap:.7rem; flex-wrap:wrap; padding:1rem 1.2rem; border-bottom:1px solid var(--case-border, #d8e3eb); background:var(--bg-card,#fff); }
.case-relation-toolbar>label:not(.case-relation-search) { display:grid; gap:.25rem; min-width:145px; font-size:.7rem; color:var(--text-secondary,#657783); font-weight:700; }
.case-relation-search { position:relative; flex:1 1 280px; }
.case-relation-search i { position:absolute; left:.8rem; top:50%; transform:translateY(-50%); color:#78909a; }
.case-relation-search input { width:100%; min-height:36px; border:1px solid var(--border-color,#dce5e9); border-radius:9px; padding:.45rem .7rem .45rem 2.25rem; background:var(--bg-card,#fff); color:var(--text-primary,#23343d); }
.case-relation-summary { display:flex; justify-content:space-between; gap:.75rem; padding:.65rem 1.25rem; color:var(--text-secondary,#6b7c86); font-size:.74rem; border-bottom:1px solid var(--border-color,#edf0f2); }
.case-relation-search i { position:absolute; left:.8rem; top:50%; transform:translateY(-50%); color:var(--case-primary, #0f4c75); }
.case-relation-search input { width:100%; min-height:36px; border:1px solid var(--case-border,#d8e3eb); border-radius:8px; padding:.45rem .7rem .45rem 2.25rem; background:var(--bg-card,#fff); color:var(--text-primary,#23343d); }
.case-relation-summary { display:flex; justify-content:space-between; gap:.75rem; padding:.65rem 1.25rem; color:var(--case-muted,#587083); font-size:.74rem; border-bottom:1px solid var(--case-border,#d8e3eb); background:var(--case-primary-soft, #eef6fa); }
.case-relation-tree { padding:1.1rem 1.2rem 1.4rem; overflow:auto; }
.relation-tree-branch { margin-left:1.1rem; padding-left:1.05rem; border-left:1px solid #d7e3e8; }
.relation-tree-branch { margin-left:1.1rem; padding-left:1.05rem; border-left:1px solid var(--case-border, #d8e3eb); }
.relation-tree-node { position:relative; margin:.45rem 0; }
.relation-tree-node:before { content:""; position:absolute; top:1.05rem; left:-1.1rem; width:.8rem; border-top:1px solid #d7e3e8; }
.relation-tree-node-card { border:1px solid #e2eaed; border-radius:13px; background:var(--bg-card,#fff); box-shadow:0 3px 12px rgba(28,61,78,.045); transition:border-color .16s,box-shadow .16s,transform .16s; }
.relation-tree-node-card:hover { border-color:#acd0d8; box-shadow:0 8px 22px rgba(28,61,78,.09); transform:translateY(-1px); }
.relation-tree-node-card.is-current { border-color:#57aab4; background:linear-gradient(110deg,rgba(27,142,154,.075),var(--bg-card,#fff) 60%); box-shadow:0 0 0 2px rgba(27,142,154,.08); }
.relation-tree-node:before { content:""; position:absolute; top:1.05rem; left:-1.1rem; width:.8rem; border-top:1px solid var(--case-border, #d8e3eb); }
.relation-tree-node-card { border:1px solid var(--case-border, #d8e3eb); border-radius:10px; background:var(--bg-card,#fff); box-shadow:0 3px 10px rgba(15,76,117,.05); transition:border-color .16s,box-shadow .16s,transform .16s; }
.relation-tree-node-card:hover { border-color:rgba(15,76,117,.45); box-shadow:0 6px 18px rgba(15,76,117,.10); transform:translateY(-1px); }
.relation-tree-node-card.is-current { border-color:var(--case-primary, #0f4c75); background:linear-gradient(110deg,var(--case-primary-soft, #eef6fa),var(--bg-card,#fff) 60%); box-shadow:0 0 0 2px rgba(15,76,117,.08); }
.relation-tree-node-main { display:flex; align-items:center; gap:.65rem; padding:.75rem .85rem; }
.relation-tree-node-icon { display:grid; place-items:center; width:34px; height:34px; flex:0 0 34px; border-radius:10px; background:#edf6f7; color:#187487; font-size:1rem; }
.relation-tree-node-card.is-current .relation-tree-node-icon { background:#187487; color:#fff; }
.relation-tree-node-icon { display:grid; place-items:center; width:34px; height:34px; flex:0 0 34px; border-radius:8px; background:var(--case-primary-soft, #eef6fa); color:var(--case-primary, #0f4c75); font-size:1rem; }
.relation-tree-node-card.is-current .relation-tree-node-icon { background:var(--case-primary, #0f4c75); color:#fff; }
.relation-tree-node-info { min-width:0; flex:1; }
.relation-tree-title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:720; color:var(--text-primary,#263c47); text-decoration:none; }
.relation-tree-meta { display:flex; flex-wrap:wrap; gap:.35rem .6rem; margin-top:.18rem; color:var(--text-secondary,#71828b); font-size:.72rem; }
.relation-tree-node-actions { display:flex; align-items:center; gap:.4rem; }
.relation-tree-relation { border-radius:99px; padding:.22rem .55rem; background:#f0f4f6; color:#50636d; font-size:.68rem; font-weight:700; white-space:nowrap; }
.relation-tree-module-panel { padding:.15rem .85rem .85rem 3.45rem; }
.relation-tree-module-content { margin-top:.65rem; max-height:260px; overflow:auto; border:1px solid #e8eef0; border-radius:10px; padding:.65rem; background:color-mix(in srgb,var(--accent,#126b82) 2%,var(--bg-card,#fff)); }
.relation-tree-relation { border-radius:99px; padding:.22rem .55rem; background:var(--case-primary-soft, #eef6fa); color:var(--case-primary, #0f4c75); font-size:.68rem; font-weight:700; white-space:nowrap; }
.relation-tree-module-panel { padding:.2rem .85rem .85rem 3.45rem; }
.relation-tree-module-content { margin-top:.35rem; max-height:360px; overflow:auto; border:1px solid var(--case-border,#d8e3eb); border-radius:10px; padding:.65rem; background:var(--case-primary-soft,#eef6fa); }
.relation-tree-module-section + .relation-tree-module-section { border-top:1px solid var(--case-border,#d8e3eb); margin-top:.65rem; padding-top:.65rem; }
.relation-tree-module-section-title { color:var(--case-primary,#0f4c75); font-size:.72rem; font-weight:800; letter-spacing:.05em; text-transform:uppercase; margin-bottom:.3rem; }
.relation-tree-module-row { padding:.45rem 0; border-bottom:1px solid #e8eef0; font-size:.78rem; white-space:pre-wrap; overflow-wrap:anywhere; }
.relation-tree-module-row:last-child { border-bottom:0; }
[data-bs-theme="dark"] .case-relation-explorer { border-color:#334650; }
@ -2165,6 +2168,34 @@
color: var(--accent);
}
.rqloc-list {
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.65rem;
overflow: hidden;
background: var(--bs-body-bg, #fff);
}
.rqloc-option {
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
padding: 0.38rem 0.55rem;
border: 0;
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
background: transparent;
color: var(--bs-body-color, #212529);
text-align: left;
}
.rqloc-option:last-child { border-bottom: 0; }
.rqloc-option:hover, .rqloc-option.selected { background: rgba(15, 76, 117, 0.07); }
.rqloc-icon { color: #0f4c75; font-size: 0.85rem; }
.rqloc-copy { display: grid; min-width: 0; flex: 1; gap: 0; line-height: 1.2; }
.rqloc-copy strong { font-size: 0.82rem; font-weight: 650; }
.rqloc-copy strong, .rqloc-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rqloc-copy span { color: var(--bs-secondary-color, #6c757d); font-size: 0.70rem; }
.rqloc-type { display: none; }
.rqloc-check { color: #0f766e; font-size: 0.8rem; }
.relation-type-pill {
display: inline-flex;
align-items: center;
@ -2589,7 +2620,44 @@
.location-list-header,
.location-row {
grid-template-columns: 1.3fr 1fr auto;
grid-template-columns: minmax(0, 1.5fr) minmax(72px, 0.8fr) 34px 34px;
}
.location-list-header span:nth-child(n+3) {
font-size: 0;
text-align: center;
}
.location-row > button {
width: 30px;
height: 30px;
min-width: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
justify-self: end;
border-radius: 0.45rem;
}
.location-row > .btn-delete {
color: #dc3545;
border: 1px solid rgba(220, 53, 69, 0.35);
background: transparent;
font-size: 0.9rem;
}
.location-row > .btn-delete:hover {
color: #fff;
border-color: #dc3545;
background: #dc3545;
}
@media (max-width: 520px) {
.location-list-header, .location-row {
grid-template-columns: minmax(0, 1fr) 34px 34px;
}
.location-list-header span:nth-child(2), .location-row > small { display: none; }
}
.customer-list-header,
@ -4270,6 +4338,122 @@
border-color: rgba(133, 165, 190, 0.26);
}
/* V4 visual system: one calm blue palette for navigation and actions.
Status, warnings and errors keep their semantic Bootstrap colours. */
.case-detail-page-shell {
--case-primary: #0f4c75;
--case-primary-hover: #0b5f8f;
--case-primary-soft: #eef6fa;
--case-border: #d8e3eb;
--case-muted: #587083;
--case-radius: 12px;
}
.case-detail-page-shell,
.case-detail-page-shell .header-bg {
background-color: var(--case-primary);
}
.case-detail-page-shell .header-bg,
.case-detail-page-shell > .container-fluid.position-relative > .row > [class*="col-"] > .card,
.case-detail-page-shell .tab-content > .tab-pane > .card,
.case-detail-page-shell .left-module-card,
.case-detail-page-shell .right-module-card {
border-radius: var(--case-radius) !important;
}
.case-detail-page-shell .case-v4-widget-dashboard .card,
.case-detail-page-shell .left-module-card,
.case-detail-page-shell .right-module-card {
border-color: var(--case-border) !important;
box-shadow: 0 6px 18px rgba(15, 76, 117, 0.08) !important;
}
.case-detail-page-shell .case-v4-widget-dashboard .text-uppercase.fw-bold.text-muted,
.case-detail-page-shell .case-v4-widget-dashboard .text-primary,
.case-detail-page-shell .case-v4-widget-dashboard .text-success,
.case-detail-page-shell .case-v4-widget-dashboard .text-info {
color: var(--case-primary) !important;
}
.case-detail-page-shell .btn-primary,
.case-detail-page-shell .btn-outline-primary:hover,
.case-detail-page-shell .btn-outline-success:hover {
background-color: var(--case-primary) !important;
border-color: var(--case-primary) !important;
color: #fff !important;
}
.case-detail-page-shell .btn-outline-primary,
.case-detail-page-shell .btn-outline-success {
color: var(--case-primary);
border-color: rgba(15, 76, 117, 0.45);
background-color: transparent;
}
.case-detail-page-shell .nav-tabs .nav-link {
color: var(--case-muted);
border-radius: 8px 8px 0 0;
}
.case-detail-page-shell .nav-tabs .nav-link.active {
color: var(--case-primary);
border-color: var(--case-border) var(--case-border) #fff;
font-weight: 700;
}
.case-detail-page-shell .dropdown-menu,
.case-detail-page-shell .modal-content {
border-color: var(--case-border);
border-radius: var(--case-radius);
}
/* The lazy-loaded tabs use the same card, toolbar and data treatment as
Sagsdetaljer. Keep state colours on badges/buttons semantic. */
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .card,
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .history-timeline-shell {
--module-accent: var(--case-primary);
border: 1px solid var(--case-border) !important;
border-left: 1px solid var(--case-border) !important;
border-radius: var(--case-radius) !important;
box-shadow: 0 6px 18px rgba(15, 76, 117, 0.08) !important;
}
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .card-header,
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .history-timeline-toolbar,
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .table thead,
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .table-light {
background: var(--case-primary-soft) !important;
border-color: var(--case-border) !important;
color: var(--case-primary);
}
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .card-header i,
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .history-timeline-toolbar i {
color: var(--case-primary) !important;
}
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .form-control,
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .form-select,
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .input-group-text {
border-color: var(--case-border);
border-radius: 8px;
}
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .table > :not(caption) > * > * {
border-bottom-color: var(--case-border);
}
.case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .table-hover > tbody > tr:hover > * {
background-color: var(--case-primary-soft);
}
[data-bs-theme="dark"] .case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .card,
[data-bs-theme="dark"] .case-detail-page-shell #caseTabsContent .tab-pane:not(#details) .history-timeline-shell {
border-color: rgba(133, 165, 190, 0.28) !important;
background: rgba(20, 32, 45, 0.97) !important;
}
@media (prefers-reduced-motion: reduce) {
.case-detail-page-shell > .container-fluid.position-relative > .row > [class*="col-"] > .card,
.case-header-meta-line a { transition: none; }
@ -6715,7 +6899,13 @@
);
}
function showCreateRelatedModal() {
function showCreateRelatedModal(sourceCaseId = caseId, sourceTitle = '') {
relatedCaseSourceId = Number(sourceCaseId) || caseId;
const context = document.getElementById('createRelatedCaseContext');
if (context) {
const title = String(sourceTitle || (relatedCaseSourceId === caseId ? 'den aktuelle sag' : `sag #${relatedCaseSourceId}`)).trim();
context.textContent = `Den nye sag linkes direkte til ${title}.`;
}
if (!createRelatedCaseModalInstance) {
const modalEl = document.getElementById('createRelatedCaseModal');
if (!modalEl) {
@ -6728,6 +6918,24 @@
updateNewCaseRelationTypeHint();
}
// The relation tree can create a follow-up directly from any node.
// Keep the normal detail-page plus button as the current case.
let relatedCaseSourceId = caseId;
window.showCreateRelatedModalForCase = function (sourceCaseId, sourceTitle) {
relatedCaseSourceId = Number(sourceCaseId) || caseId;
const title = String(sourceTitle || `sag #${relatedCaseSourceId}`).trim();
document.getElementById('newCaseTitle').value = '';
document.getElementById('newCaseDescription').value = '';
const context = document.getElementById('createRelatedCaseContext');
if (context) context.textContent = `Den nye sag linkes direkte til ${title}.`;
const modalEl = document.getElementById('createRelatedCaseModal');
if (modalEl && window.bootstrap?.Modal) {
bootstrap.Modal.getOrCreateInstance(modalEl).show();
updateNewCaseRelationTypeHint();
}
setTimeout(() => document.getElementById('newCaseTitle')?.focus(), 250);
};
function relationTypeMeaning(type) {
const map = {
'Relateret til': {
@ -6808,13 +7016,22 @@
const title = document.getElementById('newCaseTitle').value;
const relationType = document.getElementById('newCaseRelationType').value;
const description = document.getElementById('newCaseDescription').value;
const customerId = (caseCustomerId || wikiCustomerId || null);
const sourceCaseId = Number(relatedCaseSourceId) || caseId;
let customerId = (caseCustomerId || wikiCustomerId || null);
if (!title) {
showCaseFeedback('Titel er påkrævet');
return;
}
if (sourceCaseId !== caseId) {
try {
const sourceResponse = await fetch(`/api/v1/sag/${sourceCaseId}`, { credentials: 'include' });
if (sourceResponse.ok) customerId = (await sourceResponse.json())?.customer_id || customerId;
} catch (_) {
// The normal customer validation below gives the user the actionable error.
}
}
if (!customerId) {
showCaseFeedback('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
return;
@ -6840,7 +7057,7 @@
const newCase = await caseResponse.json();
// 2. Create the relation
const relationResponse = await fetch(`/api/v1/sag/${caseId}/relationer`, {
const relationResponse = await fetch(`/api/v1/sag/${sourceCaseId}/relationer`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
@ -6919,6 +7136,7 @@
let embeddedPlannerWeekAnchor = new Date();
let embeddedPlannerItems = [];
let embeddedPlannerSubcases = [];
function embeddedPlannerDateKey(value) {
const date = value instanceof Date ? value : new Date(value);
@ -6948,6 +7166,25 @@
const items = embeddedPlannerItems.filter((item) => embeddedPlannerDateKey(item.starts_at) === key);
return `<div class="case-planner-day ${key === todayKey ? 'is-today' : ''}" onclick="openEmbeddedPlannerSlot('${key}')"><div class="case-planner-day-head"><span>${day.toLocaleDateString('da-DK', {weekday:'short'})}</span><span><strong>${day.getDate()}</strong> <i class="bi bi-plus-circle text-primary ms-1"></i></span></div>${items.length ? items.map((item) => { const startTime = new Date(item.starts_at); const endTime = new Date(item.ends_at); return `<div class="case-planner-calendar-item"><strong>${startTime.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})}–${endTime.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})}</strong><div>${escapeHtml(item.user_name || 'Ukendt')}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`; }).join('') : '<div class="small text-muted opacity-50 mt-3">Klik for at planlægge</div>'}</div>`;
}).join('');
renderEmbeddedPlannerSubcaseCalendar();
}
function renderEmbeddedPlannerSubcaseCalendar() {
const host = document.getElementById('casePlannerSubcaseWeek');
if (!host) return;
const start = embeddedPlannerWeekStart(embeddedPlannerWeekAnchor);
const end = new Date(start);
end.setDate(end.getDate() + 6);
document.getElementById('casePlannerSubcaseWeekLabel').textContent = `${start.toLocaleDateString('da-DK', {day:'numeric', month:'short'})} – ${end.toLocaleDateString('da-DK', {day:'numeric', month:'short', year:'numeric'})}`;
const todayKey = embeddedPlannerDateKey(new Date());
const items = embeddedPlannerSubcases.flatMap((subcase) => (subcase.allocations || []).map((allocation) => ({...allocation, subcase}))).filter((allocation) => allocation.starts_at);
host.innerHTML = Array.from({length: 7}, (_, index) => {
const day = new Date(start);
day.setDate(day.getDate() + index);
const key = embeddedPlannerDateKey(day);
const dayItems = items.filter((item) => embeddedPlannerDateKey(item.starts_at) === key);
return `<div class="case-planner-day ${key === todayKey ? 'is-today' : ''}" onclick="openEmbeddedPlannerSubcaseSlot('${key}')"><div class="case-planner-day-head"><span>${day.toLocaleDateString('da-DK', {weekday:'short'})}</span><span><strong>${day.getDate()}</strong> <i class="bi bi-plus-circle text-primary ms-1"></i></span></div>${dayItems.length ? dayItems.map((item) => { const startTime = new Date(item.starts_at); const endTime = new Date(item.ends_at); return `<div class="case-planner-calendar-item is-subcase"><strong>#${item.subcase.id} · ${startTime.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})}–${endTime.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})}</strong><div>${escapeHtml(item.subcase.titel || '')}</div><div>${escapeHtml(item.user_name || 'Ukendt')}</div></div>`; }).join('') : '<div class="small text-muted opacity-50 mt-3">Klik for at planlægge undersag</div>'}</div>`;
}).join('');
}
function shiftEmbeddedPlannerWeek(direction) {
@ -6975,6 +7212,18 @@
bootstrap.Modal.getOrCreateInstance(document.getElementById('casePlannerSlotModal')).show();
}
function openEmbeddedPlannerSubcaseSlot(dateKey) {
if (!embeddedPlannerSubcases.length) return showCaseFeedback('Opret en undersag først');
const select = document.getElementById('casePlannerSubcaseSlotCase');
select.innerHTML = embeddedPlannerSubcases.map((subcase) => `<option value="${Number(subcase.id)}">#${subcase.id} · ${escapeHtml(subcase.titel || 'Uden titel')}</option>`).join('');
document.getElementById('casePlannerSubcaseSlotDate').value = dateKey;
document.getElementById('casePlannerSubcaseSlotUser').value = String(embeddedPlannerSubcases[0].ansvarlig_bruger_id || document.getElementById('casePlannerUser').value || '');
document.getElementById('casePlannerSubcaseSlotTime').value = document.getElementById('casePlannerTime').value || '08:00';
document.getElementById('casePlannerSubcaseSlotMinutes').value = document.getElementById('casePlannerMinutes').value || '60';
document.getElementById('casePlannerSubcaseSlotNote').value = '';
bootstrap.Modal.getOrCreateInstance(document.getElementById('casePlannerSubcaseSlotModal')).show();
}
async function saveEmbeddedPlannerSlot(event) {
event.preventDefault();
const button = document.getElementById('casePlannerSlotSaveBtn');
@ -6999,6 +7248,23 @@
}
}
async function saveEmbeddedPlannerSubcaseSlot(event) {
event.preventDefault();
const button = document.getElementById('casePlannerSubcaseSlotSaveBtn');
const subcaseId = Number(document.getElementById('casePlannerSubcaseSlotCase').value);
const userId = document.getElementById('casePlannerSubcaseSlotUser').value;
if (!subcaseId || !userId) return showCaseFeedback('Vælg både undersag og medarbejder');
button.disabled = true;
try {
await createPlannerAllocationForCase(subcaseId, userId, document.getElementById('casePlannerSubcaseSlotDate').value, document.getElementById('casePlannerSubcaseSlotTime').value, document.getElementById('casePlannerSubcaseSlotMinutes').value, document.getElementById('casePlannerSubcaseSlotNote').value);
bootstrap.Modal.getOrCreateInstance(document.getElementById('casePlannerSubcaseSlotModal')).hide();
document.getElementById('planner').dataset.loaded = 'false';
await loadEmbeddedCasePlanner(true);
showCaseFeedback(`Tid er allokeret til undersag #${subcaseId}`, 'success');
} catch (error) { showCaseFeedback(error.message || 'Kunne ikke allokere tiden'); }
finally { button.disabled = false; }
}
async function loadEmbeddedCasePlanner(force = false) {
const pane = document.getElementById('planner');
if (!pane || (pane.dataset.loaded === 'true' && !force)) return;
@ -7032,12 +7298,16 @@
}).join('') : '<div class="text-muted py-3">Sagen er ikke planlagt endnu.</div>';
const subcases = plan.subcases || [];
embeddedPlannerSubcases = subcases;
renderEmbeddedPlannerSubcaseCalendar();
subcaseList.innerHTML = subcases.length ? `<div class="small fw-semibold text-muted mb-1">Oprettede undersager</div>${subcases.map((subcase) => {
const deferredText = subcase.deferred_until
? `Udsat til ${new Date(subcase.deferred_until).toLocaleDateString('da-DK')}`
: (subcase.deferred_until_case_id ? `Venter på #${subcase.deferred_until_case_id} → ${escapeHtml(subcase.deferred_until_status || 'statusskifte')}` : '');
return `<div class="case-planner-subcase"><div><div><strong>#${subcase.id}</strong> ${escapeHtml(subcase.titel || '')}</div><div class="small text-muted">${escapeHtml(subcase.responsible_name || 'Ingen ansvarlig')} · ${escapeHtml(subcase.status || '')}</div>${deferredText ? `<div class="small text-warning-emphasis mt-1"><i class="bi bi-hourglass-split me-1"></i>${deferredText}</div>` : ''}</div><a class="btn btn-sm btn-outline-primary align-self-start" href="/sag/${subcase.id}/v4">Åbn</a></div>`;
}).join('')}` : '<div class="small text-muted">Ingen undersager oprettet endnu.</div>';
const allocations = subcase.allocations || [];
const allocationText = allocations.length ? allocations.map((allocation) => { const start = new Date(allocation.starts_at); const end = new Date(allocation.ends_at); return `<div class="small text-muted mt-1"><i class="bi bi-calendar-event me-1"></i>${start.toLocaleDateString('da-DK', {day:'2-digit', month:'2-digit'})} ${start.toLocaleTimeString('da-DK', {hour:'2-digit', minute:'2-digit'})} · ${escapeHtml(allocation.user_name || 'Ukendt')} · ${plannerFormatMinutes((end - start) / 60000)}${allocation.note ? ` · ${escapeHtml(allocation.note)}` : ''}</div>`; }).join('') : '<div class="small text-muted mt-1">Ikke allokeret endnu</div>';
return `<div class="case-planner-subcase"><div class="flex-grow-1"><div><strong>#${subcase.id}</strong> ${escapeHtml(subcase.titel || '')}</div><div class="small text-muted">${escapeHtml(subcase.responsible_name || 'Ingen ansvarlig')} · ${escapeHtml(subcase.status || '')} · ${plannerFormatMinutes(subcase.planned_minutes)}</div>${allocationText}${deferredText ? `<div class="small text-warning-emphasis mt-1"><i class="bi bi-hourglass-split me-1"></i>${deferredText}</div>` : ''}</div><a class="btn btn-sm btn-outline-primary align-self-start" href="/sag/${subcase.id}/v4">Åbn</a></div>`;
}).join('')}<div id="plannerSubcaseAllocationEditor" class="mt-2"></div>` : '<div class="small text-muted">Ingen undersager oprettet endnu.</div>';
pane.dataset.loaded = 'true';
} catch (error) {
allocationList.innerHTML = `<div class="text-danger py-3">${escapeHtml(error.message)}</div>`;
@ -7058,6 +7328,32 @@
finally { button.disabled = false; }
}
function openPlannerSubcaseAllocation(subcaseId) {
const subcase = embeddedPlannerSubcases.find((item) => Number(item.id) === Number(subcaseId));
const host = document.getElementById('plannerSubcaseAllocationEditor');
if (!subcase || !host) return;
const userOptions = document.getElementById('plannerSubcaseUser').innerHTML;
const defaultDate = document.getElementById('casePlannerDate').value || new Date().toISOString().slice(0, 10);
host.innerHTML = `<form class="border rounded p-2 bg-light row g-2" onsubmit="savePlannerSubcaseAllocation(event, ${Number(subcase.id)})"><div class="col-12 small fw-semibold">Planlæg #${subcase.id} · ${escapeHtml(subcase.titel || '')}</div><div class="col-12"><select id="plannerExistingSubcaseUser" class="form-select form-select-sm">${userOptions}</select></div><div class="col-5"><input id="plannerExistingSubcaseDate" type="date" class="form-control form-control-sm" value="${defaultDate}" required></div><div class="col-3"><input id="plannerExistingSubcaseTime" type="time" class="form-control form-control-sm" value="08:00" required></div><div class="col-4"><input id="plannerExistingSubcaseMinutes" type="number" min="15" step="15" value="60" class="form-control form-control-sm" required></div><div class="col-12"><input id="plannerExistingSubcaseNote" class="form-control form-control-sm" placeholder="Note (valgfrit)"></div><div class="col-12 d-flex gap-2"><button type="submit" class="btn btn-sm btn-primary">Allokér tid</button><button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('plannerSubcaseAllocationEditor').innerHTML=''">Annuller</button></div></form>`;
const userSelect = document.getElementById('plannerExistingSubcaseUser');
userSelect.value = String(subcase.ansvarlig_bruger_id || document.getElementById('casePlannerUser').value || '');
}
async function savePlannerSubcaseAllocation(event, subcaseId) {
event.preventDefault();
const button = event.currentTarget.querySelector('[type="submit"]');
const userId = document.getElementById('plannerExistingSubcaseUser').value;
if (!userId) return showCaseFeedback('Vælg medarbejder før tiden allokeres');
button.disabled = true;
try {
await createPlannerAllocationForCase(subcaseId, userId, document.getElementById('plannerExistingSubcaseDate').value, document.getElementById('plannerExistingSubcaseTime').value, document.getElementById('plannerExistingSubcaseMinutes').value, document.getElementById('plannerExistingSubcaseNote').value);
document.getElementById('planner').dataset.loaded = 'false';
await loadEmbeddedCasePlanner(true);
showCaseFeedback(`Tid er allokeret til undersag #${subcaseId}`, 'success');
} catch (error) { showCaseFeedback(error.message || 'Kunne ikke allokere tiden'); }
finally { button.disabled = false; }
}
async function createPlannerSubcase(event) {
event.preventDefault();
const button = document.getElementById('plannerSubcaseCreateBtn');
@ -7096,6 +7392,10 @@
window.loadEmbeddedCasePlanner = loadEmbeddedCasePlanner;
window.createEmbeddedCaseAllocation = createEmbeddedCaseAllocation;
window.createPlannerSubcase = createPlannerSubcase;
window.openPlannerSubcaseAllocation = openPlannerSubcaseAllocation;
window.savePlannerSubcaseAllocation = savePlannerSubcaseAllocation;
window.openEmbeddedPlannerSubcaseSlot = openEmbeddedPlannerSubcaseSlot;
window.saveEmbeddedPlannerSubcaseSlot = saveEmbeddedPlannerSubcaseSlot;
window.shiftEmbeddedPlannerWeek = shiftEmbeddedPlannerWeek;
window.showEmbeddedPlannerCurrentWeek = showEmbeddedPlannerCurrentWeek;
window.selectEmbeddedPlannerDay = selectEmbeddedPlannerDay;
@ -7889,17 +8189,21 @@
<div class="location-list-header">
<span>Navn</span>
<span>Type</span>
<span>Slet</span>
<span title="Vis hardware"><i class="bi bi-hdd-stack"></i></span>
<span title="Fjern lokation"><i class="bi bi-x-lg"></i></span>
</div>
${locations.map(l => `
<div class="location-row">
<div class="fw-semibold">
<div class="fw-semibold d-flex align-items-center gap-2">
<i class="bi bi-geo-alt me-1 text-secondary"></i>
${l.name}
</div>
<small>${l.location_type || '-'}</small>
<button class="btn btn-sm btn-delete" onclick="unlinkLocation(${l.relation_id || l.id})" title="Slet">
✕
<button class="btn btn-sm btn-outline-primary" type="button" onclick="showLocationHardware(${Number(l.id)}, this.dataset.locationName)" data-location-name="${escapeHtml(l.name || '')}" title="Vis hardware på lokationen og underlokationer" aria-label="Vis hardware på ${escapeHtml(l.name || 'lokation')}">
<i class="bi bi-hdd-stack"></i>
</button>
<button class="btn btn-sm btn-delete" type="button" onclick="unlinkLocation(${l.relation_id || l.id})" title="Fjern lokation" aria-label="Fjern ${escapeHtml(l.name || 'lokation')}">
<i class="bi bi-x-lg"></i>
</button>
</div>
`).join('')}
@ -7913,6 +8217,61 @@
}
}
async function showLocationHardware(locationId, locationName) {
const modalId = 'caseLocationHardwareModal';
document.getElementById(modalId)?.remove();
const modalRoot = document.createElement('div');
modalRoot.innerHTML = `
<div class="modal fade" id="${modalId}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content border-0 shadow">
<div class="modal-header">
<div><h5 class="modal-title mb-1"><i class="bi bi-hdd-stack me-2 text-primary"></i>Hardware på lokationen</h5><div class="small text-muted">${escapeHtml(locationName)} og underlokationer</div></div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body" id="caseLocationHardwareBody"><div class="text-center text-muted py-5"><span class="spinner-border spinner-border-sm me-2"></span>Henter hardware…</div></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div>
</div>
</div>
</div>`;
document.body.appendChild(modalRoot.firstElementChild);
const modalElement = document.getElementById(modalId);
const modal = new bootstrap.Modal(modalElement);
modalElement.addEventListener('hidden.bs.modal', () => modalElement.remove(), { once: true });
modal.show();
try {
const response = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locationId}/hardware`, { credentials: 'include' });
const items = response.ok ? await response.json() : null;
if (!response.ok || !Array.isArray(items)) throw new Error('Hardwareoversigten kunne ikke hentes.');
const body = document.getElementById('caseLocationHardwareBody');
if (!body) return;
if (!items.length) {
body.innerHTML = '<div class="text-center text-muted py-5"><i class="bi bi-inbox fs-2 d-block mb-2"></i>Der er ikke registreret hardware på lokationen eller dens underlokationer.</div>';
return;
}
const byLocation = items.reduce((groups, item) => {
const key = String(item.location_id);
if (!groups[key]) groups[key] = { name: item.location_name || 'Ukendt lokation', items: [] };
groups[key].items.push(item);
return groups;
}, {});
body.innerHTML = `<div class="d-flex align-items-center justify-content-between mb-3"><span class="text-muted small">${items.length} ${items.length === 1 ? 'enhed' : 'enheder'}</span><span class="badge text-bg-primary">${Object.keys(byLocation).length} lokationer</span></div>${Object.values(byLocation).map(group => `
<section class="border rounded-3 mb-3 overflow-hidden">
<div class="px-3 py-2 bg-body-tertiary border-bottom fw-semibold"><i class="bi bi-geo-alt me-2 text-primary"></i>${escapeHtml(group.name)}<span class="badge text-bg-light border ms-2">${group.items.length}</span></div>
<div class="list-group list-group-flush">${group.items.map(item => {
const title = [item.brand, item.model].filter(Boolean).join(' ') || item.asset_type || 'Hardware';
const identifiers = [item.serial_number ? `SN ${item.serial_number}` : '', item.internal_asset_id ? `Intern ${item.internal_asset_id}` : '', item.customer_asset_id ? `Kunde ${item.customer_asset_id}` : ''].filter(Boolean).join(' · ');
const statusLabels = {active: 'Aktiv', faulty_reported: 'Fejl meldt', in_repair: 'Til reparation', replaced: 'Udskiftet', retired: 'Udfaset', unsupported: 'Ikke supporteret'};
return `<a class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2" href="/hardware/${Number(item.id)}"><span class="rounded-circle bg-primary-subtle text-primary d-flex align-items-center justify-content-center" style="width:2.25rem;height:2.25rem;flex:0 0 auto"><i class="bi bi-pc-display-horizontal"></i></span><span class="flex-grow-1 min-width-0"><strong class="d-block">${escapeHtml(title)}</strong><small class="text-muted">${escapeHtml(identifiers || 'Intet serienummer registreret')} · ${escapeHtml(item.asset_type || 'Hardware')}</small></span><span class="badge ${item.status === 'active' ? 'text-bg-success' : 'text-bg-secondary'}">${escapeHtml(statusLabels[item.status] || item.status || 'Ukendt')}</span><i class="bi bi-chevron-right text-muted"></i></a>`;
}).join('')}</div>
</section>`).join('')}`;
} catch (error) {
const body = document.getElementById('caseLocationHardwareBody');
if (body) body.innerHTML = `<div class="alert alert-danger mb-0">${escapeHtml(error.message || 'Hardwareoversigten kunne ikke hentes.')}</div>`;
}
}
// ============ Wiki Handling ============
async function loadCaseWiki(searchValue = '') {
const container = document.getElementById('wiki-list');
@ -10909,6 +11268,10 @@
</div>
<div id="casePlannerWeek" class="case-planner-week"></div>
</div>
<div class="case-planner-calendar">
<div class="case-planner-calendar-toolbar"><div><strong class="small">Undersagers ugeplan</strong><div class="small text-muted">Allokeringer på alle undersager</div></div><strong id="casePlannerSubcaseWeekLabel" class="small"></strong><span class="small text-muted">Klik på en dag for at allokere</span></div>
<div id="casePlannerSubcaseWeek" class="case-planner-week"></div>
</div>
<div id="casePlannerAllocationList" class="mt-3"><div class="text-muted py-3">Åbn fanen for at hente planen.</div></div>
</div>
</section>
@ -10934,6 +11297,10 @@
</div>
</div>
<div class="modal fade" id="casePlannerSubcaseSlotModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"><div class="modal-content border-0 rounded-4 shadow-lg"><form onsubmit="saveEmbeddedPlannerSubcaseSlot(event)"><div class="modal-header border-0 pb-1"><div><div class="small text-primary text-uppercase fw-bold">Undersag</div><h5 class="modal-title"><i class="bi bi-calendar-plus me-2"></i>Allokér tid</h5></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body pt-2"><div class="row g-3"><div class="col-12"><label class="form-label small fw-semibold">Undersag</label><select id="casePlannerSubcaseSlotCase" class="form-select" required></select></div><div class="col-12"><label class="form-label small fw-semibold">Medarbejder</label><select id="casePlannerSubcaseSlotUser" class="form-select" required>{% for user in assignment_users or [] %}<option value="{{ user.user_id }}">{{ user.display_name }}</option>{% endfor %}</select></div><div class="col-6"><label class="form-label small fw-semibold">Dato</label><input id="casePlannerSubcaseSlotDate" type="date" class="form-control" required></div><div class="col-6"><label class="form-label small fw-semibold">Start</label><input id="casePlannerSubcaseSlotTime" type="time" value="08:00" class="form-control" required></div><div class="col-12"><label class="form-label small fw-semibold">Allokeret tid</label><div class="input-group"><input id="casePlannerSubcaseSlotMinutes" type="number" min="15" step="15" value="60" class="form-control" required><span class="input-group-text">minutter</span></div></div><div class="col-12"><label class="form-label small fw-semibold">Note</label><textarea id="casePlannerSubcaseSlotNote" class="form-control" rows="2" placeholder="Hvad skal udføres?"></textarea></div></div></div><div class="modal-footer border-0 bg-light rounded-bottom-4"><button type="button" class="btn btn-light border" data-bs-dismiss="modal">Annuller</button><button id="casePlannerSubcaseSlotSaveBtn" type="submit" class="btn btn-primary px-4"><i class="bi bi-plus-lg me-1"></i>Allokér tid</button></div></form></div></div>
</div>
<div class="modal fade" id="casePlannerSlotModal" tabindex="-1" aria-labelledby="casePlannerSlotModalTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 rounded-4 shadow-lg">
@ -10966,7 +11333,10 @@
<section class="case-relation-explorer" aria-labelledby="caseTreeHeading">
<div class="case-relation-hero">
<div><div class="case-relation-kicker">SAGERNES SAMMENHÆNG</div><h2 id="caseTreeHeading">Relations-træ</h2><p>Find forbindelserne, og åbn modulindhold direkte på hver sag.</p></div>
<div class="case-relation-status" id="caseTreeStatus" aria-live="polite">Åbn fanen for at indlæse relationer.</div>
<div class="d-flex align-items-center gap-3 flex-wrap justify-content-end">
<div class="case-relation-status" id="caseTreeStatus" aria-live="polite">Åbn fanen for at indlæse relationer.</div>
<button type="button" class="btn btn-primary btn-sm" onclick="showCreateRelatedModal()"><i class="bi bi-plus-lg me-1"></i>Opret relateret sag</button>
</div>
</div>
<div class="case-relation-toolbar" role="search">
<label class="case-relation-search"><i class="bi bi-search" aria-hidden="true"></i><span class="visually-hidden">Søg i relationstræ</span><input id="relationTreeSearch" type="search" placeholder="Søg på sag, titel eller relation…" autocomplete="off"></label>
@ -12490,9 +12860,9 @@
<div class="relation-tree-node-main">
<span class="relation-tree-node-icon"><i class="bi ${current?'bi-bullseye':'bi-file-earmark-text'}"></i></span>
<div class="relation-tree-node-info"><a class="relation-tree-title" ${current?'aria-current="page"':''} href="/sag/${encodeURIComponent(safeId)}/v4">#${safeId} · ${_historyEscape(node.title || `Sag #${safeId}`)}</a><div class="relation-tree-meta"><span>${_historyEscape(typeLabel)}</span><span>·</span><span>${_historyEscape(statusLabel)}</span></div></div>
<div class="relation-tree-node-actions"><span class="relation-tree-relation">${_historyEscape(relationLabel)}</span><button class="btn btn-sm btn-outline-primary" type="button" data-relation-modules="${safeId}" aria-expanded="false"><i class="bi bi-layout-text-window-reverse me-1"></i>Se modulindhold</button></div>
<div class="relation-tree-node-actions"><span class="relation-tree-relation">${_historyEscape(relationLabel)}</span><button class="btn btn-sm btn-outline-primary" type="button" data-relation-create="${safeId}" data-relation-title="${_historyEscape(node.title || `Sag #${safeId}`)}" title="Opret relateret sag til #${safeId}" aria-label="Opret relateret sag til #${safeId}"><i class="bi bi-plus-lg"></i></button><button class="btn btn-sm btn-outline-primary" type="button" data-relation-modules="${safeId}" aria-expanded="false"><i class="bi bi-layout-text-window-reverse me-1"></i>Se modulindhold</button></div>
</div>
<div class="relation-tree-module-panel d-none" id="relation-modules-${safeId}"><div class="d-flex flex-wrap gap-1" role="group" aria-label="Moduler for sag ${safeId}"><button class="btn btn-sm btn-light border" type="button" data-relation-module="comments" data-case-id="${safeId}">Kommentarer</button><button class="btn btn-sm btn-light border" type="button" data-relation-module="todos" data-case-id="${safeId}">Opgaver</button><button class="btn btn-sm btn-light border" type="button" data-relation-module="files" data-case-id="${safeId}">Filer</button><button class="btn btn-sm btn-light border" type="button" data-relation-module="relations" data-case-id="${safeId}">Relationer</button><a class="btn btn-sm btn-light border" href="/sag/${safeId}/v4"><i class="bi bi-box-arrow-up-right me-1"></i>Alle moduler</a></div><div class="relation-tree-module-content d-none" id="relation-module-content-${safeId}" aria-live="polite"></div></div>
<div class="relation-tree-module-panel d-none" id="relation-modules-${safeId}"><div class="relation-tree-module-label">Indhold på sag #${safeId}</div><div class="relation-tree-module-content d-none" id="relation-module-content-${safeId}" aria-live="polite"></div><a class="btn btn-sm btn-link px-0 mt-2" href="/sag/${safeId}/v4"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn hele sagen</a></div>
</article>
${renderedChildren.length ? `<div class="relation-tree-branch">${renderedChildren.join('')}</div>` : ''}
</div>`;
@ -12523,6 +12893,28 @@
target.innerHTML = '<span class="text-danger small">Kunne ikke hente modulindhold.</span>';
}
}
async function loadRelationModuleOverview(caseId, target) {
const modules = [
['comments', 'Kommentarer', 'bi-chat-left-text'],
['todos', 'Opgaver', 'bi-check2-square'],
['files', 'Filer', 'bi-paperclip'],
['relations', 'Relationer', 'bi-diagram-3']
];
const routes = {comments:'kommentarer', todos:'todo-steps', files:'files', relations:'relationer'};
target.classList.remove('d-none');
target.innerHTML = '<span class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Henter indhold…</span>';
const results = await Promise.all(modules.map(async ([key, label, icon]) => {
try {
const response = await fetch(`/api/v1/sag/${caseId}/${routes[key]}`);
const items = response.ok ? await response.json() : [];
return Array.isArray(items) && items.length ? {key, label, icon, items} : null;
} catch (_) { return null; }
}));
const nonEmpty = results.filter(Boolean);
target.innerHTML = nonEmpty.length
? nonEmpty.map(({key, label, icon, items}) => `<section class="relation-tree-module-section"><div class="relation-tree-module-section-title"><i class="bi ${icon} me-1"></i>${label} <span class="text-muted">(${items.length})</span></div>${renderRelationModuleRows(key, items, caseId)}</section>`).join('')
: '<div class="text-muted small">Ingen moduler med indhold på denne sag.</div>';
}
async function loadCaseTree() {
const status = document.getElementById('caseTreeStatus');
const content = document.getElementById('caseTreeContent');
@ -12601,6 +12993,14 @@
document.getElementById('caseTreeContent').innerHTML = renderCaseTreeNodes(window.caseRelationTreeData || []);
});
document.getElementById('caseTreeContent')?.addEventListener('click', event => {
const createButton = event.target.closest('[data-relation-create]');
if (createButton) {
window.showCreateRelatedModalForCase?.(
Number(createButton.dataset.relationCreate),
createButton.dataset.relationTitle
);
return;
}
const moduleToggle = event.target.closest('[data-relation-modules]');
if (moduleToggle) {
const caseId = Number(moduleToggle.dataset.relationModules);
@ -12608,13 +13008,11 @@
const opening = panel.classList.contains('d-none');
panel.classList.toggle('d-none', !opening);
moduleToggle.setAttribute('aria-expanded', String(opening));
if (opening) {
loadRelationModuleOverview(caseId, document.getElementById(`relation-module-content-${caseId}`));
}
return;
}
const moduleButton = event.target.closest('[data-relation-module]');
if (!moduleButton) return;
const caseId = Number(moduleButton.dataset.caseId);
const target = document.getElementById(`relation-module-content-${caseId}`);
loadRelationModule(caseId, moduleButton.dataset.relationModule, target);
});
});
</script>
@ -16722,6 +17120,7 @@
<input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på...">
</div>
<div class="mb-3">
<div class="small text-muted mb-2" id="createRelatedCaseContext">Den nye sag linkes til den aktuelle sag.</div>
<label class="form-label">Hvordan forholder den nye sag sig til denne sag? *</label>
<select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
<option value="undersag">↳ Den nye sag er en undersag til denne</option>
@ -18039,6 +18438,9 @@
reloadCasePreservingContext();
} catch (e) {
console.error('saveCaseStatusFromTopbar error', e);
// The server is the authority for status transitions. In
// particular, it can reject Lukket when time is unfinished.
select.value = previousStatus;
showToast(e.message || 'Kunne ikke gemme status', 'danger');
}
}
@ -22987,32 +23389,63 @@
_showRelModal(
`<i class="bi bi-geo-alt me-2"></i>Lokation`,
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} – ${esc(caseTitle)}</label></div>
<div class="mb-2"><label class="form-label small fw-semibold">Søg lokation</label><input type="search" id="rqloc_search" class="form-control form-control-sm" placeholder="Navn, adresse eller by…" autocomplete="off"><div id="rqloc_results" class="mt-1 border rounded" style="max-height:180px;overflow-y:auto;display:none;"></div></div>
<div class="mb-2"><label class="form-label small fw-semibold">Søg blandt kundens lokationer</label><input type="search" id="rqloc_search" class="form-control form-control-sm" placeholder="Filtrer på navn, adresse eller by…" autocomplete="off"></div>
<div id="rqloc_results" class="rqloc-list" style="max-height:360px;overflow-y:auto;"><div class="p-3 text-muted small">Henter kundens lokationer…</div></div>
<div id="rqloc_selected" class="text-muted small">Vælg en eksisterende lokation fra listen.</div><input type="hidden" id="rqloc_id" value="">`,
`<div id="rqloc_create" class="d-none border-top pt-2 mt-2"><div class="small fw-semibold mb-2"><i class="bi bi-plus-circle me-1"></i>Opret ny lokation til kunden</div><div class="mb-2"><label class="form-label small fw-semibold">Navn *</label><input type="text" id="rqloc_create_name" class="form-control form-control-sm" placeholder="F.eks. Hovedkontor"></div><div class="mb-2"><label class="form-label small fw-semibold">Type</label><select id="rqloc_create_type" class="form-select form-select-sm"><option value="customer_site">Kundelokation</option><option value="bygning">Bygning</option><option value="etage">Etage</option><option value="rum">Rum</option><option value="vehicle">Køretøj</option></select></div><div class="small text-muted">Oprettes på sagens kunde og tilknyttes straks bagefter.</div><button type="button" class="btn btn-sm btn-primary mt-2" onclick="createRelLocation(${caseId})"><i class="bi bi-plus-lg me-1"></i>Opret og tilknyt</button></div>`,
`<button class="btn btn-sm btn-outline-secondary me-auto" type="button" onclick="toggleRelLocationCreate()"><i class="bi bi-plus-lg me-1"></i>Opret ny</button><button class="btn btn-sm btn-primary" onclick="_submitRelLocation(${caseId})"><i class="bi bi-link-45deg me-1"></i>Tilknyt</button>`
);
const input = document.getElementById('rqloc_search');
const results = document.getElementById('rqloc_results');
let searchTimer;
input.addEventListener('input', function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(async function () {
const query = input.value.trim();
if (query.length < 2) { results.style.display = 'none'; return; }
try {
const response = await fetch(`/api/v1/locations/search?q=${encodeURIComponent(query)}&limit=10`, { credentials: 'include' });
const payload = response.ok ? await response.json() : {};
const items = Array.isArray(payload.results) ? payload.results : [];
results.innerHTML = items.length ? items.map(function (item) {
const place = [item.address_street, item.address_postal_code, item.address_city].filter(Boolean).join(', ');
return `<button type="button" class="btn btn-link text-start text-decoration-none w-100 p-2 border-bottom rqloc-option" data-id="${Number(item.id || 0)}" data-label="${esc(item.name || '')}"><strong>${esc(item.name || 'Lokation')}</strong><span class="d-block text-muted small">${esc(item.customer_name || '')}${item.customer_name && place ? ' · ' : ''}${esc(place)}</span></button>`;
}).join('') : '<div class="p-2 text-muted small">Ingen lokationer fundet</div>';
results.style.display = 'block';
results.querySelectorAll('.rqloc-option').forEach(function (option) { option.addEventListener('click', function () { document.getElementById('rqloc_id').value = option.dataset.id; document.getElementById('rqloc_selected').textContent = '✓ Valgt: ' + option.dataset.label; input.value = option.dataset.label; results.style.display = 'none'; }); });
} catch { results.innerHTML = '<div class="p-2 text-danger small">Lokationer kunne ikke hentes</div>'; results.style.display = 'block'; }
}, 250);
const selectedId = document.getElementById('rqloc_id');
let customerLocations = [];
let locationSavePending = false;
const normalizeAddress = value => String(value || '').toLocaleLowerCase('da-DK').replace(/[^\p{L}\p{N}]+/gu, ' ').trim().replace(/\s+/g, ' ');
const caseAddress = normalizeAddress({{ ((customer.address if customer else '') ~ ' ' ~ (customer.postal_code if customer else '') ~ ' ' ~ (customer.city if customer else ''))|tojson }});
const renderCustomerLocations = () => {
const query = normalizeAddress(input.value);
const visible = customerLocations.filter(item => {
if (!query) return true;
const haystack = normalizeAddress([item.name, item.address_street, item.address_postal_code, item.address_city, item.location_type].join(' '));
return haystack.includes(query);
});
visible.sort((a, b) => {
const addressOf = item => normalizeAddress([item.address_street, item.address_postal_code, item.address_city].filter(Boolean).join(' '));
const aAddress = addressOf(a), bAddress = addressOf(b);
const aMatch = caseAddress && aAddress && (aAddress.includes(caseAddress) || caseAddress.includes(aAddress));
const bMatch = caseAddress && bAddress && (bAddress.includes(caseAddress) || caseAddress.includes(bAddress));
return Number(bMatch) - Number(aMatch) || String(a.name || '').localeCompare(String(b.name || ''), 'da');
});
results.innerHTML = visible.length ? visible.map(item => {
const place = [item.address_street, [item.address_postal_code, item.address_city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
const isSelected = Number(selectedId.value) === Number(item.id);
return `<button type="button" class="rqloc-option ${isSelected ? 'selected' : ''}" data-id="${Number(item.id || 0)}" data-label="${esc(item.name || '')}">
<span class="rqloc-icon"><i class="bi bi-geo-alt"></i></span>
<span class="rqloc-copy"><strong>${esc(item.name || 'Lokation')}</strong><span>${esc(place || 'Ingen adresse registreret')}</span></span>
${item.location_type ? `<span class="rqloc-type">${esc(item.location_type.replaceAll('_', ' '))}</span>` : ''}
<i class="bi ${isSelected ? 'bi-check-circle-fill' : 'bi-circle'} rqloc-check"></i>
</button>`;
}).join('') : `<div class="p-3 text-muted small">${query ? 'Ingen lokationer matcher søgningen.' : 'Kunden har ingen registrerede lokationer.'}</div>`;
};
results.addEventListener('click', async event => {
const option = event.target.closest('.rqloc-option');
if (!option || locationSavePending) return;
selectedId.value = option.dataset.id;
locationSavePending = true;
results.querySelectorAll('.rqloc-option').forEach(row => { row.disabled = true; });
document.getElementById('rqloc_selected').textContent = 'Gemmer lokation…';
await window._submitRelLocation(caseId);
locationSavePending = false;
});
input.addEventListener('input', renderCustomerLocations);
if (!caseAddCustomerId) {
results.innerHTML = '<div class="p-3 text-muted small">Tilknyt en kunde til sagen for at se kundens lokationer.</div>';
return;
}
fetch(`/api/v1/locations/by-customer/${encodeURIComponent(caseAddCustomerId)}`, { credentials: 'include' })
.then(response => response.ok ? response.json() : Promise.reject(new Error('load_failed')))
.then(items => { customerLocations = Array.isArray(items) ? items : []; renderCustomerLocations(); })
.catch(() => { results.innerHTML = '<div class="p-3 text-danger small">Kundens lokationer kunne ikke hentes.</div>'; });
};
window.toggleRelLocationCreate = function() {
@ -23058,7 +23491,15 @@
const response = await fetch(`/api/v1/sag/${caseId}/locations`, { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ location_id: locationId }) });
if (!response.ok) { const payload = await response.json().catch(() => ({})); throw new Error(payload.detail || 'Lokationen kunne ikke tilknyttes'); }
closeRelQaSurfaceAfterSave(); if (typeof showNotification === 'function') showNotification('Lokation tilknyttet ✓', 'success');
} catch (error) { if (typeof showNotification === 'function') showNotification(error.message || 'Lokationen kunne ikke tilknyttes', 'error'); if (saveBtn) saveBtn.disabled = false; }
} catch (error) {
if (typeof showNotification === 'function') showNotification(error.message || 'Lokationen kunne ikke tilknyttes', 'error');
if (saveBtn) saveBtn.disabled = false;
const selectedLabel = document.getElementById('rqloc_selected');
if (selectedLabel) selectedLabel.textContent = 'Kunne ikke gemme lokationen. Vælg den igen for at prøve på ny.';
const selectedId = document.getElementById('rqloc_id');
if (selectedId) selectedId.value = '';
results.querySelectorAll('.rqloc-option').forEach(row => { row.disabled = false; });
}
};
// ── Quick Løsning modal ───────────────────────────────────────────

View File

@ -0,0 +1,41 @@
{% extends "modules/sag/templates/detail_v4.html" %}
{% block title %}{{ case.titel }} · Sagsbord V5 - BMC Hub{% endblock %}
{% block extra_css %}
{{ super() }}
<link rel="stylesheet" href="/static/css/case-v5.css">
{% endblock %}
{% block content %}
{{ super() }}
<template id="caseV5Tools">
<div class="v5-version-line"><span>Sagsbord <strong>V5</strong></span><div><a href="/planner">Åbn Planner</a><a href="/sag/{{ case.id }}/v4">Åbn V4</a></div></div>
</template>
<template id="caseV5ComposerTools">
<div class="v5-composer-actions">
<button id="v5ActionToggle" type="button" class="btn btn-outline-secondary" aria-label="Tilføj handling" aria-expanded="false" aria-controls="v5ActionMenu"><i class="bi bi-plus-lg"></i></button>
<div id="v5SelectedActions" class="v5-selected-actions" aria-live="polite"></div>
</div>
<div id="v5ActionMenu" class="v5-action-menu" hidden>
<div class="v5-menu-title">Tilføj handling</div>
<div id="v5ActionOptions" class="v5-action-options"></div>
</div>
<div id="v5AssignmentFields" class="v5-assignment-fields" hidden>
<label for="v5GroupSearch">Søg gruppe</label>
<input id="v5GroupSearch" class="form-control form-control-sm" type="search" placeholder="Find gruppe…" autocomplete="off">
<div id="v5GroupOptions" class="v5-picker" aria-label="Vælg gruppe"></div>
<label for="v5UserSearch">Søg ansvarlig medarbejder</label>
<input id="v5UserSearch" class="form-control form-control-sm" type="search" placeholder="Vælg først en gruppe…" autocomplete="off" disabled>
<div id="v5UserOptions" class="v5-picker" aria-label="Vælg ansvarlig medarbejder"></div>
<label for="v5AssignmentReason">Begrundelse for tildeling <span class="text-danger">*</span></label>
<textarea id="v5AssignmentReason" class="form-control" rows="2" maxlength="2000" placeholder="Hvorfor overdrages sagen?"></textarea>
<p class="small text-muted mt-2 mb-0">Kommentar og begrundelse gemmes internt. Andre medarbejdere i den valgte gruppe bevares.</p>
</div>
<div id="v5SubmitFeedback" class="small mt-2" role="status" aria-live="polite"></div>
</template>
<script id="caseV5Data" type="application/json">{{ {
'caseId': case.id,
'groups': assignment_groups or [],
'groupId': case.assigned_group_id,
'userId': case.ansvarlig_bruger_id
}|tojson }}</script>
<script src="/static/js/case-v5.js"></script>
{% endblock %}

View File

@ -26,6 +26,7 @@
<div class="offcanvas offcanvas-end sol-editor" tabindex="-1" id="solutionEditor"><div class="offcanvas-header border-bottom"><div><div class="small text-uppercase text-primary fw-semibold">Løsning</div><h2 class="offcanvas-title h4 mb-0" id="editorHeading">Rediger løsning</h2></div><button class="btn-close" data-bs-dismiss="offcanvas"></button></div><div class="offcanvas-body">
<input type="hidden" id="editSagId"><div class="mb-3"><label class="form-label">Titel *</label><input class="form-control" id="editTitle"></div>
<div class="mb-3 position-relative"><label class="form-label">Kunde</label><input class="form-control" id="editCustomerSearch" autocomplete="off" placeholder="Søg og vælg kunde (min. 2 tegn)"><div id="editCustomerSelected" class="small text-muted mt-2"></div><div id="editCustomerResults" class="list-group position-absolute w-100 shadow-sm d-none" style="z-index:1060"></div></div>
<div class="row g-3 mb-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select" id="editType"><option>Support</option><option>Drift</option><option>Konsulent</option><option>Infrastruktur</option><option>Ekstern</option></select></div><div class="col-md-4"><label class="form-label">Resultat</label><select class="form-select" id="editResult"><option>Løst</option><option>Delvist</option><option>Workaround</option><option>Ej løst</option></select></div><div class="col-md-4"><label class="form-label">Synlighed</label><select class="form-select" id="editVisibility"><option value="internal">Kun internt</option><option value="general">Generel viden</option><option value="customer">Kun denne kunde</option></select></div></div>
<div class="mb-3"><label class="form-label">Problem og symptomer</label><textarea class="form-control" id="editProblem" rows="3"></textarea></div>
<div class="row g-3 mb-3"><div class="col-md-6"><label class="form-label">Årsag</label><textarea class="form-control" id="editCause" rows="3"></textarea></div><div class="col-md-6"><label class="form-label">Undersøgelse</label><textarea class="form-control" id="editInvestigation" rows="3"></textarea></div></div>
@ -38,21 +39,23 @@
<script>
(() => {
const state={items:new Map(),status:'',archived:false,timer:null,archiveId:null}; const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const state={items:new Map(),status:'',archived:false,timer:null,archiveId:null}; let editorCustomer=null,editorOriginalCustomerId=null,customerSearchTimer=null; const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const labels={draft:'Kladde',pending:'Afventer godkendelse',approved:'Godkendt',outdated:'Forældet',rejected:'Afvist'};
async function api(url,options={}){const r=await fetch(url,{credentials:'include',...options});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(typeof e.detail==='string'?e.detail:'Handlingen mislykkedes');}return r.json();}
async function load(){const box=document.getElementById('solState'),grid=document.getElementById('solGrid'),q=document.getElementById('solSearch').value.trim();box.classList.remove('d-none');box.innerHTML='<div class="spinner-border spinner-border-sm me-2"></div>Henter…';grid.innerHTML='';
try{const d=await api(`/api/v1/solutions?q=${encodeURIComponent(q)}&approval_status=${encodeURIComponent(state.status)}&include_archived=${state.archived}`);state.items=new Map(d.items.map(x=>[x.id,x]));document.getElementById('solCount').textContent=`${d.total} løsninger`;document.getElementById('solHeading').textContent=state.archived?'Arkiverede løsninger':'Aktive løsninger';box.classList.toggle('d-none',d.items.length>0);if(!d.items.length)box.innerHTML='<i class="bi bi-lightbulb fs-1 d-block mb-2"></i>Ingen løsninger matcher.';
grid.innerHTML=d.items.map(s=>`<div class="col-xxl-4 col-lg-6"><article class="sol-card h-100 p-4"><div class="d-flex justify-content-between gap-2 mb-2"><div class="d-flex gap-1"><span class="sol-chip ${s.approval_status==='approved'?'bg-success-subtle text-success-emphasis':s.approval_status==='pending'?'bg-warning-subtle text-warning-emphasis':'bg-secondary-subtle text-secondary-emphasis'}">${labels[s.approval_status]||esc(s.approval_status)}</span><span class="sol-chip bg-light border">${s.visibility==='general'?'Generel':s.visibility==='customer'?'Kunde':'Intern'}</span></div><span class="small text-muted">#${s.sag_id}</span></div><h3 class="h5 fw-bold">${esc(s.title)}</h3><p class="sol-summary text-muted">${esc(s.problem||s.description||'Ingen beskrivelse')}</p><div class="small text-muted mb-3">${esc(s.customer_name||'Ingen kunde')} · ${esc(s.updated_by||s.created_by||'Ukendt')}</div><div class="d-flex flex-wrap gap-2"><a class="btn btn-sm btn-outline-secondary" href="/sag/${s.sag_id}/v3#solution">Sag</a>${state.archived?`<button class="btn btn-sm btn-success" onclick="restoreManagedSolution(${s.id})"><i class="bi bi-arrow-counterclockwise me-1"></i>Gendan</button>`:`<button class="btn btn-sm btn-outline-primary" onclick="openManagedSolution(${s.id})"><i class="bi bi-pencil me-1"></i>Rediger</button>${s.approval_status==='pending'?`<button class="btn btn-sm btn-success" onclick="solutionAction(${s.sag_id},'approve')">Godkend</button>`:''}${s.approval_status==='approved'?`<button class="btn btn-sm btn-primary" onclick="publishManagedSolution(${s.sag_id})">Udgiv</button>`:''}<button class="btn btn-sm btn-outline-danger ms-auto" onclick="askArchiveSolution(${s.id})" title="Arkivér"><i class="bi bi-archive"></i></button>`}</div></article></div>`).join('');
}catch(e){box.classList.remove('d-none');box.innerHTML=`<i class="bi bi-exclamation-triangle text-danger d-block fs-2"></i>${esc(e.message)}`;}}
window.openManagedSolution=id=>{const s=state.items.get(id);if(!s)return;document.getElementById('editSagId').value=s.sag_id;document.getElementById('editorHeading').textContent=s.title;document.getElementById('editTitle').value=s.title||'';document.getElementById('editType').value=s.solution_type||'Support';document.getElementById('editResult').value=s.result||'Løst';document.getElementById('editVisibility').value=s.visibility||'internal';document.getElementById('editProblem').value=s.problem||'';document.getElementById('editCause').value=s.root_cause||'';document.getElementById('editInvestigation').value=s.investigation||'';document.getElementById('editDescription').value=s.description||'';document.getElementById('editWorkaround').value=s.workaround||'';document.getElementById('editTags').value=(s.tags||[]).join(', ');document.getElementById('editProducts').value=(s.products||[]).join(', ');document.getElementById('editNote').value='';document.getElementById('editorCaseLink').href=`/sag/${s.sag_id}/v3#solution`;bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('solutionEditor')).show();};
window.saveManagedSolution=async()=>{const id=document.getElementById('editSagId').value,payload={title:document.getElementById('editTitle').value.trim(),solution_type:document.getElementById('editType').value,result:document.getElementById('editResult').value,visibility:document.getElementById('editVisibility').value,problem:document.getElementById('editProblem').value.trim(),root_cause:document.getElementById('editCause').value.trim(),investigation:document.getElementById('editInvestigation').value.trim(),description:document.getElementById('editDescription').value.trim(),workaround:document.getElementById('editWorkaround').value.trim(),tags:document.getElementById('editTags').value.split(',').map(x=>x.trim()).filter(Boolean),products:document.getElementById('editProducts').value.split(',').map(x=>x.trim()).filter(Boolean),change_note:document.getElementById('editNote').value.trim()||'Redigeret fra Løsninger'};if(!payload.title||!payload.description){alert('Titel og endelig løsning skal udfyldes');return;}try{await api(`/api/v1/sag/${id}/solution`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});bootstrap.Offcanvas.getInstance(document.getElementById('solutionEditor'))?.hide();load();}catch(e){alert(e.message);}};
function renderEditorCustomer(){const selected=document.getElementById('editCustomerSelected'),input=document.getElementById('editCustomerSearch');if(editorCustomer){selected.innerHTML=`<i class="bi bi-building me-1"></i><strong>${esc(editorCustomer.name)}</strong> <button class="btn btn-link btn-sm p-0 ms-1" type="button" id="changeEditorCustomer">Skift</button>`;input.value='';document.getElementById('changeEditorCustomer').onclick=()=>{editorCustomer=null;renderEditorCustomer();input.focus();};}else selected.textContent='Ingen kunde valgt endnu';}
async function searchEditorCustomers(){const input=document.getElementById('editCustomerSearch'),results=document.getElementById('editCustomerResults'),q=input.value.trim();if(q.length<2){results.classList.add('d-none');results.innerHTML='';return;}try{const rows=await api(`/api/v1/search/customers?q=${encodeURIComponent(q)}`);if(input.value.trim()!==q)return;results.innerHTML=rows.length?rows.map(c=>`<button type="button" class="list-group-item list-group-item-action" data-customer-id="${Number(c.id)}" data-customer-name="${esc(c.name)}"><strong>${esc(c.name)}</strong><small class="d-block text-muted">${esc(c.email||c.cvr_nummer||'')}</small></button>`).join(''):'<div class="list-group-item text-muted">Ingen kunder fundet</div>';results.classList.remove('d-none');results.querySelectorAll('[data-customer-id]').forEach(button=>button.onclick=()=>{editorCustomer={id:Number(button.dataset.customerId),name:button.dataset.customerName};results.classList.add('d-none');renderEditorCustomer();});}catch(e){results.innerHTML=`<div class="list-group-item text-danger">${esc(e.message)}</div>`;results.classList.remove('d-none');}}
window.openManagedSolution=id=>{const s=state.items.get(id);if(!s)return;document.getElementById('editSagId').value=s.sag_id;document.getElementById('editorHeading').textContent=s.title;document.getElementById('editTitle').value=s.title||'';document.getElementById('editType').value=s.solution_type||'Support';document.getElementById('editResult').value=s.result||'Løst';document.getElementById('editVisibility').value=s.visibility||'internal';document.getElementById('editProblem').value=s.problem||'';document.getElementById('editCause').value=s.root_cause||'';document.getElementById('editInvestigation').value=s.investigation||'';document.getElementById('editDescription').value=s.description||'';document.getElementById('editWorkaround').value=s.workaround||'';document.getElementById('editTags').value=(s.tags||[]).join(', ');document.getElementById('editProducts').value=(s.products||[]).join(', ');document.getElementById('editNote').value='';editorOriginalCustomerId=Number(s.customer_id)||null;editorCustomer=editorOriginalCustomerId?{id:editorOriginalCustomerId,name:s.customer_name||`Kunde #${editorOriginalCustomerId}`}:null;renderEditorCustomer();document.getElementById('editorCaseLink').href=`/sag/${s.sag_id}/v3#solution`;bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('solutionEditor')).show();};
window.saveManagedSolution=async()=>{const id=document.getElementById('editSagId').value,payload={title:document.getElementById('editTitle').value.trim(),solution_type:document.getElementById('editType').value,result:document.getElementById('editResult').value,visibility:document.getElementById('editVisibility').value,problem:document.getElementById('editProblem').value.trim(),root_cause:document.getElementById('editCause').value.trim(),investigation:document.getElementById('editInvestigation').value.trim(),description:document.getElementById('editDescription').value.trim(),workaround:document.getElementById('editWorkaround').value.trim(),tags:document.getElementById('editTags').value.split(',').map(x=>x.trim()).filter(Boolean),products:document.getElementById('editProducts').value.split(',').map(x=>x.trim()).filter(Boolean),change_note:document.getElementById('editNote').value.trim()||'Redigeret fra Løsninger'};if(!payload.title||!payload.description){alert('Titel og endelig løsning skal udfyldes');return;}try{if(editorCustomer?.id&&editorCustomer.id!==editorOriginalCustomerId)await api(`/api/v1/sag/${id}/customer/replace`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({customer_id:editorCustomer.id})});await api(`/api/v1/sag/${id}/solution`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});bootstrap.Offcanvas.getInstance(document.getElementById('solutionEditor'))?.hide();load();}catch(e){alert(e.message);}};
window.solutionAction=async(sag,action)=>{try{await api(`/api/v1/sag/${sag}/solution/workflow`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action})});load();}catch(e){alert(e.message);}};
window.publishManagedSolution=async sag=>{try{const a=await api(`/api/v1/sag/${sag}/solution/publish`,{method:'POST'});location.href=`/knowledge/${a.id}`;}catch(e){alert(e.message);}};
window.askArchiveSolution=id=>{state.archiveId=id;document.getElementById('archiveSolutionName').textContent=state.items.get(id)?.title||'';bootstrap.Modal.getOrCreateInstance(document.getElementById('archiveSolutionModal')).show();};
document.getElementById('archiveConfirmBtn').addEventListener('click',async()=>{try{await api(`/api/v1/solutions/${state.archiveId}`,{method:'DELETE'});bootstrap.Modal.getInstance(document.getElementById('archiveSolutionModal'))?.hide();load();}catch(e){alert(e.message);}});
window.restoreManagedSolution=async id=>{try{await api(`/api/v1/solutions/${id}/restore`,{method:'POST'});load();}catch(e){alert(e.message);}};
document.querySelectorAll('.sol-filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.sol-filter').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.status=b.dataset.status;load();}));document.getElementById('archivedToggle').addEventListener('click',e=>{state.archived=!state.archived;e.currentTarget.classList.toggle('btn-danger',state.archived);e.currentTarget.classList.toggle('btn-outline-danger',!state.archived);load();});document.getElementById('solSearch').addEventListener('input',()=>{clearTimeout(state.timer);state.timer=setTimeout(load,280)});load();
document.querySelectorAll('.sol-filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.sol-filter').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.status=b.dataset.status;load();}));document.getElementById('archivedToggle').addEventListener('click',e=>{state.archived=!state.archived;e.currentTarget.classList.toggle('btn-danger',state.archived);e.currentTarget.classList.toggle('btn-outline-danger',!state.archived);load();});document.getElementById('solSearch').addEventListener('input',()=>{clearTimeout(state.timer);state.timer=setTimeout(load,280)});document.getElementById('editCustomerSearch').addEventListener('input',()=>{clearTimeout(customerSearchTimer);customerSearchTimer=setTimeout(searchEditorCustomers,250)});load();
})();
</script>
{% endblock %}

View File

@ -2656,7 +2656,7 @@ function buildYealinkActionUrls() {
const base = baseRaw.replace(/\/$/, '');
const tokenPart = token ? `token=${encodeURIComponent(token)}&` : '';
incomingEl.value = `${base}/api/v1/mission/webhook/telefoni/ringing?${tokenPart}call_id=$call_id&caller_number=$display_remote&queue_name=$local&recipient_extension=$active_user`;
incomingEl.value = `${base}/api/v1/mission/webhook/telefoni/ringing?${tokenPart}call_id=$call_id&caller_number=$display_remote&queue_name=$local&recipient_extension=$local`;
estEl.value = `${base}/api/v1/telefoni/established?${tokenPart}callid=$call_id&remote=$remote&display_remote=$display_remote&caller_id=$callerID&local=$local&active_user=$active_user&called_number=$calledNumber`;
termEl.value = `${base}/api/v1/telefoni/terminated?${tokenPart}callid=$call_id&duration=$call_duration`;
}

View File

@ -2289,6 +2289,9 @@ if (bmcOriginalFetch) {
navigateResults(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (navigateToCaseNumber(globalSearchInput.value)) {
return;
}
if (navigateToSagFromScan(e.target.value)) {
return;
}
@ -2531,6 +2534,19 @@ if (bmcOriginalFetch) {
return true;
}
function navigateToCaseNumber(value) {
// An exact #123 entry is an intentional direct case lookup. Keep this
// separate from scan handling so typing it does not navigate before Enter.
const match = String(value || '').trim().match(/^#\s*(\d+)$/);
if (!match) return false;
const sagId = Number(match[1]);
if (!Number.isSafeInteger(sagId) || sagId < 1) return false;
window.location.href = `/sag/${sagId}/v4`;
return true;
}
// Global search function
async function performGlobalSearch(query) {
if (navigateToSagFromScan(query)) {

View File

@ -0,0 +1,456 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sag V5 · Arbejdsrummet</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#f3f5f7;color:#243449;font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input,textarea{font:inherit}
button{cursor:default;border:1px solid #dbe2e9;border-radius:8px;padding:7px 12px;background:white;color:#243449}
a{color:inherit;text-decoration:none}
h1,h2,h3,p{margin:0}
h1{font-size:27px;letter-spacing:-.8px;line-height:1.3}
h2{font-size:16px}
h3{font-size:13px}
p+p{margin-top:9px}
small,.muted{color:#728095;font-size:12px}
.appbar{background:#142d43;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:13px 28px;gap:16px}
.brand{font-weight:800;letter-spacing:1px}
.brand span{font-weight:400;color:#a5b6c6;margin-left:12px}
.prototype{font-size:11px;letter-spacing:1px;text-transform:uppercase;color:#c4d6e5}
.switcher{display:flex;gap:5px;flex-wrap:wrap;padding:14px 28px;background:#fff;border-bottom:1px solid #e2e7ec}
.switcher a{padding:6px 12px;border-radius:6px;font-size:12px}
.switcher a.active{background:#e7f2ef;color:#177460;font-weight:700}
.shell{max-width:1500px;margin:auto;padding:25px 28px 48px}
.eyebrow{font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#7c8b9b;margin-bottom:8px}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.between{justify-content:space-between}
.badge,.chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:5px 10px;border-radius:6px;background:#edf1f5}
.green{background:#e4f3ed;color:#167158}
.amber{background:#fff1d9;color:#966018}
.primary{background:#177b68;color:white;border-color:#177b68;font-weight:600}
.hero{background:white;border:1px solid #e0e6ec;border-radius:12px;padding:22px 24px;margin-bottom:18px}
.heroline{margin-top:15px;gap:24px}
.next{margin-top:18px;border-top:1px solid #e7edf1;padding-top:12px;display:flex;justify-content:space-between;gap:12px;font-size:13px}
.next strong{color:#177b68}
.panel{background:#fff;border:1px solid #e0e6ec;border-radius:11px;overflow:hidden;margin-bottom:16px}
.panelhead{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:16px 20px;border-bottom:1px solid #edf0f4}
.panelbody{padding:19px 20px}
.description{border-left:3px solid #b6d8ce;padding-left:15px}
.nav{display:flex;gap:20px;flex-wrap:wrap;margin-bottom:18px;border-bottom:1px solid #dfe5eb}
.nav span{padding:10px 0;color:#778597;font-size:13px}
.nav .selected{border-bottom:2px solid #177b68;color:#177b68;font-weight:700}
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:20px}
.entry{display:flex;gap:12px;padding:19px 20px;border-bottom:1px solid #edf0f4}
.avatar{background:#e6eef7;color:#396084;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;flex-shrink:0;font-size:11px;font-weight:700}
.avatar.teal{background:#dff0e9;color:#177b68}
.entrycontent{flex:1;min-width:0;overflow-wrap:anywhere}
.entrytitle{display:flex;justify-content:space-between;gap:10px;margin-bottom:8px}
.entrytitle strong{font-size:13px}
.entry p{font-size:13px}
.event{background:#f8fafb}
.event .avatar{border-radius:8px}
.quote{background:#f5f7fa;border:1px solid #e6ebf0;border-radius:7px;padding:12px;margin-top:10px}
.composer{padding:20px}
.composer textarea{width:100%;height:100px;resize:none;border:0;background:#f7f9fb;border-radius:8px;padding:13px;color:#42546a}
.composerfooter{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}
.composerfooter .primary{margin-left:auto}
.plus{font-size:21px;line-height:1;padding:6px 11px}
.actionmenu{margin-top:15px;border:1px solid #dce5eb;border-radius:9px;padding:13px;background:#fff}
.menugrid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-top:9px}
.menugrid button{text-align:left;font-size:12px;padding:8px;border-color:transparent;background:#f6f8fa}
.menugrid .chosen{background:#e5f3ed;color:#167158;border-color:#b7d8cb}
.assignment{margin-top:12px;background:#f0f8f5;border-radius:8px;padding:13px}
.assignment label{display:block;font-size:11px;color:#477565;font-weight:700;margin-bottom:5px}
.assignment input{width:100%;min-width:0;border:1px solid #d6e6df;background:#fff;padding:8px 10px;border-radius:6px;color:#42546a}
.assignment .row>div{flex:1;min-width:180px}
.todo{display:flex;gap:10px;padding:11px 0;border-bottom:1px solid #edf0f4;font-size:13px}
.todo:last-child{border:0}
.check{width:16px;height:16px;border:1px solid #c4d2db;border-radius:4px;flex-shrink:0;margin-top:3px}
.check.done{background:#e1f0e9;color:#177b68;text-align:center;font-size:10px}
.moduleitem{padding:12px 0;border-bottom:1px solid #edf0f4;font-size:13px}
.moduleitem:last-child{border:0}
.moduleitem small{display:block;margin-top:3px}
.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}
.fact{min-width:0;overflow-wrap:anywhere}
.fact small{display:block;font-size:11px;margin-bottom:3px}
.fact strong{font-size:13px;font-weight:600}
.contactactions{display:flex;gap:6px;margin-top:12px}
.contactactions button{font-size:12px}
.note{font-size:11px;color:#8a96a5;text-align:center;margin-top:20px}
.journal .shell{max-width:1190px}
.journal .hero{border:0;background:transparent;padding:5px 0 20px;border-radius:0;border-bottom:1px solid #dbe2e9}
.journal h1{font-size:32px}
.journalgrid{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:24px}
.journal .entry{padding:22px}
.journal .stream{border-top:3px solid #177b68}
.journal .composer{background:#fcfdfd}
.journal .facts{grid-template-columns:1fr 1fr}
.journal .hero .facts{grid-template-columns:repeat(4,minmax(0,1fr));margin-top:18px}
.day{padding:14px 22px;background:#f6f8fa;color:#7b8c9e;font-size:11px;font-weight:700;letter-spacing:1px}
.board{background:#edf1f4}
.board .shell{max-width:1700px}
.board .hero{background:#17364d;color:white;border:0;padding:20px 24px}
.board .hero .eyebrow{color:#93b5c7}
.board .hero .next{border-color:#365569;color:#dae6ee}
.board .hero .next strong{color:#b9e6d7}
.boardgrid{display:grid;grid-template-columns:250px minmax(0,1fr) 280px;gap:18px}
.board .facts{grid-template-columns:1fr}
.board .fact{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
.board .fact strong{text-align:right}
.board .menugrid{grid-template-columns:repeat(3,minmax(0,1fr))}
.board .panelhead{padding:14px 17px}
.board .panelbody{padding:17px}
.board h1{font-size:24px}
@media(max-width:1100px){.boardgrid{grid-template-columns:220px minmax(0,1fr)}
.boardgrid>aside:last-child{grid-column:1/-1;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px}
.facts{grid-template-columns:repeat(3,minmax(0,1fr))}
.menugrid{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media(max-width:760px){.appbar{padding:12px 16px}
.brand span{display:none}
.prototype{font-size:9px}
.switcher{padding:10px 12px}
.shell{padding:18px 14px 30px}
.workspace,.journalgrid,.boardgrid{grid-template-columns:1fr}
.boardgrid>aside:last-child{display:block}
.hero{padding:18px}
.hero h1,.journal h1{font-size:24px}
.heroline{gap:10px}
.facts,.journal .hero .facts{grid-template-columns:1fr 1fr}
.menugrid,.board .menugrid{grid-template-columns:1fr 1fr}
.entry{padding:16px}
.entrytitle{flex-wrap:wrap}
.next{flex-wrap:wrap}
.composer{padding:16px}
.composerfooter .primary{margin-left:0;width:100%;margin-top:5px}
.nav{gap:15px}
.assignment .row>div{min-width:100%}
.board .facts{grid-template-columns:1fr}
.journal .hero .row.between{align-items:flex-start}
}
</style>
</head>
<body class="work">
<div class="appbar">
<div class="brand">BMC HUB <span>/ Sager</span>
</div>
<div class="prototype">V5 · Designprototype · Fiktive data</div>
</div>
<nav class="switcher" aria-label="Sammenlign forslag">
<a href="arbejdsrum.html" class="active" aria-current=page>1. Arbejdsrummet</a>
<a href="journal.html" class="" >2. Sagsjournalen</a>
<a href="sagsbord.html" class="" >3. Sagsbordet</a>
</nav>
<div class="shell">
<header class="hero">
<div class="row between">
<div>
<div class="eyebrow">Sag #1248 · Nordby Kontor A/S</div>
<div class="row">
<h1>Printer virker ikke</h1>
<button type="button" aria-label="Rediger titel">✎</button>
<button type="button" aria-label="Upload fil">+ Fil</button>
</div>
</div>
<div class="row">
<button type="button" class="green">● Igangværende</button>
<button type="button" class="amber">↑ Høj prioritet</button>
</div>
</div>
<div class="row heroline">
<span>Peter Hansen <button type="button" aria-label="Mail">✉</button> <button type="button" aria-label="Ring">☎</button> <button type="button">SMS</button>
</span>
<span class="muted">Ansvarlig <strong>Christian</strong>
</span>
<span class="muted">Deadline <strong>05.10.2026</strong>
</span>
<button type="button">Sagsdetaljer ↗</button>
</div>
<div class="next">
<span>
<strong>Næste todo</strong> · Genstart printspooler på serveren</span>
<span>Maria Jensen · 05.10</span>
</div>
</header>
<div class="nav">
<span class="selected">Overblik</span>
<span>Tidsforbrug · 15 min</span>
<span>Historik</span>
<span>Relations-træ</span>
<span>Øvrige moduler +</span>
</div>
<div class="workspace">
<main>
<section class="panel">
<div class="panelhead">
<h2>Opgaven</h2>
<small>Rediger ✎</small>
</div>
<div class="panelbody">
<div class="description">
<p>
<strong>Printeren på 1. sal udskriver ikke.</strong>
</p>
<p>Job bliver stående i køen hos alle medarbejdere. Printeren er online, og kopiering virker. Fejlen opstod efter gårsdagens opdatering.</p>
<p class="muted">Brother MFC-L8900CDW · Administration · 1. sal</p>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Samtale og handlinger</h2>
<small>Alle · Mail · Internt</small>
</div>
<section class="composer" aria-label="Ny kommentar">
<div class="row between" style="margin-bottom:10px">
<h2>Hvad er næste skridt?</h2>
<span class="badge">Intern kommentar</span>
</div>
<textarea readonly aria-label="Kommentar">Netværket er kontrolleret. Maria overtager fejlsøgningen på printserveren.</textarea>
<div class="composerfooter">
<button type="button" class="plus" aria-label="Tilføj handling">+</button>
<span class="chip green">Tildel: Maria <span aria-hidden="true">×</span>
</span>
<span class="chip">Tid: 15 min <span aria-hidden="true">×</span>
</span>
<button type="button" class="primary">Gem og tildel →</button>
</div>
<div class="actionmenu">
<div class="row between">
<h3>Tilføj handling</h3>
<small>Eksempel på åben plus-menu</small>
</div>
<div class="menugrid">
<button type="button" class="chosen">↗ Tildel sag</button>
<button type="button">✉ Svar kunden</button>
<button type="button" class="chosen">◷ Registrer tid</button>
<button type="button">◇ Gem som løsning</button>
<button type="button">✓ Luk sag</button>
<button type="button">◴ Afventer kunde</button>
<button type="button">▤ Tilføj til sag</button>
</div>
<div class="assignment">
<div class="row">
<div>
<label for="assignee">Medarbejder / gruppe · søgning</label>
<input id="assignee" readonly value="Maria Jensen · Support">
</div>
<div>
<label for="reason">Begrundelse for tildeling</label>
<input id="reason" readonly value="Maria har adgang til printserveren.">
</div>
</div>
<div class="row" style="margin-top:10px">
<span class="chip">15 minutter · Fejlsøgning</span>
<small>02.10.2026 · 09.00</small>
</div>
</div>
</div>
<p class="muted" style="margin-top:12px">Gemmer en intern kommentar, registrerer 15 minutter og tildeler Maria.</p>
</section>
<article class="entry ">
<div class="avatar ">PH</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>Peter Hansen <span class="chip">Mail</span>
</strong>
<small>02.10 · 08.42</small>
</div>
<p>Hej. Ingen af os kan printe fra computeren i dag. Jeg har prøvet at slukke printeren, men det hjalp ikke. Kan I kigge på det?</p>
<div class="quote">
<small>Til: support@example.com · Emne: Printer virker ikke</small>
</div>
</div>
</article>
<article class="entry ">
<div class="avatar ">CT</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>Christian Thomas <span class="chip">Intern kommentar</span>
</strong>
<small>02.10 · 09.10</small>
</div>
<p>Printeren svarer på netværket. Køen på printserveren stopper efter første job. Jeg har kontrolleret driveren og fundet en fejl i spooleren.</p>
</div>
</article>
<article class="entry event">
<div class="avatar teal">◷</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>15 minutter registreret <span class="chip">Tid</span>
</strong>
<small>02.10 · 09.12</small>
</div>
<p>Christian · Fejlsøgning · 09.00–09.15</p>
</div>
</article>
<article class="entry event">
<div class="avatar teal">→</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>Christian → Maria <span class="chip">Tildeling</span>
</strong>
<small>02.10 · 09.18</small>
</div>
<p>
<strong>Begrundelse:</strong> Maria har adgang til printserveren og kan genstarte spooleren. Netværk og printer er kontrolleret.</p>
</div>
</article>
</section>
<section class="panel">
<div class="panelhead">
<h2>Sagsdetaljer</h2>
<small>Eksempel på åbent detaljepanel</small>
</div>
<div class="panelbody">
<div class="facts">
<div class="fact">
<small>Status</small>
<strong>Igangværende</strong>
</div>
<div class="fact">
<small>Type</small>
<strong>Ticket</strong>
</div>
<div class="fact">
<small>Prioritet</small>
<strong>Høj</strong>
</div>
<div class="fact">
<small>Ansvarlig</small>
<strong>Christian Thomas</strong>
</div>
<div class="fact">
<small>Gruppe</small>
<strong>Support</strong>
</div>
<div class="fact">
<small>Medarbejdere</small>
<strong>Christian · Maria</strong>
</div>
<div class="fact">
<small>Startdato</small>
<strong>02.10.2026</strong>
</div>
<div class="fact">
<small>Senest / udsat</small>
<strong>05.10.2026</strong>
</div>
<div class="fact">
<small>Deadline</small>
<strong>05.10.2026</strong>
</div>
<div class="fact">
<small>Forventet tid</small>
<strong>60 minutter</strong>
</div>
<div class="fact">
<small>Trigger</small>
<strong>Afventer #1242 → lukket</strong>
</div>
<div class="fact">
<small>Oprettet</small>
<strong>02.10.2026 · 08.42</strong>
</div>
</div>
</div>
</section>
</main>
<aside>
<section class="panel">
<div class="panelhead">
<h2>Næste skridt</h2>
<small>2 åbne</small>
</div>
<div class="panelbody">
<div class="todo">
<span class="check done">✓</span>
<div>Kontrollér netværksforbindelse<small style="display:block">Christian · udført kl. 09.05</small>
</div>
</div>
<div class="todo">
<span class="check">
</span>
<div>
<strong>Genstart printspooler på serveren</strong>
<small style="display:block">Maria · næste skridt</small>
</div>
</div>
<div class="todo">
<span class="check">
</span>
<div>Bekræft testprint med Peter</div>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Kunde og kontakt</h2>
<small>
</small>
</div>
<div class="panelbody">
<h3>Nordby Kontor A/S</h3>
<p class="muted">Afdeling: Administration · København</p>
<div style="margin-top:13px">
<strong>Peter Hansen</strong>
<p class="muted">peter.hansen@example.com<br>Telefon: +45 XX XX XX XX</p>
</div>
<div class="contactactions">
<button type="button">✉ Mail</button>
<button type="button">☎ Ring</button>
<button type="button">SMS</button>
</div>
<div class="row" style="margin-top:10px">
<button type="button">Skift kunde</button>
<button type="button">Skift kontakt</button>
<button type="button">Ændr afdeling</button>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Filer</h2>
<small>2</small>
</div>
<div class="panelbody">
<div class="moduleitem">▤ Fejlmeddelelse.png <small>Peter · 02.10 · 184 KB</small>
</div>
<div class="moduleitem">✉ Printerfejl.eml <small>Oprindelig henvendelse · 28 KB</small>
</div>
<div class="row" style="margin-top:12px">
<button type="button">+ Tilføj fil</button>
<button type="button">Upload mail</button>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Relateret til sagen</h2>
<small>
</small>
</div>
<div class="panelbody">
<div class="moduleitem">
<strong>#1242 · Opdatering af printserver</strong>
<small>Relateret sag · Igangværende</small>
</div>
<div class="moduleitem">
<strong>Brother MFC-L8900CDW</strong>
<small>Hardware · Administration, 1. sal</small>
</div>
<div class="moduleitem">København · Kontor 1. sal<small>Lokation</small>
</div>
<button type="button" style="margin-top:12px">+ Tilføj relation</button>
</div>
</section>
</aside>
</div>
<footer class="note">Arbejdsrummet · Statisk HTML/CSS · Knapper og felter er kun visuelle</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,470 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sag V5 · Sagsjournalen</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#f3f5f7;color:#243449;font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input,textarea{font:inherit}
button{cursor:default;border:1px solid #dbe2e9;border-radius:8px;padding:7px 12px;background:white;color:#243449}
a{color:inherit;text-decoration:none}
h1,h2,h3,p{margin:0}
h1{font-size:27px;letter-spacing:-.8px;line-height:1.3}
h2{font-size:16px}
h3{font-size:13px}
p+p{margin-top:9px}
small,.muted{color:#728095;font-size:12px}
.appbar{background:#142d43;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:13px 28px;gap:16px}
.brand{font-weight:800;letter-spacing:1px}
.brand span{font-weight:400;color:#a5b6c6;margin-left:12px}
.prototype{font-size:11px;letter-spacing:1px;text-transform:uppercase;color:#c4d6e5}
.switcher{display:flex;gap:5px;flex-wrap:wrap;padding:14px 28px;background:#fff;border-bottom:1px solid #e2e7ec}
.switcher a{padding:6px 12px;border-radius:6px;font-size:12px}
.switcher a.active{background:#e7f2ef;color:#177460;font-weight:700}
.shell{max-width:1500px;margin:auto;padding:25px 28px 48px}
.eyebrow{font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#7c8b9b;margin-bottom:8px}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.between{justify-content:space-between}
.badge,.chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:5px 10px;border-radius:6px;background:#edf1f5}
.green{background:#e4f3ed;color:#167158}
.amber{background:#fff1d9;color:#966018}
.primary{background:#177b68;color:white;border-color:#177b68;font-weight:600}
.hero{background:white;border:1px solid #e0e6ec;border-radius:12px;padding:22px 24px;margin-bottom:18px}
.heroline{margin-top:15px;gap:24px}
.next{margin-top:18px;border-top:1px solid #e7edf1;padding-top:12px;display:flex;justify-content:space-between;gap:12px;font-size:13px}
.next strong{color:#177b68}
.panel{background:#fff;border:1px solid #e0e6ec;border-radius:11px;overflow:hidden;margin-bottom:16px}
.panelhead{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:16px 20px;border-bottom:1px solid #edf0f4}
.panelbody{padding:19px 20px}
.description{border-left:3px solid #b6d8ce;padding-left:15px}
.nav{display:flex;gap:20px;flex-wrap:wrap;margin-bottom:18px;border-bottom:1px solid #dfe5eb}
.nav span{padding:10px 0;color:#778597;font-size:13px}
.nav .selected{border-bottom:2px solid #177b68;color:#177b68;font-weight:700}
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:20px}
.entry{display:flex;gap:12px;padding:19px 20px;border-bottom:1px solid #edf0f4}
.avatar{background:#e6eef7;color:#396084;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;flex-shrink:0;font-size:11px;font-weight:700}
.avatar.teal{background:#dff0e9;color:#177b68}
.entrycontent{flex:1;min-width:0;overflow-wrap:anywhere}
.entrytitle{display:flex;justify-content:space-between;gap:10px;margin-bottom:8px}
.entrytitle strong{font-size:13px}
.entry p{font-size:13px}
.event{background:#f8fafb}
.event .avatar{border-radius:8px}
.quote{background:#f5f7fa;border:1px solid #e6ebf0;border-radius:7px;padding:12px;margin-top:10px}
.composer{padding:20px}
.composer textarea{width:100%;height:100px;resize:none;border:0;background:#f7f9fb;border-radius:8px;padding:13px;color:#42546a}
.composerfooter{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}
.composerfooter .primary{margin-left:auto}
.plus{font-size:21px;line-height:1;padding:6px 11px}
.actionmenu{margin-top:15px;border:1px solid #dce5eb;border-radius:9px;padding:13px;background:#fff}
.menugrid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-top:9px}
.menugrid button{text-align:left;font-size:12px;padding:8px;border-color:transparent;background:#f6f8fa}
.menugrid .chosen{background:#e5f3ed;color:#167158;border-color:#b7d8cb}
.assignment{margin-top:12px;background:#f0f8f5;border-radius:8px;padding:13px}
.assignment label{display:block;font-size:11px;color:#477565;font-weight:700;margin-bottom:5px}
.assignment input{width:100%;min-width:0;border:1px solid #d6e6df;background:#fff;padding:8px 10px;border-radius:6px;color:#42546a}
.assignment .row>div{flex:1;min-width:180px}
.todo{display:flex;gap:10px;padding:11px 0;border-bottom:1px solid #edf0f4;font-size:13px}
.todo:last-child{border:0}
.check{width:16px;height:16px;border:1px solid #c4d2db;border-radius:4px;flex-shrink:0;margin-top:3px}
.check.done{background:#e1f0e9;color:#177b68;text-align:center;font-size:10px}
.moduleitem{padding:12px 0;border-bottom:1px solid #edf0f4;font-size:13px}
.moduleitem:last-child{border:0}
.moduleitem small{display:block;margin-top:3px}
.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}
.fact{min-width:0;overflow-wrap:anywhere}
.fact small{display:block;font-size:11px;margin-bottom:3px}
.fact strong{font-size:13px;font-weight:600}
.contactactions{display:flex;gap:6px;margin-top:12px}
.contactactions button{font-size:12px}
.note{font-size:11px;color:#8a96a5;text-align:center;margin-top:20px}
.journal .shell{max-width:1190px}
.journal .hero{border:0;background:transparent;padding:5px 0 20px;border-radius:0;border-bottom:1px solid #dbe2e9}
.journal h1{font-size:32px}
.journalgrid{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:24px}
.journal .entry{padding:22px}
.journal .stream{border-top:3px solid #177b68}
.journal .composer{background:#fcfdfd}
.journal .facts{grid-template-columns:1fr 1fr}
.journal .hero .facts{grid-template-columns:repeat(4,minmax(0,1fr));margin-top:18px}
.day{padding:14px 22px;background:#f6f8fa;color:#7b8c9e;font-size:11px;font-weight:700;letter-spacing:1px}
.board{background:#edf1f4}
.board .shell{max-width:1700px}
.board .hero{background:#17364d;color:white;border:0;padding:20px 24px}
.board .hero .eyebrow{color:#93b5c7}
.board .hero .next{border-color:#365569;color:#dae6ee}
.board .hero .next strong{color:#b9e6d7}
.boardgrid{display:grid;grid-template-columns:250px minmax(0,1fr) 280px;gap:18px}
.board .facts{grid-template-columns:1fr}
.board .fact{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
.board .fact strong{text-align:right}
.board .menugrid{grid-template-columns:repeat(3,minmax(0,1fr))}
.board .panelhead{padding:14px 17px}
.board .panelbody{padding:17px}
.board h1{font-size:24px}
@media(max-width:1100px){.boardgrid{grid-template-columns:220px minmax(0,1fr)}
.boardgrid>aside:last-child{grid-column:1/-1;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px}
.facts{grid-template-columns:repeat(3,minmax(0,1fr))}
.menugrid{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media(max-width:760px){.appbar{padding:12px 16px}
.brand span{display:none}
.prototype{font-size:9px}
.switcher{padding:10px 12px}
.shell{padding:18px 14px 30px}
.workspace,.journalgrid,.boardgrid{grid-template-columns:1fr}
.boardgrid>aside:last-child{display:block}
.hero{padding:18px}
.hero h1,.journal h1{font-size:24px}
.heroline{gap:10px}
.facts,.journal .hero .facts{grid-template-columns:1fr 1fr}
.menugrid,.board .menugrid{grid-template-columns:1fr 1fr}
.entry{padding:16px}
.entrytitle{flex-wrap:wrap}
.next{flex-wrap:wrap}
.composer{padding:16px}
.composerfooter .primary{margin-left:0;width:100%;margin-top:5px}
.nav{gap:15px}
.assignment .row>div{min-width:100%}
.board .facts{grid-template-columns:1fr}
.journal .hero .row.between{align-items:flex-start}
}
</style>
</head>
<body class="journal">
<div class="appbar">
<div class="brand">BMC HUB <span>/ Sager</span>
</div>
<div class="prototype">V5 · Designprototype · Fiktive data</div>
</div>
<nav class="switcher" aria-label="Sammenlign forslag">
<a href="arbejdsrum.html" class="" >1. Arbejdsrummet</a>
<a href="journal.html" class="active" aria-current=page>2. Sagsjournalen</a>
<a href="sagsbord.html" class="" >3. Sagsbordet</a>
</nav>
<div class="shell">
<header class="hero">
<div class="row between">
<div>
<div class="eyebrow">Sag #1248 · Nordby Kontor A/S</div>
<div class="row">
<h1>Printer virker ikke</h1>
<button type="button" aria-label="Rediger titel">✎</button>
<button type="button" aria-label="Upload fil">+ Fil</button>
</div>
</div>
<div class="row">
<button type="button" class="green">● Igangværende</button>
<button type="button" class="amber">↑ Høj prioritet</button>
</div>
</div>
<div class="facts">
<div class="fact">
<small>Kontakt</small>
<strong>Peter Hansen</strong>
</div>
<div class="fact">
<small>Ansvarlig</small>
<strong>Christian Thomas</strong>
</div>
<div class="fact">
<small>Gruppe</small>
<strong>Support</strong>
</div>
<div class="fact">
<small>Deadline</small>
<strong>05.10.2026</strong>
</div>
</div>
<div class="next">
<span>
<strong>Næste todo</strong> · Genstart printspooler på serveren</span>
<span>Maria Jensen · 05.10</span>
</div>
</header>
<div class="nav">
<span class="selected">Overblik</span>
<span>Tidsforbrug · 15 min</span>
<span>Historik</span>
<span>Relations-træ</span>
<span>Øvrige moduler +</span>
</div>
<div class="journalgrid">
<main>
<section class="panel">
<div class="panelhead">
<h2>Henvendelsen</h2>
<small>Rediger ✎</small>
</div>
<div class="panelbody">
<div class="description">
<p>
<strong>Printeren på 1. sal udskriver ikke.</strong>
</p>
<p>Job bliver stående i køen hos alle medarbejdere. Printeren er online, og kopiering virker. Fejlen opstod efter gårsdagens opdatering.</p>
<p class="muted">Brother MFC-L8900CDW · Administration · 1. sal</p>
</div>
</div>
</section>
<section class="panel stream">
<div class="panelhead">
<h2>Sagens forløb</h2>
<div class="row">
<span class="chip green">Alle</span>
<span class="muted">Mail · Tid · Tildeling</span>
</div>
</div>
<div class="day">FREDAG 2. OKTOBER 2026</div>
<article class="entry ">
<div class="avatar ">PH</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>Peter Hansen <span class="chip">Mail</span>
</strong>
<small>02.10 · 08.42</small>
</div>
<p>Hej. Ingen af os kan printe fra computeren i dag. Jeg har prøvet at slukke printeren, men det hjalp ikke. Kan I kigge på det?</p>
<div class="quote">
<small>Til: support@example.com · Emne: Printer virker ikke</small>
</div>
</div>
</article>
<article class="entry ">
<div class="avatar ">CT</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>Christian Thomas <span class="chip">Intern kommentar</span>
</strong>
<small>02.10 · 09.10</small>
</div>
<p>Printeren svarer på netværket. Køen på printserveren stopper efter første job. Jeg har kontrolleret driveren og fundet en fejl i spooleren.</p>
</div>
</article>
<article class="entry event">
<div class="avatar teal">◷</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>15 minutter registreret <span class="chip">Tid</span>
</strong>
<small>02.10 · 09.12</small>
</div>
<p>Christian · Fejlsøgning · 09.00–09.15</p>
</div>
</article>
<article class="entry event">
<div class="avatar teal">→</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>Christian → Maria <span class="chip">Tildeling</span>
</strong>
<small>02.10 · 09.18</small>
</div>
<p>
<strong>Begrundelse:</strong> Maria har adgang til printserveren og kan genstarte spooleren. Netværk og printer er kontrolleret.</p>
</div>
</article>
<section class="composer" aria-label="Ny kommentar">
<div class="row between" style="margin-bottom:10px">
<h2>Hvad er næste skridt?</h2>
<span class="badge">Intern kommentar</span>
</div>
<textarea readonly aria-label="Kommentar">Netværket er kontrolleret. Maria overtager fejlsøgningen på printserveren.</textarea>
<div class="composerfooter">
<button type="button" class="plus" aria-label="Tilføj handling">+</button>
<span class="chip green">Tildel: Maria <span aria-hidden="true">×</span>
</span>
<span class="chip">Tid: 15 min <span aria-hidden="true">×</span>
</span>
<button type="button" class="primary">Gem og tildel →</button>
</div>
<div class="actionmenu">
<div class="row between">
<h3>Tilføj handling</h3>
<small>Eksempel på åben plus-menu</small>
</div>
<div class="menugrid">
<button type="button" class="chosen">↗ Tildel sag</button>
<button type="button">✉ Svar kunden</button>
<button type="button" class="chosen">◷ Registrer tid</button>
<button type="button">◇ Gem som løsning</button>
<button type="button">✓ Luk sag</button>
<button type="button">◴ Afventer kunde</button>
<button type="button">▤ Tilføj til sag</button>
</div>
<div class="assignment">
<div class="row">
<div>
<label for="assignee">Medarbejder / gruppe · søgning</label>
<input id="assignee" readonly value="Maria Jensen · Support">
</div>
<div>
<label for="reason">Begrundelse for tildeling</label>
<input id="reason" readonly value="Maria har adgang til printserveren.">
</div>
</div>
<div class="row" style="margin-top:10px">
<span class="chip">15 minutter · Fejlsøgning</span>
<small>02.10.2026 · 09.00</small>
</div>
</div>
</div>
<p class="muted" style="margin-top:12px">Gemmer en intern kommentar, registrerer 15 minutter og tildeler Maria.</p>
</section>
</section>
</main>
<aside>
<section class="panel">
<div class="panelhead">
<h2>Kunde og kontakt</h2>
<small>
</small>
</div>
<div class="panelbody">
<h3>Nordby Kontor A/S</h3>
<p class="muted">Afdeling: Administration · København</p>
<div style="margin-top:13px">
<strong>Peter Hansen</strong>
<p class="muted">peter.hansen@example.com<br>Telefon: +45 XX XX XX XX</p>
</div>
<div class="contactactions">
<button type="button">✉ Mail</button>
<button type="button">☎ Ring</button>
<button type="button">SMS</button>
</div>
<div class="row" style="margin-top:10px">
<button type="button">Skift kunde</button>
<button type="button">Skift kontakt</button>
<button type="button">Ændr afdeling</button>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Næste skridt</h2>
<small>2 åbne</small>
</div>
<div class="panelbody">
<div class="todo">
<span class="check done">✓</span>
<div>Kontrollér netværksforbindelse<small style="display:block">Christian · udført kl. 09.05</small>
</div>
</div>
<div class="todo">
<span class="check">
</span>
<div>
<strong>Genstart printspooler på serveren</strong>
<small style="display:block">Maria · næste skridt</small>
</div>
</div>
<div class="todo">
<span class="check">
</span>
<div>Bekræft testprint med Peter</div>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Sagsoplysninger</h2>
<small>
</small>
</div>
<div class="panelbody">
<div class="facts">
<div class="fact">
<small>Status</small>
<strong>Igangværende</strong>
</div>
<div class="fact">
<small>Type</small>
<strong>Ticket</strong>
</div>
<div class="fact">
<small>Prioritet</small>
<strong>Høj</strong>
</div>
<div class="fact">
<small>Ansvarlig</small>
<strong>Christian Thomas</strong>
</div>
<div class="fact">
<small>Gruppe</small>
<strong>Support</strong>
</div>
<div class="fact">
<small>Medarbejdere</small>
<strong>Christian · Maria</strong>
</div>
<div class="fact">
<small>Startdato</small>
<strong>02.10.2026</strong>
</div>
<div class="fact">
<small>Senest / udsat</small>
<strong>05.10.2026</strong>
</div>
<div class="fact">
<small>Deadline</small>
<strong>05.10.2026</strong>
</div>
<div class="fact">
<small>Forventet tid</small>
<strong>60 minutter</strong>
</div>
<div class="fact">
<small>Trigger</small>
<strong>Afventer #1242 → lukket</strong>
</div>
<div class="fact">
<small>Oprettet</small>
<strong>02.10.2026 · 08.42</strong>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Filer og bilag</h2>
<small>2</small>
</div>
<div class="panelbody">
<div class="moduleitem">▤ Fejlmeddelelse.png <small>Peter · 02.10 · 184 KB</small>
</div>
<div class="moduleitem">✉ Printerfejl.eml <small>Oprindelig henvendelse · 28 KB</small>
</div>
<div class="row" style="margin-top:12px">
<button type="button">+ Tilføj fil</button>
<button type="button">Upload mail</button>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Relationer</h2>
<small>
</small>
</div>
<div class="panelbody">
<div class="moduleitem">
<strong>#1242 · Opdatering af printserver</strong>
<small>Relateret sag · Igangværende</small>
</div>
<div class="moduleitem">
<strong>Brother MFC-L8900CDW</strong>
<small>Hardware · Administration, 1. sal</small>
</div>
<div class="moduleitem">København · Kontor 1. sal<small>Lokation</small>
</div>
<button type="button" style="margin-top:12px">+ Tilføj relation</button>
</div>
</section>
</aside>
</div>
<footer class="note">Sagsjournalen · Statisk HTML/CSS · Knapper og felter er kun visuelle</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,449 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sag V5 · Sagsbordet</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#f3f5f7;color:#243449;font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input,textarea{font:inherit}
button{cursor:default;border:1px solid #dbe2e9;border-radius:8px;padding:7px 12px;background:white;color:#243449}
a{color:inherit;text-decoration:none}
h1,h2,h3,p{margin:0}
h1{font-size:27px;letter-spacing:-.8px;line-height:1.3}
h2{font-size:16px}
h3{font-size:13px}
p+p{margin-top:9px}
small,.muted{color:#728095;font-size:12px}
.appbar{background:#142d43;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:13px 28px;gap:16px}
.brand{font-weight:800;letter-spacing:1px}
.brand span{font-weight:400;color:#a5b6c6;margin-left:12px}
.prototype{font-size:11px;letter-spacing:1px;text-transform:uppercase;color:#c4d6e5}
.switcher{display:flex;gap:5px;flex-wrap:wrap;padding:14px 28px;background:#fff;border-bottom:1px solid #e2e7ec}
.switcher a{padding:6px 12px;border-radius:6px;font-size:12px}
.switcher a.active{background:#e7f2ef;color:#177460;font-weight:700}
.shell{max-width:1500px;margin:auto;padding:25px 28px 48px}
.eyebrow{font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#7c8b9b;margin-bottom:8px}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.between{justify-content:space-between}
.badge,.chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:5px 10px;border-radius:6px;background:#edf1f5}
.green{background:#e4f3ed;color:#167158}
.amber{background:#fff1d9;color:#966018}
.primary{background:#177b68;color:white;border-color:#177b68;font-weight:600}
.hero{background:white;border:1px solid #e0e6ec;border-radius:12px;padding:22px 24px;margin-bottom:18px}
.heroline{margin-top:15px;gap:24px}
.next{margin-top:18px;border-top:1px solid #e7edf1;padding-top:12px;display:flex;justify-content:space-between;gap:12px;font-size:13px}
.next strong{color:#177b68}
.panel{background:#fff;border:1px solid #e0e6ec;border-radius:11px;overflow:hidden;margin-bottom:16px}
.panelhead{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:16px 20px;border-bottom:1px solid #edf0f4}
.panelbody{padding:19px 20px}
.description{border-left:3px solid #b6d8ce;padding-left:15px}
.nav{display:flex;gap:20px;flex-wrap:wrap;margin-bottom:18px;border-bottom:1px solid #dfe5eb}
.nav span{padding:10px 0;color:#778597;font-size:13px}
.nav .selected{border-bottom:2px solid #177b68;color:#177b68;font-weight:700}
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:20px}
.entry{display:flex;gap:12px;padding:19px 20px;border-bottom:1px solid #edf0f4}
.avatar{background:#e6eef7;color:#396084;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;flex-shrink:0;font-size:11px;font-weight:700}
.avatar.teal{background:#dff0e9;color:#177b68}
.entrycontent{flex:1;min-width:0;overflow-wrap:anywhere}
.entrytitle{display:flex;justify-content:space-between;gap:10px;margin-bottom:8px}
.entrytitle strong{font-size:13px}
.entry p{font-size:13px}
.event{background:#f8fafb}
.event .avatar{border-radius:8px}
.quote{background:#f5f7fa;border:1px solid #e6ebf0;border-radius:7px;padding:12px;margin-top:10px}
.composer{padding:20px}
.composer textarea{width:100%;height:100px;resize:none;border:0;background:#f7f9fb;border-radius:8px;padding:13px;color:#42546a}
.composerfooter{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}
.composerfooter .primary{margin-left:auto}
.plus{font-size:21px;line-height:1;padding:6px 11px}
.actionmenu{margin-top:15px;border:1px solid #dce5eb;border-radius:9px;padding:13px;background:#fff}
.menugrid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-top:9px}
.menugrid button{text-align:left;font-size:12px;padding:8px;border-color:transparent;background:#f6f8fa}
.menugrid .chosen{background:#e5f3ed;color:#167158;border-color:#b7d8cb}
.assignment{margin-top:12px;background:#f0f8f5;border-radius:8px;padding:13px}
.assignment label{display:block;font-size:11px;color:#477565;font-weight:700;margin-bottom:5px}
.assignment input{width:100%;min-width:0;border:1px solid #d6e6df;background:#fff;padding:8px 10px;border-radius:6px;color:#42546a}
.assignment .row>div{flex:1;min-width:180px}
.todo{display:flex;gap:10px;padding:11px 0;border-bottom:1px solid #edf0f4;font-size:13px}
.todo:last-child{border:0}
.check{width:16px;height:16px;border:1px solid #c4d2db;border-radius:4px;flex-shrink:0;margin-top:3px}
.check.done{background:#e1f0e9;color:#177b68;text-align:center;font-size:10px}
.moduleitem{padding:12px 0;border-bottom:1px solid #edf0f4;font-size:13px}
.moduleitem:last-child{border:0}
.moduleitem small{display:block;margin-top:3px}
.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}
.fact{min-width:0;overflow-wrap:anywhere}
.fact small{display:block;font-size:11px;margin-bottom:3px}
.fact strong{font-size:13px;font-weight:600}
.contactactions{display:flex;gap:6px;margin-top:12px}
.contactactions button{font-size:12px}
.note{font-size:11px;color:#8a96a5;text-align:center;margin-top:20px}
.journal .shell{max-width:1190px}
.journal .hero{border:0;background:transparent;padding:5px 0 20px;border-radius:0;border-bottom:1px solid #dbe2e9}
.journal h1{font-size:32px}
.journalgrid{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:24px}
.journal .entry{padding:22px}
.journal .stream{border-top:3px solid #177b68}
.journal .composer{background:#fcfdfd}
.journal .facts{grid-template-columns:1fr 1fr}
.journal .hero .facts{grid-template-columns:repeat(4,minmax(0,1fr));margin-top:18px}
.day{padding:14px 22px;background:#f6f8fa;color:#7b8c9e;font-size:11px;font-weight:700;letter-spacing:1px}
.board{background:#edf1f4}
.board .shell{max-width:1700px}
.board .hero{background:#17364d;color:white;border:0;padding:20px 24px}
.board .hero .eyebrow{color:#93b5c7}
.board .hero .next{border-color:#365569;color:#dae6ee}
.board .hero .next strong{color:#b9e6d7}
.boardgrid{display:grid;grid-template-columns:250px minmax(0,1fr) 280px;gap:18px}
.board .facts{grid-template-columns:1fr}
.board .fact{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
.board .fact strong{text-align:right}
.board .menugrid{grid-template-columns:repeat(3,minmax(0,1fr))}
.board .panelhead{padding:14px 17px}
.board .panelbody{padding:17px}
.board h1{font-size:24px}
@media(max-width:1100px){.boardgrid{grid-template-columns:220px minmax(0,1fr)}
.boardgrid>aside:last-child{grid-column:1/-1;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px}
.facts{grid-template-columns:repeat(3,minmax(0,1fr))}
.menugrid{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media(max-width:760px){.appbar{padding:12px 16px}
.brand span{display:none}
.prototype{font-size:9px}
.switcher{padding:10px 12px}
.shell{padding:18px 14px 30px}
.workspace,.journalgrid,.boardgrid{grid-template-columns:1fr}
.boardgrid>aside:last-child{display:block}
.hero{padding:18px}
.hero h1,.journal h1{font-size:24px}
.heroline{gap:10px}
.facts,.journal .hero .facts{grid-template-columns:1fr 1fr}
.menugrid,.board .menugrid{grid-template-columns:1fr 1fr}
.entry{padding:16px}
.entrytitle{flex-wrap:wrap}
.next{flex-wrap:wrap}
.composer{padding:16px}
.composerfooter .primary{margin-left:0;width:100%;margin-top:5px}
.nav{gap:15px}
.assignment .row>div{min-width:100%}
.board .facts{grid-template-columns:1fr}
.journal .hero .row.between{align-items:flex-start}
}
</style>
</head>
<body class="board">
<div class="appbar">
<div class="brand">BMC HUB <span>/ Sager</span>
</div>
<div class="prototype">V5 · Designprototype · Fiktive data</div>
</div>
<nav class="switcher" aria-label="Sammenlign forslag">
<a href="arbejdsrum.html" class="" >1. Arbejdsrummet</a>
<a href="journal.html" class="" >2. Sagsjournalen</a>
<a href="sagsbord.html" class="active" aria-current=page>3. Sagsbordet</a>
</nav>
<div class="shell">
<header class="hero">
<div class="row between">
<div>
<div class="eyebrow">Sag #1248 · Nordby Kontor A/S</div>
<div class="row">
<h1>Printer virker ikke</h1>
<button type="button" aria-label="Rediger titel">✎</button>
<button type="button" aria-label="Upload fil">+ Fil</button>
</div>
</div>
<div class="row">
<button type="button" class="green">● Igangværende</button>
<button type="button" class="amber">↑ Høj prioritet</button>
</div>
</div>
<div class="next">
<span>
<strong>Næste todo</strong> · Genstart printspooler på serveren</span>
<span>Maria Jensen · 05.10</span>
</div>
</header>
<div class="boardgrid">
<aside>
<section class="panel">
<div class="panelhead">
<h2>Kunde og kontakt</h2>
<small>
</small>
</div>
<div class="panelbody">
<h3>Nordby Kontor A/S</h3>
<p class="muted">Afdeling: Administration · København</p>
<div style="margin-top:13px">
<strong>Peter Hansen</strong>
<p class="muted">peter.hansen@example.com<br>Telefon: +45 XX XX XX XX</p>
</div>
<div class="contactactions">
<button type="button">✉ Mail</button>
<button type="button">☎ Ring</button>
<button type="button">SMS</button>
</div>
<div class="row" style="margin-top:10px">
<button type="button">Skift kunde</button>
<button type="button">Skift kontakt</button>
<button type="button">Ændr afdeling</button>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Sagsoplysninger</h2>
<small>✎</small>
</div>
<div class="panelbody">
<div class="facts">
<div class="fact">
<small>Status</small>
<strong>Igangværende</strong>
</div>
<div class="fact">
<small>Type</small>
<strong>Ticket</strong>
</div>
<div class="fact">
<small>Prioritet</small>
<strong>Høj</strong>
</div>
<div class="fact">
<small>Ansvarlig</small>
<strong>Christian Thomas</strong>
</div>
<div class="fact">
<small>Gruppe</small>
<strong>Support</strong>
</div>
<div class="fact">
<small>Medarbejdere</small>
<strong>Christian · Maria</strong>
</div>
<div class="fact">
<small>Startdato</small>
<strong>02.10.2026</strong>
</div>
<div class="fact">
<small>Senest / udsat</small>
<strong>05.10.2026</strong>
</div>
<div class="fact">
<small>Deadline</small>
<strong>05.10.2026</strong>
</div>
<div class="fact">
<small>Forventet tid</small>
<strong>60 minutter</strong>
</div>
<div class="fact">
<small>Trigger</small>
<strong>Afventer #1242 → lukket</strong>
</div>
<div class="fact">
<small>Oprettet</small>
<strong>02.10.2026 · 08.42</strong>
</div>
</div>
</div>
</section>
</aside>
<main>
<div class="nav">
<span class="selected">Overblik</span>
<span>Tidsforbrug · 15 min</span>
<span>Historik</span>
<span>Relations-træ</span>
<span>Øvrige moduler +</span>
</div>
<section class="panel">
<div class="panelhead">
<h2>Opgavebeskrivelse</h2>
<small>Rediger ✎</small>
</div>
<div class="panelbody">
<div class="description">
<p>
<strong>Printeren på 1. sal udskriver ikke.</strong>
</p>
<p>Job bliver stående i køen hos alle medarbejdere. Printeren er online, og kopiering virker. Fejlen opstod efter gårsdagens opdatering.</p>
<p class="muted">Brother MFC-L8900CDW · Administration · 1. sal</p>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Kommentarer og handlinger</h2>
<small>4 hændelser</small>
</div>
<section class="composer" aria-label="Ny kommentar">
<div class="row between" style="margin-bottom:10px">
<h2>Hvad er næste skridt?</h2>
<span class="badge">Intern kommentar</span>
</div>
<textarea readonly aria-label="Kommentar">Netværket er kontrolleret. Maria overtager fejlsøgningen på printserveren.</textarea>
<div class="composerfooter">
<button type="button" class="plus" aria-label="Tilføj handling">+</button>
<span class="chip green">Tildel: Maria <span aria-hidden="true">×</span>
</span>
<span class="chip">Tid: 15 min <span aria-hidden="true">×</span>
</span>
<button type="button" class="primary">Gem og tildel →</button>
</div>
<div class="actionmenu">
<div class="row between">
<h3>Tilføj handling</h3>
<small>Eksempel på åben plus-menu</small>
</div>
<div class="menugrid">
<button type="button" class="chosen">↗ Tildel sag</button>
<button type="button">✉ Svar kunden</button>
<button type="button" class="chosen">◷ Registrer tid</button>
<button type="button">◇ Gem som løsning</button>
<button type="button">✓ Luk sag</button>
<button type="button">◴ Afventer kunde</button>
<button type="button">▤ Tilføj til sag</button>
</div>
<div class="assignment">
<div class="row">
<div>
<label for="assignee">Medarbejder / gruppe · søgning</label>
<input id="assignee" readonly value="Maria Jensen · Support">
</div>
<div>
<label for="reason">Begrundelse for tildeling</label>
<input id="reason" readonly value="Maria har adgang til printserveren.">
</div>
</div>
<div class="row" style="margin-top:10px">
<span class="chip">15 minutter · Fejlsøgning</span>
<small>02.10.2026 · 09.00</small>
</div>
</div>
</div>
<p class="muted" style="margin-top:12px">Gemmer en intern kommentar, registrerer 15 minutter og tildeler Maria.</p>
</section>
<article class="entry ">
<div class="avatar ">PH</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>Peter Hansen <span class="chip">Mail</span>
</strong>
<small>02.10 · 08.42</small>
</div>
<p>Hej. Ingen af os kan printe fra computeren i dag. Jeg har prøvet at slukke printeren, men det hjalp ikke. Kan I kigge på det?</p>
<div class="quote">
<small>Til: support@example.com · Emne: Printer virker ikke</small>
</div>
</div>
</article>
<article class="entry ">
<div class="avatar ">CT</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>Christian Thomas <span class="chip">Intern kommentar</span>
</strong>
<small>02.10 · 09.10</small>
</div>
<p>Printeren svarer på netværket. Køen på printserveren stopper efter første job. Jeg har kontrolleret driveren og fundet en fejl i spooleren.</p>
</div>
</article>
<article class="entry event">
<div class="avatar teal">◷</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>15 minutter registreret <span class="chip">Tid</span>
</strong>
<small>02.10 · 09.12</small>
</div>
<p>Christian · Fejlsøgning · 09.00–09.15</p>
</div>
</article>
<article class="entry event">
<div class="avatar teal">→</div>
<div class="entrycontent">
<div class="entrytitle">
<strong>Christian → Maria <span class="chip">Tildeling</span>
</strong>
<small>02.10 · 09.18</small>
</div>
<p>
<strong>Begrundelse:</strong> Maria har adgang til printserveren og kan genstarte spooleren. Netværk og printer er kontrolleret.</p>
</div>
</article>
</section>
</main>
<aside>
<section class="panel">
<div class="panelhead">
<h2>Todo</h2>
<small>2 åbne</small>
</div>
<div class="panelbody">
<div class="todo">
<span class="check done">✓</span>
<div>Kontrollér netværksforbindelse<small style="display:block">Christian · udført kl. 09.05</small>
</div>
</div>
<div class="todo">
<span class="check">
</span>
<div>
<strong>Genstart printspooler på serveren</strong>
<small style="display:block">Maria · næste skridt</small>
</div>
</div>
<div class="todo">
<span class="check">
</span>
<div>Bekræft testprint med Peter</div>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Filer</h2>
<small>2</small>
</div>
<div class="panelbody">
<div class="moduleitem">▤ Fejlmeddelelse.png <small>Peter · 02.10 · 184 KB</small>
</div>
<div class="moduleitem">✉ Printerfejl.eml <small>Oprindelig henvendelse · 28 KB</small>
</div>
<div class="row" style="margin-top:12px">
<button type="button">+ Tilføj fil</button>
<button type="button">Upload mail</button>
</div>
</div>
</section>
<section class="panel">
<div class="panelhead">
<h2>Hardware og relationer</h2>
<small>
</small>
</div>
<div class="panelbody">
<div class="moduleitem">
<strong>#1242 · Opdatering af printserver</strong>
<small>Relateret sag · Igangværende</small>
</div>
<div class="moduleitem">
<strong>Brother MFC-L8900CDW</strong>
<small>Hardware · Administration, 1. sal</small>
</div>
<div class="moduleitem">København · Kontor 1. sal<small>Lokation</small>
</div>
<button type="button" style="margin-top:12px">+ Tilføj relation</button>
</div>
</section>
</aside>
</div>
<footer class="note">Sagsbordet · Statisk HTML/CSS · Knapper og felter er kun visuelle</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,4 @@
-- V5 assignment explanations must remain internal. Existing comments keep
-- their current visibility; no rows are removed or reclassified.
ALTER TABLE sag_kommentarer
ADD COLUMN IF NOT EXISTS er_intern BOOLEAN NOT NULL DEFAULT FALSE;

49
static/css/case-v5.css Normal file
View File

@ -0,0 +1,49 @@
/* V5 only: retain module behavior, replace page presentation. */
.case-detail-page-shell.case-v5{--case-primary:#177b68;--case-primary-soft:#e7f3ee;--case-border:#dfe6eb;--case-radius:11px;background:#edf1f4!important;margin-top:0!important;padding:18px 20px 36px;color:var(--text-color,#243449)}
.case-v5 .v5-version-line{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px;font-size:12px;color:var(--case-muted,#728095)}
.v5-version-line>div{display:flex;gap:16px}.v5-version-line a{color:var(--case-primary)}
.case-v5 .header-bg{background:#17364d!important;margin:0 0 18px!important;padding:18px 8px!important;border-radius:12px}
.case-v5 .case-header-main{display:flex;align-items:center;justify-content:space-between;gap:24px}.case-v5 .case-header-copy{flex:1}
.case-v5 #sag-titel-text{font-size:25px!important;line-height:1.3;letter-spacing:-.5px}.case-v5 .case-header-todo{background:transparent!important;border:0!important;box-shadow:none!important;max-width:340px}
.case-v5 .case-header-todo-label{color:#b9e6d7!important}.case-v5 #topbarNextTodoValue{color:#fff}.case-v5 .case-v4-widget-dashboard{display:none!important}
.v5-board{display:grid;grid-template-columns:260px minmax(0,1fr) 300px;gap:18px;align-items:start}.v5-board>*,.v5-board main{min-width:0}
.case-v5 .v5-case-facts .card{margin-bottom:14px;border:1px solid var(--case-border)!important;border-radius:11px!important;background:var(--bg-surface,#fff)!important;box-shadow:none!important}
.case-v5 .v5-case-facts .card-body{padding:16px!important}.case-v5 .v5-case-facts .form-select,.case-v5 .v5-case-facts .input-group{width:62%!important;min-width:0}
.case-v5 .v5-case-facts .contact-widget-field{min-width:0}.case-v5 .v5-case-facts .contact-widget-row{flex-wrap:wrap}
.case-v5 #caseTabs{margin:0 0 14px!important;padding:0!important;border-bottom:1px solid var(--case-border);border-radius:0!important;background:transparent!important;gap:2px;display:flex;flex-wrap:wrap}
.case-v5 #caseTabs .nav-link{font-size:12px!important;padding:9px 8px!important;background:transparent!important;border-radius:0!important;box-shadow:none!important;border:0!important}
.case-v5 #caseTabs .nav-link.active{border-bottom:2px solid var(--case-primary)!important;color:var(--case-primary)!important}.case-v5 #caseTabs .nav-link i{display:none}
.case-v5 #planner-tab,.case-v5 #planner{display:none!important}
.case-v5 #case-left-column,.case-v5 #case-right-column{width:100%!important;max-width:100%!important;padding:0!important;flex:none!important}
.case-v5 .case-v4-details-columns{display:block!important;margin:0!important}.case-v5 #inner-center-col,.case-v5 #inner-left-col{padding:0!important;width:100%!important}
.case-v5 #case-left-column>.row,.case-v5 #inner-center-col>.row{margin:0!important;display:block!important}.case-v5 #inner-center-col>.row>.col-12{padding:0!important;margin:0!important}
.case-v5 .case-details-shell{background:transparent!important;border:0!important;box-shadow:none!important;padding:0!important}.case-v5 .case-details-shell>.card-body{padding:0!important}.case-v5 #beskrivelse-section{padding-top:0!important}
.case-v5 .right-modules-grid{display:flex!important;flex-direction:column!important;gap:14px!important}.case-v5 .right-modules-grid>.d-none{display:none!important}
.case-v5 .right-module-card,.case-v5 .left-module-card{height:auto!important;border:1px solid var(--case-border)!important;border-radius:11px!important;box-shadow:none!important;background:var(--bg-surface,#fff)!important;margin-bottom:14px}
.case-v5 .right-module-card{margin-bottom:0}.case-v5 .card-header{background:transparent!important;border-bottom:1px solid var(--case-border)!important;padding:14px 16px!important}
.case-v5 .module-title{font-size:14px!important;letter-spacing:0!important}.case-v5 .module-icon{color:var(--case-primary)!important}
.case-v5 .comment-composer{display:flex;flex-direction:column;gap:10px;border:0!important;background:transparent!important;padding:0!important}.case-v5 .comment-composer textarea{background:var(--bg-element,#f6f8fa);border:1px solid var(--case-border)!important;border-radius:9px!important;min-height:120px}
.case-v5 .comment-send{align-self:flex-end;position:static!important;width:auto!important;background:#177b68;border-color:#177b68}
.v5-composer-actions{display:flex;align-items:center;gap:9px;margin-bottom:10px}.v5-selected-actions{display:flex;gap:6px;flex-wrap:wrap}
.v5-selected-actions button{font-size:12px;border:1px solid #b9d9cc;border-radius:7px;background:var(--case-primary-soft);color:var(--case-primary);padding:5px 9px}
.v5-action-menu{border:1px solid var(--case-border);border-radius:9px;padding:13px;margin-bottom:12px;background:var(--bg-surface,#fff)}
.v5-menu-title{font-size:12px;font-weight:700;margin-bottom:9px}.v5-action-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
.v5-action-options button{font-size:12px;text-align:left;border:1px solid var(--case-border);background:var(--bg-element,#f7f9fa);color:var(--text-color,#243449);border-radius:7px;padding:9px}
.v5-action-options button[aria-pressed=true],.v5-picker button[aria-pressed=true]{border-color:#79b7a2;background:var(--case-primary-soft);color:var(--case-primary)}
.v5-assignment-fields{background:var(--case-primary-soft);border-radius:9px;padding:13px;margin-bottom:12px}.v5-assignment-fields label{font-size:12px;font-weight:600;margin:8px 0 5px;display:block}.v5-assignment-fields label:first-child{margin-top:0}
.v5-picker{display:flex;flex-wrap:wrap;gap:6px;max-height:145px;overflow:auto;margin:8px 0}.v5-picker button{border:1px solid var(--case-border);border-radius:7px;padding:5px 9px;background:var(--bg-surface,#fff);color:var(--text-color,#243449);font-size:12px}
.case-v5 .comment-action-chips{display:none!important}.case-v5 .comment-toolbar{flex-wrap:wrap}.case-v5 .comment-item{box-shadow:none!important;border-radius:8px!important}
.v5-action-menu[hidden],.v5-assignment-fields[hidden]{display:none!important}
[data-bs-theme=dark] .case-v5{--case-primary:#69c7a9;--case-primary-soft:#1e3933;--case-border:#384850;background:#17232c!important}
@media(max-width:1250px){.v5-board{grid-template-columns:225px minmax(0,1fr)}.v5-board>.v5-case-modules{grid-column:1/-1}.case-v5 .v5-case-modules .right-modules-grid{display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:767px){.case-detail-page-shell.case-v5{padding:12px}.v5-board{display:flex;flex-direction:column;gap:14px}.v5-board>main{order:0;width:100%}.v5-board>.v5-case-facts{order:1;width:100%}.v5-board>.v5-case-modules{order:2;width:100%}.case-v5 .case-header-main{flex-direction:column;align-items:stretch;gap:12px}.case-v5 .case-header-todo{max-width:none}.case-v5 #sag-titel-text{font-size:22px!important}.v5-action-options{grid-template-columns:repeat(2,minmax(0,1fr))}.case-v5 .v5-case-modules .right-modules-grid{display:flex!important}.case-v5 .comment-toolbar-search{width:100%!important}.case-v5 .comment-send{width:100%!important}.v5-version-line{flex-wrap:wrap}}
.case-v5 #caseTabs .nav-link{color:var(--text-color,#56677a)!important}
.case-v5 #caseTabs .nav-link.active{color:var(--case-primary)!important}
.case-v5 .v5-hero-status{display:flex;flex-direction:column;gap:7px;min-width:190px;max-width:240px}
.case-v5 .v5-hero-status label{color:#c8dce8!important;font-size:12px!important;margin-right:10px}
.case-v5 .v5-hero-status select{width:auto!important;min-width:130px;border:0;background:#e6f2ed!important;color:#176b56;font-size:13px}
.case-v5 .comment-composer textarea{height:135px;min-height:100px;padding-right:12px!important}
.case-v5 .v5-composer-actions{margin-top:10px}
@media(max-width:767px){.case-v5 .v5-hero-status{max-width:none;flex-direction:row;flex-wrap:wrap}.case-v5 .v5-hero-status>div{flex:1;min-width:155px}}
.case-v5 .v5-composer-actions .comment-send{margin-left:auto;align-self:center}
@media(max-width:767px){.case-v5 .v5-composer-actions{flex-wrap:wrap}.case-v5 .v5-composer-actions .comment-send{margin-left:0}}

View File

@ -162,7 +162,7 @@
opgave: ['relations'],
abonnement: ['relations'],
}[type] || ['relations'];
const availableModules = visiblePanels.filter(key => key !== 'relations');
const availableModules = visiblePanels;
const moduleAction = $('typeModuleAction');
if (showTypeFields) state.openModules[type] = [...availableModules];
const openModules = state.openModules[type] || [];
@ -172,8 +172,8 @@
admin: 'Adminoplysninger', projekt: 'Projektoplysninger', postit: 'Notat og påmindelse',
intern_ticket: 'Intern opgave', bug_report: 'Fejlrapport'
};
const moduleLabels = {'type-details': typeDetailsLabels[type] || 'Oplysninger til sagstypen', hardware:'Hardware', pipeline:'Pipeline', order:'Indkøb og salg'};
const moduleIcons = {'type-details':'bi-card-text', hardware:'bi-pc-display', pipeline:'bi-graph-up-arrow', order:'bi-bag'};
const moduleLabels = {relations:'Kunde og kontakt', 'type-details': typeDetailsLabels[type] || 'Oplysninger til sagstypen', hardware:'Hardware', pipeline:'Pipeline', order:'Indkøb og salg'};
const moduleIcons = {relations:'bi-people', 'type-details':'bi-card-text', hardware:'bi-pc-display', pipeline:'bi-graph-up-arrow', order:'bi-bag'};
moduleAction.classList.toggle('d-none', !availableModules.length);
moduleAction.innerHTML = availableModules.map(key => `<button type="button" class="btn ${openModules.includes(key) ? 'btn-primary' : 'btn-outline-primary'}" data-case-module="${esc(key)}"><i class="bi ${moduleIcons[key] || 'bi-plus-square'} me-1"></i>${esc(moduleLabels[key] || key)}</button>`).join('');
moduleAction.querySelectorAll('[data-case-module]').forEach(button => {
@ -184,6 +184,9 @@
};
});
}
const relationsSection = $('caseRelationsSection');
relationsSection?.classList.toggle('d-none', !openModules.includes('relations') || !visiblePanels.includes('relations'));
$('caseGroupField')?.classList.toggle('d-none', !openModules.includes('relations') || !visiblePanels.includes('relations'));
['relations', 'type-details', 'hardware', 'pipeline', 'order'].forEach(key => state.panels[key]?.classList.toggle('d-none', !openModules.includes(key) || !visiblePanels.includes(key)));
const sectionIds = {hardware: 'hardwareSection', pipeline: 'pipelineSection', order: 'orderSection'};
$('typeDetailsSection')?.classList.toggle('d-none', !openModules.includes('type-details') || !(typeFieldSets[type] || []).length);
@ -332,6 +335,10 @@
};
const originalRender = window.renderSelections;
window.renderSelections = () => { originalRender(); if (state.ready) scheduleSave(); };
$('caseTagsButton')?.addEventListener('click', () => {
if (!window.tagPicker?.showSelection) return;
window.tagPicker.showSelection(addTag);
});
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>');
// Core flow stays visible: type → required fields → chosen type's fields.
const typeSection = form.querySelector('.cc-type-section');

269
static/js/case-v5.js Normal file
View File

@ -0,0 +1,269 @@
/* V5 composes the established V4 controls without copying their implementations. */
(() => {
'use strict';
const config = JSON.parse(document.getElementById('caseV5Data').textContent);
const byId = id => document.getElementById(id);
const shell = document.querySelector('.case-detail-page-shell');
shell.classList.add('case-v5');
shell.prepend(byId('caseV5Tools').content.cloneNode(true));
const board = document.createElement('div');
board.className = 'v5-board';
const facts = document.createElement('aside');
facts.className = 'v5-case-facts';
facts.setAttribute('aria-label', 'Kunde og sagsoplysninger');
const center = document.createElement('main');
const modules = document.createElement('aside');
modules.className = 'v5-case-modules';
modules.setAttribute('aria-label', 'Todo, filer og relationer');
const tabs = byId('caseTabs');
tabs.before(board);
board.append(facts, center, modules);
const widgets = [...shell.querySelectorAll('.case-v4-widget-dashboard .row > div > .card')];
[3, 1, 0, 2].forEach(index => { if (widgets[index]) facts.append(widgets[index]); });
const heroStatus = document.createElement('div');
heroStatus.className = 'v5-hero-status';
['topbarStatusSelect', 'topbarPrioritySelect'].forEach(id => {
const row = byId(id)?.parentElement;
if (row) heroStatus.append(row);
});
shell.querySelector('.case-header-main').insertBefore(heroStatus, shell.querySelector('.case-header-todo'));
center.append(tabs, byId('timeActiveBanner'), byId('caseTabsContent'));
// Normalize legacy closing tags: browsers can parse later panes outside
// their tab-content. Every V5 pane belongs to the same center column.
tabs.querySelectorAll('[data-bs-target]').forEach(button => {
const pane = document.querySelector(button.getAttribute('data-bs-target'));
if (pane && pane.parentElement !== byId('caseTabsContent')) byId('caseTabsContent').append(pane);
});
const planner = byId('planner');
if (planner) { planner.hidden = true; planner.style.setProperty('display', 'none', 'important'); }
byId('planner-tab')?.closest('.nav-item')?.remove();
// Keep planner markup for existing dialogs, but never expose its pane in V5.
if (new URLSearchParams(location.search).get('tab') === 'planner') {
const url = new URL(location.href);
url.searchParams.set('tab', 'details');
history.replaceState(null, '', url);
}
const details = byId('details');
const left = byId('case-left-column');
const right = byId('case-right-column');
const detailsRow = document.createElement('div');
detailsRow.className = 'case-v4-details-columns';
details.prepend(detailsRow);
detailsRow.append(left);
modules.append(right);
const rightGrid = right.querySelector('.right-modules-grid');
['todo-steps', 'files', 'relations', 'hardware', 'time'].reverse().forEach(key => {
const card = shell.querySelector(`.right-module-card[data-module="${key}"]`);
if (card) rightGrid.prepend(card);
});
// V4 refreshes this hook after async module loads. Keep its columns in V5.
window.ensureCaseDetailsColumns = () => detailsRow;
window.updateRightColumnVisibility = () => right.classList.remove('d-none');
const originalApplyLayout = window.applyViewLayout;
window.applyViewLayout = function (...args) {
originalApplyLayout(...args);
right.classList.remove('d-none');
};
const actions = [
['assign', 'Tildel sag', 'person-check', null],
['reply_customer', 'Svar kunden', 'envelope', 'commentActionReplyCustomer'],
['log_time', 'Registrer tid', 'clock-history', 'commentActionLogTime'],
['solution', 'Gem som løsning', 'lightbulb', 'commentActionSolution'],
['close_case', 'Luk sag', 'check-circle', 'commentActionCloseCase'],
['await_customer', 'Afventer kunde', 'hourglass-split', 'commentActionAwaitCustomer'],
['append_case', 'Tilføj til sag', 'file-earmark-plus', 'commentActionAppendCase']
];
const form = byId('comment-form');
const legacyChips = form.querySelector('.comment-action-chips');
legacyChips.before(byId('caseV5ComposerTools').content.cloneNode(true));
byId('v5SelectedActions').parentElement.append(form.querySelector('button[type="submit"]'));
let assignSelected = false;
let groupId = config.groupId;
let userId = config.userId;
let members = [];
let memberRequest = 0;
let membersLoading = false;
let busy = false;
let pending = null;
const feedback = (message, error = false) => {
const el = byId('v5SubmitFeedback');
el.textContent = message;
el.classList.toggle('text-danger', error);
};
const selected = action => action[0] === 'assign' ? assignSelected : Boolean(byId(action[3])?.checked);
function renderActions() {
const host = byId('v5SelectedActions');
host.replaceChildren();
actions.forEach(action => {
const active = selected(action);
byId(`v5Option-${action[0]}`).setAttribute('aria-pressed', String(active));
if (!active) return;
const chip = document.createElement('button');
chip.type = 'button';
let label = action[1];
if (action[0] === 'assign') {
const target = members.find(member => member.user_id === userId)?.display_name
|| config.groups.find(group => group.id === groupId)?.name || 'Vælg modtager';
label = `Tildel: ${target}`;
}
if (action[0] === 'log_time') label = `Tid: ${byId('commentQuickTimeMinutes').value || '0'} min`;
chip.textContent = `${label} ×`;
chip.setAttribute('aria-label', `Fjern ${action[1]}`);
chip.addEventListener('click', () => toggleAction(action));
chip.disabled = busy || Boolean(pending);
host.append(chip);
});
byId('v5AssignmentFields').hidden = !assignSelected;
form.querySelector('button[type="submit"]').textContent = pending ? 'Fortsæt resterende handlinger' : assignSelected ? 'Gem og tildel' : actions.some(selected) ? 'Gem med handlinger' : 'Send';
}
function toggleAction(action) {
if (busy || pending) return;
if (action[0] === 'assign') {
assignSelected = !assignSelected;
if (assignSelected && groupId) loadMembers();
} else {
const checkbox = byId(action[3]);
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
}
renderActions();
}
actions.forEach(action => {
const button = document.createElement('button');
button.id = `v5Option-${action[0]}`;
button.type = 'button';
button.innerHTML = `<i class="bi bi-${action[2]} me-1"></i>`;
button.append(document.createTextNode(action[1]));
button.addEventListener('click', () => toggleAction(action));
byId('v5ActionOptions').append(button);
byId(action[3])?.addEventListener('change', renderActions);
});
const toggle = byId('v5ActionToggle');
function closeMenu() { byId('v5ActionMenu').hidden = true; toggle.setAttribute('aria-expanded', 'false'); }
toggle.addEventListener('click', () => {
const menu = byId('v5ActionMenu');
menu.hidden = !menu.hidden;
toggle.setAttribute('aria-expanded', String(!menu.hidden));
});
document.addEventListener('click', event => {
if (!byId('v5ActionMenu').contains(event.target) && !toggle.contains(event.target)) closeMenu();
});
form.addEventListener('keydown', event => { if (event.key === 'Escape') { closeMenu(); toggle.focus(); } });
function renderPicker(host, items, current, choose, search) {
host.replaceChildren();
const filtered = items.filter(item => item.name.toLocaleLowerCase('da').includes(search.toLocaleLowerCase('da')));
filtered.forEach(item => {
const button = document.createElement('button');
button.type = 'button'; button.textContent = item.name;
button.setAttribute('aria-pressed', String(item.id === current));
button.disabled = busy || Boolean(pending);
button.addEventListener('click', () => choose(item.id));
host.append(button);
});
if (!filtered.length) host.textContent = 'Ingen match';
}
function renderGroups() {
renderPicker(byId('v5GroupOptions'), config.groups, groupId, id => {
groupId = id; userId = null; members = []; renderGroups(); loadMembers();
}, byId('v5GroupSearch').value);
}
function renderUsers() {
const items = [{id: null, name: 'Kun gruppen'}, ...members.map(member => ({id: member.user_id, name: member.display_name}))];
renderPicker(byId('v5UserOptions'), items, userId, id => { userId = id; renderUsers(); }, byId('v5UserSearch').value);
renderActions();
}
async function loadMembers() {
const sequence = ++memberRequest;
membersLoading = true;
byId('v5UserSearch').disabled = true;
byId('v5UserOptions').textContent = 'Henter medarbejdere…';
try {
const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${groupId}`, {credentials: 'include'});
if (!response.ok) throw new Error('Kunne ikke hente gruppens medarbejdere. Vælg gruppen igen for at prøve igen.');
const data = await response.json();
if (sequence !== memberRequest) return;
members = data.members || [];
if (!members.some(member => member.user_id === userId)) userId = null;
byId('v5UserSearch').disabled = false;
byId('v5UserSearch').placeholder = 'Find medarbejder…';
renderUsers(); feedback('');
} catch (error) {
if (sequence === memberRequest) { byId('v5UserOptions').textContent = ''; feedback(error.message, true); }
} finally { if (sequence === memberRequest) membersLoading = false; }
}
byId('v5GroupSearch').addEventListener('input', renderGroups);
byId('v5UserSearch').addEventListener('input', renderUsers);
byId('commentQuickTimeMinutes').addEventListener('input', renderActions);
function lock(locked) {
form.querySelectorAll('input, textarea, select, button').forEach(el => { el.disabled = locked; });
// Failed submissions remain locked except for retry, avoiding an altered
// draft being attached to an already persisted comment.
if (pending && !busy) {
form.querySelector('button[type="submit"]').disabled = false;
if (pending.remaining.includes('log_time')) {
byId('commentQuickTimeRow').querySelectorAll('input, select').forEach(el => { el.disabled = false; });
toggleCommentQuickTimeStartField();
}
}
}
form.removeAttribute('onsubmit');
form.addEventListener('submit', async event => {
event.preventDefault();
if (busy) return;
const textarea = form.querySelector('textarea[name="indhold"]');
const content = textarea.value.trim();
const reason = byId('v5AssignmentReason').value.trim();
const chosen = getSelectedCommentActions();
if (!pending) {
if (!content) { feedback('Skriv en kommentar først.', true); return; }
if (assignSelected && (!groupId || !reason || membersLoading || byId('v5UserSearch').disabled)) {
feedback('Vælg gruppe, vent på medarbejderlisten og skriv en begrundelse.', true); return;
}
if (chosen.includes('log_time') && (Number(byId('commentQuickTimeMinutes').value) <= 0 || !byId('commentQuickTimeDate').value)) {
feedback('Vælg dato og et positivt minuttal før tidsregistrering.', true); return;
}
if (chosen.includes('close_case')) {
busy = true; lock(true);
let confirmed = false;
try { confirmed = await confirmCaseClosureIfNeeded({timeWillBeRegistered: chosen.includes('log_time')}); }
finally { busy = false; lock(false); }
if (!confirmed) return;
}
}
busy = true; lock(true); feedback('Gemmer…');
try {
if (!pending) {
const url = `/api/v1/sag/${config.caseId}/${assignSelected ? 'assign-with-reason' : 'kommentarer'}`;
const internal = assignSelected || chosen.includes('solution') || chosen.includes('log_time');
const body = assignSelected ? {group_id: groupId, primary_user_id: userId, reason, content} : {indhold: content, er_intern: internal};
const response = await fetch(url, {method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
const result = await response.json();
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Kunne ikke gemme kommentaren.');
const comment = assignSelected ? result.comment : result;
pending = {content, remaining: chosen.slice(), assigned: assignSelected};
let visibleContent = comment.indhold || content;
if (assignSelected) {
const decoder = document.createElement('textarea');
decoder.innerHTML = visibleContent;
visibleContent = decoder.value;
}
injectCreatedComment(comment, visibleContent, internal);
}
// Finish one action at a time. Retry never creates another comment
// or repeats actions that already returned successfully.
while (pending.remaining.length) {
await applyCommentActionPrefill(pending.remaining[0], pending.content);
pending.remaining.shift();
}
pending = null;
textarea.value = ''; byId('v5AssignmentReason').value = '';
assignSelected = false; clearSelectedCommentActions();
feedback('Kommentar og handlinger gemt.');
reloadCasePreservingContext();
} catch (error) {
feedback(pending ? `Kommentaren er gemt. En handling mangler: ${error.message}. Brug Fortsæt for at prøve den igen.` : error.message, true);
} finally { busy = false; lock(Boolean(pending)); renderActions(); }
});
renderGroups(); renderActions();
})();

View File

@ -0,0 +1,58 @@
from app.dashboard.backend.mission_service import MissionService
def test_stale_ringing_timeout_uses_last_update(monkeypatch):
calls = []
monkeypatch.setattr(MissionService, "_table_exists", staticmethod(lambda _table: True))
monkeypatch.setattr(MissionService, "get_ring_timeout_seconds", staticmethod(lambda: 120))
monkeypatch.setattr(
"app.dashboard.backend.mission_service.execute_query",
lambda query, params=(): calls.append((query, params)),
)
MissionService.expire_stale_ringing_calls()
assert len(calls) == 1
query, params = calls[0]
assert "updated_at < (NOW() - make_interval(secs => %s))" in query
assert "started_at < (NOW() - make_interval(secs => %s))" not in query
assert params == (120,)
def test_repeated_ringing_event_refreshes_call_start_time():
from pathlib import Path
source = Path("app/dashboard/backend/mission_router.py").read_text(encoding="utf-8")
assert "started_at = EXCLUDED.started_at" in source
assert "started_at = LEAST(mission_call_state.started_at, EXCLUDED.started_at)" not in source
def test_yealink_incoming_url_uses_local_value_for_recipient_and_queue():
from pathlib import Path
source = Path("app/settings/frontend/settings.html").read_text(encoding="utf-8")
assert "queue_name=$local&recipient_extension=$local" in source
def test_case_location_list_is_customer_scoped_and_prioritizes_customer_address():
from pathlib import Path
template = Path("app/modules/sag/templates/detail_v4.html").read_text(encoding="utf-8")
assert "/api/v1/locations/by-customer/${encodeURIComponent(caseAddCustomerId)}" in template
assert "visible = customerLocations.filter" in template
assert "customer.address" in template
assert "normalizeAddress" in template
def test_active_calls_are_not_expired_during_state_read():
import inspect
source = inspect.getsource(MissionService.get_active_calls)
assert "expire_stale_ringing_calls" not in source
def test_active_call_read_failure_preserves_client_state():
from pathlib import Path
source = Path("app/dashboard/frontend/mission_control_v2.html").read_text(encoding="utf-8")
assert '"active_calls", MissionService' not in source

View File

@ -104,6 +104,8 @@ def test_case_planner_exposes_case_specific_allocations_endpoint():
assert '"remaining_minutes"' in source
assert '"deferred"' in source
assert '"subcases"' in source
assert "allocation_summary.planned_minutes" in source
assert "COALESCE(allocation_summary.items, '[]'::json) AS allocations" in source
def test_closed_case_is_checked_before_allocation(monkeypatch):

View File

@ -534,10 +534,10 @@ def test_v4_relation_tree_is_own_tab_with_search_filters_and_inline_module_conte
assert 'id="relationTreeType"' in template
assert 'id="relationTreePosition"' in template
assert 'id="relationTreeStatusFilter"' in template
assert 'data-relation-module="comments"' in template
assert 'data-relation-module="todos"' in template
assert 'data-relation-module="files"' in template
assert 'data-relation-module="relations"' in template
assert "['comments', 'Kommentarer'" in template
assert "['todos', 'Opgaver'" in template
assert "['files', 'Filer'" in template
assert "['relations', 'Relationer'" in template
def test_case_route_access_requires_edit_for_nested_post():
@ -659,6 +659,28 @@ def test_v4_compact_time_module_is_moved_below_tags_and_creates_drafts():
assert "medarbejder_ids: [null]" in template
def test_new_case_starts_with_only_core_fields_and_reveals_type_modules():
template = Path("app/modules/sag/templates/create.html").read_text()
script = Path("static/js/case-create.js").read_text()
assert 'id="caseRelationsSection" class="d-none"' in template
assert 'id="caseGroupField"' in template
assert 'id="caseTagsSection"' in template
assert 'id="caseTagsButton"' in template
assert "window.tagPicker.showSelection(addTag);" in script
assert "const availableModules = visiblePanels;" in script
assert "relationsSection?.classList.toggle('d-none'" in script
assert "addEventListener('change', () => updateCaseTypeSections(true))" in template
def test_global_search_enter_opens_exact_hash_case_number_in_v4():
template = Path("app/shared/frontend/base.html").read_text()
assert "if (navigateToCaseNumber(globalSearchInput.value))" in template
assert "match(/^#\\s*(\\d+)$/)" in template
assert "window.location.href = `/sag/${sagId}/v4`;" in template
def test_postit_keeps_tags_time_and_full_time_tab_available():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
@ -771,6 +793,12 @@ def test_v4_embeds_case_planner_with_direct_subcase_creation():
assert "function shiftEmbeddedPlannerWeek(direction)" in template
assert "Klik på en dag for at allokere tid" in template
assert 'id="casePlannerSlotModal"' in template
assert "function renderEmbeddedPlannerSubcaseCalendar()" in template
assert "function openEmbeddedPlannerSubcaseSlot(dateKey)" in template
assert "async function saveEmbeddedPlannerSubcaseSlot(event)" in template
assert "subcase.allocations || []" in template
assert "Tid er allokeret til undersag" in template
assert 'id="casePlannerSubcaseWeek"' in template
assert "function openEmbeddedPlannerSlot(dateKey)" in template
assert "async function saveEmbeddedPlannerSlot(event)" in template
assert "Dette er planlagt tid" in template
@ -826,6 +854,48 @@ def test_v4_uses_fixed_case_statuses_and_blocks_unfinished_time_on_close():
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
assert "select.value = previousStatus;" in template
def test_v4_does_not_add_legacy_current_status_to_trigger_options():
views = Path("app/modules/sag/frontend/views.py").read_text()
v4_block = views.split('template_name = "modules/sag/templates/detail_v4.html"', 1)[0].rsplit(
"status_options = _fetch_case_status_options()", 1
)[1].split('is_deadline_overdue', 1)[0]
assert "status_options.append(current_status)" not in v4_block
def test_v4_uses_a_scoped_visual_system_for_consistent_case_ui():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "--case-primary: #0f4c75;" in template
assert "--case-primary-soft: #eef6fa;" in template
assert ".case-detail-page-shell .case-v4-widget-dashboard .card" in template
assert ".case-detail-page-shell .btn-outline-success" in template
assert "#caseTabsContent .tab-pane:not(#details) .history-timeline-shell" in template
assert "#caseTabsContent .tab-pane:not(#details) .table-hover > tbody > tr:hover" in template
assert ".case-relation-hero { display:flex" in template
assert "background:var(--case-primary-soft, #eef6fa);" in template
def test_v4_relation_tree_can_create_related_cases_from_the_tree():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'onclick="showCreateRelatedModal()"><i class="bi bi-plus-lg me-1"></i>Opret relateret sag' in template
assert 'data-relation-create="${safeId}"' in template
assert "window.showCreateRelatedModalForCase" in template
assert "relatedCaseSourceId = Number(sourceCaseId) || caseId;" in template
assert "const sourceCaseId = Number(relatedCaseSourceId) || caseId;" in template
def test_v4_relation_tree_exposes_module_content_as_a_clear_preview():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "Indhold på sag #${safeId}" in template
assert "loadRelationModuleOverview" in template
assert "const nonEmpty = results.filter(Boolean);" in template
assert "relation-tree-module-section" in template
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():

View File

@ -74,3 +74,12 @@ def test_customer_articles_are_excluded_without_customer_scope(monkeypatch):
assert result["total"] == 0
assert "ka.visibility IN ('general','internal')" in captured[0][0]
assert "visibility='customer'" not in captured[0][0]
def test_solution_management_supports_setting_the_case_customer():
template = (Path(__file__).resolve().parent.parent / "app/modules/sag/templates/solutions_management.html").read_text()
source = (Path(__file__).resolve().parent.parent / "app/modules/sag/backend/solutions.py").read_text()
assert 'id="editCustomerSearch"' in template
assert '/api/v1/search/customers' in template
assert '/customer/replace' in template
assert 'sg.customer_id, c.name AS customer_name' in source

163
tests/test_sag_v5.py Normal file
View File

@ -0,0 +1,163 @@
"""V5 assignment persistence is atomic and leaves the existing APIs untouched."""
import asyncio
from collections import deque
from unittest.mock import Mock
import pytest
from fastapi import HTTPException
from starlette.requests import Request
# Reuse the established test module's optional-auth isolation.
from test_sag_module import sag_router
class Cursor:
def __init__(self, rows, fail_insert=False):
self.rows = deque(rows)
self.calls = []
self.fail_insert = fail_insert
def __enter__(self):
return self
def __exit__(self, *_):
return False
def execute(self, sql, params):
self.calls.append((sql, params))
if self.fail_insert and 'INSERT INTO sag_kommentarer' in sql:
raise RuntimeError('database failure')
def fetchone(self):
return self.rows.popleft()
def setup_transaction(monkeypatch, *, target=True, fail_insert=False):
rows = [
{'id': 12, 'ansvarlig_bruger_id': 2, 'assigned_group_id': 3},
{'name': 'Support'},
{'name': 'Maria'} if target else None,
{'name': 'Christian'}, {'name': 'Peter'}, {'name': 'Drift'},
{'id': 91, 'indhold': 'intern note', 'er_intern': True},
]
cursor = Cursor(rows, fail_insert)
conn = Mock()
conn.cursor.return_value = cursor
release = Mock()
monkeypatch.setattr(sag_router, 'get_db_connection', lambda: conn)
monkeypatch.setattr(sag_router, 'release_db_connection', release)
monkeypatch.setattr(sag_router, 'table_has_column', lambda *_: True)
monkeypatch.setattr(sag_router, '_get_user_id_from_request', lambda _: 1)
return conn, cursor, release
def run_assignment(**kwargs):
payload = sag_router.CaseAssignmentWithReason(
group_id=4, primary_user_id=5, reason=kwargs.pop('reason', ' Adgang til serveren '),
content=kwargs.pop('content', ' Netværk kontrolleret '), **kwargs,
)
request = Request({'type': 'http', 'method': 'POST', 'path': '/api/v1/sag/12/assign-with-reason', 'headers': []})
return asyncio.run(sag_router.assign_case_with_reason(12, payload, request, {"id": 1}))
def test_assignment_and_internal_reason_commit_together(monkeypatch):
conn, cursor, release = setup_transaction(monkeypatch)
result = run_assignment()
assert result['comment']['er_intern'] is True
conn.commit.assert_called_once()
conn.rollback.assert_not_called()
release.assert_called_once_with(conn)
insert = next(params for sql, params in cursor.calls if 'INSERT INTO sag_kommentarer' in sql)
assert insert[:2] == (12, 'Christian')
assert 'Tildeling: Peter → Maria' in insert[2]
assert 'Gruppe: Drift → Support' in insert[2]
assert 'Begrundelse: Adgang til serveren' in insert[2]
assert insert[2].startswith('Netværk kontrolleret\n')
# Compatible colleagues survive; the new primary is added without duplicates.
assert any('NOT EXISTS' in sql and 'DELETE FROM sag_case_assignees' in sql for sql, _ in cursor.calls)
assert any('ON CONFLICT' in sql for sql, _ in cursor.calls)
def test_invalid_group_member_does_not_change_assignment(monkeypatch):
conn, cursor, release = setup_transaction(monkeypatch, target=False)
with pytest.raises(HTTPException) as error:
run_assignment()
assert error.value.status_code == 400
assert not any('UPDATE sag_sager' in sql for sql, _ in cursor.calls)
conn.commit.assert_not_called()
conn.rollback.assert_called_once()
release.assert_called_once_with(conn)
def test_comment_failure_rolls_back_assignment(monkeypatch):
conn, _, release = setup_transaction(monkeypatch, fail_insert=True)
with pytest.raises(HTTPException) as error:
run_assignment()
assert error.value.status_code == 500
conn.commit.assert_not_called()
conn.rollback.assert_called_once()
release.assert_called_once_with(conn)
def test_assignment_reason_is_plain_text_in_legacy_comment_renderer(monkeypatch):
_, cursor, _ = setup_transaction(monkeypatch)
run_assignment(reason='<img src=x onerror=alert(1)>', content='<script>bad()</script>')
note = next(params[2] for sql, params in cursor.calls if 'INSERT INTO sag_kommentarer' in sql)
assert '<img' not in note and '<script' not in note
assert '&lt;img' in note and '&lt;script&gt;' in note
@pytest.mark.parametrize('field', ['reason', 'content'])
def test_whitespace_is_rejected_before_database_access(monkeypatch, field):
connect = Mock()
monkeypatch.setattr(sag_router, 'get_db_connection', connect)
with pytest.raises(HTTPException) as error:
run_assignment(**{field: ' '})
assert error.value.status_code == 400
connect.assert_not_called()
def test_no_fallback_to_external_comment_on_old_schema(monkeypatch):
connect = Mock()
monkeypatch.setattr(sag_router, 'get_db_connection', connect)
monkeypatch.setattr(sag_router, 'table_has_column', lambda *_: False)
with pytest.raises(HTTPException) as error:
run_assignment()
assert error.value.status_code == 409
connect.assert_not_called()
@pytest.mark.parametrize('version', ['v4', 'v5'])
def test_case_view_selects_requested_version_without_changing_default(monkeypatch, version):
from app.modules.sag.frontend import views
from datetime import datetime
from starlette.responses import Response
case = {'id': 12, 'titel': 'Test', 'status': 'Ny', 'created_at': datetime(2026, 10, 3),
'customer_id': None, 'hovedkontakt_id': None, 'kontakt_id': None,
'beskrivelse': '', 'template_key': 'ticket', 'type': 'ticket'}
def query(sql, params=None):
if 'SELECT s.*,' in sql and 'WHERE s.id = %s' in sql:
return [case]
return []
rendered = []
monkeypatch.setattr(views, 'execute_query', query)
monkeypatch.setattr(views, '_fetch_case_status_options', lambda: ['Ny', 'Lukket'])
monkeypatch.setattr(views, '_fetch_assignment_users', lambda: [])
monkeypatch.setattr(views, '_fetch_assignment_groups', lambda: [])
class Templates:
def TemplateResponse(self, name, context):
rendered.append((name, context))
return Response('test')
monkeypatch.setattr(views, 'templates', Templates())
request = Request({'type': 'http', 'method': 'GET', 'path': f'/sag/12/{version}',
'scheme': 'http', 'server': ('localhost', 8001), 'headers': [], 'query_string': b''})
response = asyncio.run(views.sag_detaljer_v4(request, 12))
assert response.status_code == 200
assert rendered[0][0] == f'modules/sag/templates/detail_{version}.html'
assert rendered[0][1]['case']['id'] == 12
assert response.headers['cache-control'] == 'no-store, max-age=0'

View File

@ -202,7 +202,7 @@ def test_yealink_incoming_url_contains_call_and_recipient_variables():
assert 'id="yealinkIncomingUrl"' in settings_template
assert "call_id=$call_id" in settings_template
assert "caller_number=$display_remote" in settings_template
assert "recipient_extension=$active_user" in settings_template
assert "recipient_extension=$local" in settings_template
def test_mission_incoming_call_resolves_the_receiving_phone_user(monkeypatch):