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', state = 'ringing',
ended_at = NULL, ended_at = NULL,
answered_at = NULL, answered_at = NULL,
started_at = LEAST(mission_call_state.started_at, EXCLUDED.started_at), started_at = EXCLUDED.started_at,
updated_at = NOW(), updated_at = NOW(),
last_payload = jsonb_set( last_payload = jsonb_set(
EXCLUDED.last_payload, EXCLUDED.last_payload,

View File

@ -27,9 +27,9 @@ class MissionService:
@staticmethod @staticmethod
def get_ring_timeout_seconds() -> int: def get_ring_timeout_seconds() -> int:
# A Yealink "Incoming call" event has no matching event when the # A Yealink "Incoming call" event has no matching event when the
# caller abandons the call. Keep it briefly, then remove it from the # caller abandons the call. Phones may not emit another event while
# live boards so stale ringing phones cannot remain visible. # ringing, so allow a full ring window before clearing a stale entry.
return 30 return 120
@staticmethod @staticmethod
def expire_stale_ringing_calls() -> None: def expire_stale_ringing_calls() -> None:
@ -44,7 +44,7 @@ class MissionService:
ended_at = COALESCE(ended_at, NOW()), ended_at = COALESCE(ended_at, NOW()),
updated_at = NOW() updated_at = NOW()
WHERE state = 'ringing' WHERE state = 'ringing'
AND started_at < (NOW() - make_interval(secs => %s)) AND updated_at < (NOW() - make_interval(secs => %s))
""", """,
(timeout_seconds,), (timeout_seconds,),
) )
@ -211,7 +211,6 @@ class MissionService:
logger.warning("Mission table missing: mission_call_state (active calls unavailable)") logger.warning("Mission table missing: mission_call_state (active calls unavailable)")
return [] return []
MissionService.expire_stale_ringing_calls()
rows = execute_query( 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, 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 { return {
"kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default), "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, []), "employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []), "active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []), "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) @router.get("/locations/search", response_model=LocationSearchResponse)
async def search_locations( async def search_locations(
q: str = Query(..., min_length=1, max_length=255, description="Search query (name or address)"), 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. Search for locations by name or address.
@ -1562,31 +1563,38 @@ async def search_locations(
search_term = f"%{q}%" search_term = f"%{q}%"
# Search locations_locations (case-insensitive match on name, street, city) # 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 SELECT * FROM locations_locations
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
{customer_filter}
AND ( AND (
name ILIKE %s name ILIKE %s
OR address_street ILIKE %s OR address_street ILIKE %s
OR address_postal_code ILIKE %s
OR address_city ILIKE %s OR address_city ILIKE %s
) )
ORDER BY name ASC ORDER BY name ASC
LIMIT %s 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 # Get total count (without limit) for the search
count_query = """ count_query = f"""
SELECT COUNT(*) as total FROM locations_locations SELECT COUNT(*) as total FROM locations_locations
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
{customer_filter}
AND ( AND (
name ILIKE %s name ILIKE %s
OR address_street ILIKE %s OR address_street ILIKE %s
OR address_postal_code ILIKE %s
OR address_city 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 total_count = count_result[0]['total'] if count_result else 0
locations = [Location(**row) for row in results] 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 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 = [] hierarchy = []
current_parent_id = location.get("parent_location_id") current_parent_id = location.get("parent_location_id")
while current_parent_id: while current_parent_id:
@ -907,6 +930,7 @@ def detail_location_view(id: int = Path(..., gt=0)):
location["hierarchy"] = hierarchy location["hierarchy"] = hierarchy
location["children"] = children location["children"] = children
location["relation_tree"] = relation_tree location["relation_tree"] = relation_tree
location["related_cases"] = related_cases
location["direct_child_nodes"] = direct_child_nodes location["direct_child_nodes"] = direct_child_nodes
location["related_location_count"] = max(len(relation_rows) - 1, 0) location["related_location_count"] = max(len(relation_rows) - 1, 0)
location["same_customer_locations"] = same_customer_locations location["same_customer_locations"] = same_customer_locations

View File

@ -506,6 +506,12 @@
<i class="bi bi-diagram-3 me-2"></i>Relationer <i class="bi bi-diagram-3 me-2"></i>Relationer
</button> </button>
</li> </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"> <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"> <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 <i class="bi bi-hdd-stack me-2"></i>Hardware på lokation
@ -521,6 +527,39 @@
<!-- Tab Content --> <!-- Tab Content -->
<div class="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 --> <!-- Tab 1: Information -->
<div class="tab-pane fade show active" id="infoContent" role="tabpanel" aria-labelledby="infoTab"> <div class="tab-pane fade show active" id="infoContent" role="tabpanel" aria-labelledby="infoTab">
<section class="card border-0 mb-4"> <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, child.deferred_until, child.deferred_until_case_id, child.deferred_until_status,
COALESCE(owner.full_name,owner.username) AS responsible_name, COALESCE(owner.full_name,owner.username) AS responsible_name,
waiting.titel AS deferred_case_title, 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 FROM sag_relationer relation
JOIN sag_sager child ON child.id=relation.målsag_id AND child.deleted_at IS NULL 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 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 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' WHERE relation.kilde_sag_id=%s AND LOWER(relation.relationstype)='undersag'
AND relation.deleted_at IS NULL AND relation.deleted_at IS NULL
ORDER BY child.created_at, child.id""", ORDER BY child.created_at, child.id""",

View File

@ -407,6 +407,13 @@ class CaseAssignmentsUpdate(BaseModel):
primary_user_id: Optional[int] = None 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 = ( SAG_LIST_COLUMN_KEYS = (
"id", "company", "contact", "description", "type", "priority", "status", "id", "company", "contact", "description", "type", "priority", "status",
"owner", "group", "next_todo", "created", "start", "deferred", "deadline", "owner", "group", "next_todo", "created", "start", "deferred", "deadline",
@ -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) 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]) @router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
async def list_todo_steps(sag_id: int): async def list_todo_steps(sag_id: int):
try: try:
@ -3013,6 +3092,48 @@ async def list_case_hardware(sag_id: int):
logger.error("❌ Error listing case hardware: %s", e) logger.error("❌ Error listing case hardware: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case hardware") 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") @router.post("/sag/{sag_id}/hardware")
async def add_case_hardware(sag_id: int, data: dict): async def add_case_hardware(sag_id: int, data: dict):
"""Add hardware to case.""" """Add hardware to case."""

View File

@ -418,7 +418,7 @@ async def list_solutions(
tuple(params), tuple(params),
) or {"total": 0} ) or {"total": 0}
items = execute_query( 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(creator.full_name,creator.username) AS created_by,
COALESCE(updater.full_name,updater.username) AS updated_by, COALESCE(updater.full_name,updater.username) AS updated_by,
ka.id AS article_id, ka.status AS article_status 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") 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) @router.get("/sag/{sag_id}/v4", response_class=HTMLResponse)
async def sag_detaljer_v4(request: Request, sag_id: int): async def sag_detaljer_v4(request: Request, sag_id: int):
"""Display the active V4 case page.""" """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) logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = [] 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() 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")) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
template_name = "modules/sag/templates/detail_v4.html" 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, { response = templates.TemplateResponse(template_name, {
"request": request, "request": request,
"case": sag, "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 class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
</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> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<!-- Contact Search --> <!-- Contact Search -->
@ -338,6 +339,7 @@
<input type="hidden" id="customer_id" name="customer_id"> <input type="hidden" id="customer_id" name="customer_id">
</div> </div>
</div> </div>
</section>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div> <div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
@ -350,7 +352,7 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </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> <label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id"> <select class="form-select" id="assigned_group_id">
<option value="">Ingen</option> <option value="">Ingen</option>
@ -388,6 +390,15 @@
</div> </div>
</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"> <hr class="my-4 opacity-25">
<!-- Fields that belong to the selected case type are rendered here. --> <!-- 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> <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: 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> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-12"> <div class="col-12">
@ -1538,7 +1549,7 @@
// Both the selected type and current user are now known. Reapply the // Both the selected type and current user are now known. Reapply the
// type defaults so Intern ticket cannot display a stale assignee. // type defaults so Intern ticket cannot display a stale assignee.
updateCaseTypeSections(false); updateCaseTypeSections(false);
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(false)); document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(true));
await applyTelefoniPrefill(); await applyTelefoniPrefill();
await window.caseCreateUI?.init(); await window.caseCreateUI?.init();
}); });
@ -1674,5 +1685,5 @@
} }
}); });
</script> </script>
<script src="/static/js/case-create.js?v=13"></script> <script src="/static/js/case-create.js?v=14"></script>
{% endblock %} {% endblock %}

View File

@ -938,6 +938,7 @@
.case-planner-day.is-today { background:#f0f8fc; box-shadow:inset 0 3px #1782aa; } .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-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 { 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: 900px) { .case-planner-week { min-width:780px; } .case-planner-calendar { overflow-x:auto; } }
@media (max-width: 1050px) { .case-planner-shell { grid-template-columns:1fr; } } @media (max-width: 1050px) { .case-planner-shell { grid-template-columns:1fr; } }
@ -1290,35 +1291,37 @@
margin-top: 0.5rem; 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-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.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-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; opacity:.76; } .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 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-hero p { margin:0; color:var(--case-muted, #587083); font-size:.88rem; }
.case-relation-status { font-size:.78rem; color:rgba(255,255,255,.82); text-align:right; } .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(--border-color,#e4eaed); background:color-mix(in srgb,var(--accent,#126b82) 3%,var(--bg-card,#fff)); } .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-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 { position:relative; flex:1 1 280px; }
.case-relation-search i { position:absolute; left:.8rem; top:50%; transform:translateY(-50%); color:#78909a; } .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(--border-color,#dce5e9); border-radius:9px; padding:.45rem .7rem .45rem 2.25rem; background:var(--bg-card,#fff); color:var(--text-primary,#23343d); } .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(--text-secondary,#6b7c86); font-size:.74rem; border-bottom:1px solid var(--border-color,#edf0f2); } .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; } .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 { 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: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 #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 { 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:#acd0d8; box-shadow:0 8px 22px rgba(28,61,78,.09); transform:translateY(-1px); } .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:#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-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-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-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:#187487; color:#fff; } .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-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-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-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-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-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:.15rem .85rem .85rem 3.45rem; } .relation-tree-module-panel { padding:.2rem .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-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 { 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; } .relation-tree-module-row:last-child { border-bottom:0; }
[data-bs-theme="dark"] .case-relation-explorer { border-color:#334650; } [data-bs-theme="dark"] .case-relation-explorer { border-color:#334650; }
@ -2165,6 +2168,34 @@
color: var(--accent); 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 { .relation-type-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -2589,7 +2620,44 @@
.location-list-header, .location-list-header,
.location-row { .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, .customer-list-header,
@ -4270,6 +4338,122 @@
border-color: rgba(133, 165, 190, 0.26); 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) { @media (prefers-reduced-motion: reduce) {
.case-detail-page-shell > .container-fluid.position-relative > .row > [class*="col-"] > .card, .case-detail-page-shell > .container-fluid.position-relative > .row > [class*="col-"] > .card,
.case-header-meta-line a { transition: none; } .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) { if (!createRelatedCaseModalInstance) {
const modalEl = document.getElementById('createRelatedCaseModal'); const modalEl = document.getElementById('createRelatedCaseModal');
if (!modalEl) { if (!modalEl) {
@ -6728,6 +6918,24 @@
updateNewCaseRelationTypeHint(); 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) { function relationTypeMeaning(type) {
const map = { const map = {
'Relateret til': { 'Relateret til': {
@ -6808,13 +7016,22 @@
const title = document.getElementById('newCaseTitle').value; const title = document.getElementById('newCaseTitle').value;
const relationType = document.getElementById('newCaseRelationType').value; const relationType = document.getElementById('newCaseRelationType').value;
const description = document.getElementById('newCaseDescription').value; const description = document.getElementById('newCaseDescription').value;
const customerId = (caseCustomerId || wikiCustomerId || null); const sourceCaseId = Number(relatedCaseSourceId) || caseId;
let customerId = (caseCustomerId || wikiCustomerId || null);
if (!title) { if (!title) {
showCaseFeedback('Titel er påkrævet'); showCaseFeedback('Titel er påkrævet');
return; 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) { if (!customerId) {
showCaseFeedback('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.'); showCaseFeedback('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
return; return;
@ -6840,7 +7057,7 @@
const newCase = await caseResponse.json(); const newCase = await caseResponse.json();
// 2. Create the relation // 2. Create the relation
const relationResponse = await fetch(`/api/v1/sag/${caseId}/relationer`, { const relationResponse = await fetch(`/api/v1/sag/${sourceCaseId}/relationer`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
@ -6919,6 +7136,7 @@
let embeddedPlannerWeekAnchor = new Date(); let embeddedPlannerWeekAnchor = new Date();
let embeddedPlannerItems = []; let embeddedPlannerItems = [];
let embeddedPlannerSubcases = [];
function embeddedPlannerDateKey(value) { function embeddedPlannerDateKey(value) {
const date = value instanceof Date ? value : new Date(value); const date = value instanceof Date ? value : new Date(value);
@ -6948,6 +7166,25 @@
const items = embeddedPlannerItems.filter((item) => embeddedPlannerDateKey(item.starts_at) === key); 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>`; 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(''); }).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) { function shiftEmbeddedPlannerWeek(direction) {
@ -6975,6 +7212,18 @@
bootstrap.Modal.getOrCreateInstance(document.getElementById('casePlannerSlotModal')).show(); 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) { async function saveEmbeddedPlannerSlot(event) {
event.preventDefault(); event.preventDefault();
const button = document.getElementById('casePlannerSlotSaveBtn'); 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) { async function loadEmbeddedCasePlanner(force = false) {
const pane = document.getElementById('planner'); const pane = document.getElementById('planner');
if (!pane || (pane.dataset.loaded === 'true' && !force)) return; 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>'; }).join('') : '<div class="text-muted py-3">Sagen er ikke planlagt endnu.</div>';
const subcases = plan.subcases || []; 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) => { subcaseList.innerHTML = subcases.length ? `<div class="small fw-semibold text-muted mb-1">Oprettede undersager</div>${subcases.map((subcase) => {
const deferredText = subcase.deferred_until const deferredText = subcase.deferred_until
? `Udsat til ${new Date(subcase.deferred_until).toLocaleDateString('da-DK')}` ? `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')}` : ''); : (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>`; const allocations = subcase.allocations || [];
}).join('')}` : '<div class="small text-muted">Ingen undersager oprettet endnu.</div>'; 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'; pane.dataset.loaded = 'true';
} catch (error) { } catch (error) {
allocationList.innerHTML = `<div class="text-danger py-3">${escapeHtml(error.message)}</div>`; allocationList.innerHTML = `<div class="text-danger py-3">${escapeHtml(error.message)}</div>`;
@ -7058,6 +7328,32 @@
finally { button.disabled = false; } 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) { async function createPlannerSubcase(event) {
event.preventDefault(); event.preventDefault();
const button = document.getElementById('plannerSubcaseCreateBtn'); const button = document.getElementById('plannerSubcaseCreateBtn');
@ -7096,6 +7392,10 @@
window.loadEmbeddedCasePlanner = loadEmbeddedCasePlanner; window.loadEmbeddedCasePlanner = loadEmbeddedCasePlanner;
window.createEmbeddedCaseAllocation = createEmbeddedCaseAllocation; window.createEmbeddedCaseAllocation = createEmbeddedCaseAllocation;
window.createPlannerSubcase = createPlannerSubcase; window.createPlannerSubcase = createPlannerSubcase;
window.openPlannerSubcaseAllocation = openPlannerSubcaseAllocation;
window.savePlannerSubcaseAllocation = savePlannerSubcaseAllocation;
window.openEmbeddedPlannerSubcaseSlot = openEmbeddedPlannerSubcaseSlot;
window.saveEmbeddedPlannerSubcaseSlot = saveEmbeddedPlannerSubcaseSlot;
window.shiftEmbeddedPlannerWeek = shiftEmbeddedPlannerWeek; window.shiftEmbeddedPlannerWeek = shiftEmbeddedPlannerWeek;
window.showEmbeddedPlannerCurrentWeek = showEmbeddedPlannerCurrentWeek; window.showEmbeddedPlannerCurrentWeek = showEmbeddedPlannerCurrentWeek;
window.selectEmbeddedPlannerDay = selectEmbeddedPlannerDay; window.selectEmbeddedPlannerDay = selectEmbeddedPlannerDay;
@ -7889,17 +8189,21 @@
<div class="location-list-header"> <div class="location-list-header">
<span>Navn</span> <span>Navn</span>
<span>Type</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> </div>
${locations.map(l => ` ${locations.map(l => `
<div class="location-row"> <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> <i class="bi bi-geo-alt me-1 text-secondary"></i>
${l.name} ${l.name}
</div> </div>
<small>${l.location_type || '-'}</small> <small>${l.location_type || '-'}</small>
<button class="btn btn-sm btn-delete" onclick="unlinkLocation(${l.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> </button>
</div> </div>
`).join('')} `).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 ============ // ============ Wiki Handling ============
async function loadCaseWiki(searchValue = '') { async function loadCaseWiki(searchValue = '') {
const container = document.getElementById('wiki-list'); const container = document.getElementById('wiki-list');
@ -10909,6 +11268,10 @@
</div> </div>
<div id="casePlannerWeek" class="case-planner-week"></div> <div id="casePlannerWeek" class="case-planner-week"></div>
</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 id="casePlannerAllocationList" class="mt-3"><div class="text-muted py-3">Åbn fanen for at hente planen.</div></div>
</div> </div>
</section> </section>
@ -10934,6 +11297,10 @@
</div> </div>
</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 fade" id="casePlannerSlotModal" tabindex="-1" aria-labelledby="casePlannerSlotModalTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 rounded-4 shadow-lg"> <div class="modal-content border-0 rounded-4 shadow-lg">
@ -10966,7 +11333,10 @@
<section class="case-relation-explorer" aria-labelledby="caseTreeHeading"> <section class="case-relation-explorer" aria-labelledby="caseTreeHeading">
<div class="case-relation-hero"> <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><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>
<div class="case-relation-toolbar" role="search"> <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> <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"> <div class="relation-tree-node-main">
<span class="relation-tree-node-icon"><i class="bi ${current?'bi-bullseye':'bi-file-earmark-text'}"></i></span> <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-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>
<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> </article>
${renderedChildren.length ? `<div class="relation-tree-branch">${renderedChildren.join('')}</div>` : ''} ${renderedChildren.length ? `<div class="relation-tree-branch">${renderedChildren.join('')}</div>` : ''}
</div>`; </div>`;
@ -12523,6 +12893,28 @@
target.innerHTML = '<span class="text-danger small">Kunne ikke hente modulindhold.</span>'; 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() { async function loadCaseTree() {
const status = document.getElementById('caseTreeStatus'); const status = document.getElementById('caseTreeStatus');
const content = document.getElementById('caseTreeContent'); const content = document.getElementById('caseTreeContent');
@ -12601,6 +12993,14 @@
document.getElementById('caseTreeContent').innerHTML = renderCaseTreeNodes(window.caseRelationTreeData || []); document.getElementById('caseTreeContent').innerHTML = renderCaseTreeNodes(window.caseRelationTreeData || []);
}); });
document.getElementById('caseTreeContent')?.addEventListener('click', event => { 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]'); const moduleToggle = event.target.closest('[data-relation-modules]');
if (moduleToggle) { if (moduleToggle) {
const caseId = Number(moduleToggle.dataset.relationModules); const caseId = Number(moduleToggle.dataset.relationModules);
@ -12608,13 +13008,11 @@
const opening = panel.classList.contains('d-none'); const opening = panel.classList.contains('d-none');
panel.classList.toggle('d-none', !opening); panel.classList.toggle('d-none', !opening);
moduleToggle.setAttribute('aria-expanded', String(opening)); moduleToggle.setAttribute('aria-expanded', String(opening));
if (opening) {
loadRelationModuleOverview(caseId, document.getElementById(`relation-module-content-${caseId}`));
}
return; 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> </script>
@ -16722,6 +17120,7 @@
<input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på..."> <input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på...">
</div> </div>
<div class="mb-3"> <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> <label class="form-label">Hvordan forholder den nye sag sig til denne sag? *</label>
<select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()"> <select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
<option value="undersag">↳ Den nye sag er en undersag til denne</option> <option value="undersag">↳ Den nye sag er en undersag til denne</option>
@ -18039,6 +18438,9 @@
reloadCasePreservingContext(); reloadCasePreservingContext();
} catch (e) { } catch (e) {
console.error('saveCaseStatusFromTopbar error', 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'); showToast(e.message || 'Kunne ikke gemme status', 'danger');
} }
} }
@ -22987,32 +23389,63 @@
_showRelModal( _showRelModal(
`<i class="bi bi-geo-alt me-2"></i>Lokation`, `<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">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_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>`, `<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>` `<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 input = document.getElementById('rqloc_search');
const results = document.getElementById('rqloc_results'); const results = document.getElementById('rqloc_results');
let searchTimer; const selectedId = document.getElementById('rqloc_id');
input.addEventListener('input', function () { let customerLocations = [];
clearTimeout(searchTimer); let locationSavePending = false;
searchTimer = setTimeout(async function () { const normalizeAddress = value => String(value || '').toLocaleLowerCase('da-DK').replace(/[^\p{L}\p{N}]+/gu, ' ').trim().replace(/\s+/g, ' ');
const query = input.value.trim(); const caseAddress = normalizeAddress({{ ((customer.address if customer else '') ~ ' ' ~ (customer.postal_code if customer else '') ~ ' ' ~ (customer.city if customer else ''))|tojson }});
if (query.length < 2) { results.style.display = 'none'; return; } const renderCustomerLocations = () => {
try { const query = normalizeAddress(input.value);
const response = await fetch(`/api/v1/locations/search?q=${encodeURIComponent(query)}&limit=10`, { credentials: 'include' }); const visible = customerLocations.filter(item => {
const payload = response.ok ? await response.json() : {}; if (!query) return true;
const items = Array.isArray(payload.results) ? payload.results : []; const haystack = normalizeAddress([item.name, item.address_street, item.address_postal_code, item.address_city, item.location_type].join(' '));
results.innerHTML = items.length ? items.map(function (item) { return haystack.includes(query);
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>`; visible.sort((a, b) => {
}).join('') : '<div class="p-2 text-muted small">Ingen lokationer fundet</div>'; const addressOf = item => normalizeAddress([item.address_street, item.address_postal_code, item.address_city].filter(Boolean).join(' '));
results.style.display = 'block'; const aAddress = addressOf(a), bAddress = addressOf(b);
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'; }); }); const aMatch = caseAddress && aAddress && (aAddress.includes(caseAddress) || caseAddress.includes(aAddress));
} catch { results.innerHTML = '<div class="p-2 text-danger small">Lokationer kunne ikke hentes</div>'; results.style.display = 'block'; } const bMatch = caseAddress && bAddress && (bAddress.includes(caseAddress) || caseAddress.includes(bAddress));
}, 250); 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() { 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 }) }); 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'); } 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'); 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 ─────────────────────────────────────────── // ── 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"> <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> <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="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="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> <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> <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'}; 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 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=''; 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.'; 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(''); 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)}`;}} }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();}; 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';}
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);}}; 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.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.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();}; 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);}}); 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);}}; 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> </script>
{% endblock %} {% endblock %}

View File

@ -2656,7 +2656,7 @@ function buildYealinkActionUrls() {
const base = baseRaw.replace(/\/$/, ''); const base = baseRaw.replace(/\/$/, '');
const tokenPart = token ? `token=${encodeURIComponent(token)}&` : ''; 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`; 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`; termEl.value = `${base}/api/v1/telefoni/terminated?${tokenPart}callid=$call_id&duration=$call_duration`;
} }

View File

@ -2289,6 +2289,9 @@ if (bmcOriginalFetch) {
navigateResults(-1); navigateResults(-1);
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (navigateToCaseNumber(globalSearchInput.value)) {
return;
}
if (navigateToSagFromScan(e.target.value)) { if (navigateToSagFromScan(e.target.value)) {
return; return;
} }
@ -2531,6 +2534,19 @@ if (bmcOriginalFetch) {
return true; 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 // Global search function
async function performGlobalSearch(query) { async function performGlobalSearch(query) {
if (navigateToSagFromScan(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'], opgave: ['relations'],
abonnement: ['relations'], abonnement: ['relations'],
}[type] || ['relations']; }[type] || ['relations'];
const availableModules = visiblePanels.filter(key => key !== 'relations'); const availableModules = visiblePanels;
const moduleAction = $('typeModuleAction'); const moduleAction = $('typeModuleAction');
if (showTypeFields) state.openModules[type] = [...availableModules]; if (showTypeFields) state.openModules[type] = [...availableModules];
const openModules = state.openModules[type] || []; const openModules = state.openModules[type] || [];
@ -172,8 +172,8 @@
admin: 'Adminoplysninger', projekt: 'Projektoplysninger', postit: 'Notat og påmindelse', admin: 'Adminoplysninger', projekt: 'Projektoplysninger', postit: 'Notat og påmindelse',
intern_ticket: 'Intern opgave', bug_report: 'Fejlrapport' 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 moduleLabels = {relations:'Kunde og kontakt', '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 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.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.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 => { 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))); ['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'}; const sectionIds = {hardware: 'hardwareSection', pipeline: 'pipelineSection', order: 'orderSection'};
$('typeDetailsSection')?.classList.toggle('d-none', !openModules.includes('type-details') || !(typeFieldSets[type] || []).length); $('typeDetailsSection')?.classList.toggle('d-none', !openModules.includes('type-details') || !(typeFieldSets[type] || []).length);
@ -332,6 +335,10 @@
}; };
const originalRender = window.renderSelections; const originalRender = window.renderSelections;
window.renderSelections = () => { originalRender(); if (state.ready) scheduleSave(); }; 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>'); 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. // Core flow stays visible: type → required fields → chosen type's fields.
const typeSection = form.querySelector('.cc-type-section'); 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 '"remaining_minutes"' in source
assert '"deferred"' in source assert '"deferred"' in source
assert '"subcases"' 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): 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="relationTreeType"' in template
assert 'id="relationTreePosition"' in template assert 'id="relationTreePosition"' in template
assert 'id="relationTreeStatusFilter"' in template assert 'id="relationTreeStatusFilter"' in template
assert 'data-relation-module="comments"' in template assert "['comments', 'Kommentarer'" in template
assert 'data-relation-module="todos"' in template assert "['todos', 'Opgaver'" in template
assert 'data-relation-module="files"' in template assert "['files', 'Filer'" in template
assert 'data-relation-module="relations"' in template assert "['relations', 'Relationer'" in template
def test_case_route_access_requires_edit_for_nested_post(): 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 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(): def test_postit_keeps_tags_time_and_full_time_tab_available():
template = Path("app/modules/sag/templates/detail_v4.html").read_text() 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 "function shiftEmbeddedPlannerWeek(direction)" in template
assert "Klik på en dag for at allokere tid" in template assert "Klik på en dag for at allokere tid" in template
assert 'id="casePlannerSlotModal"' 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 "function openEmbeddedPlannerSlot(dateKey)" in template
assert "async function saveEmbeddedPlannerSlot(event)" in template assert "async function saveEmbeddedPlannerSlot(event)" in template
assert "Dette er planlagt tid" 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 "const closesCase = nextStatus.toLowerCase() === 'lukket';" in template
assert "Sagen kan kun lukkes, når alle tidsregistreringer er færdigbehandlede" 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 "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(): 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 result["total"] == 0
assert "ka.visibility IN ('general','internal')" in captured[0][0] assert "ka.visibility IN ('general','internal')" in captured[0][0]
assert "visibility='customer'" not 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 'id="yealinkIncomingUrl"' in settings_template
assert "call_id=$call_id" in settings_template assert "call_id=$call_id" in settings_template
assert "caller_number=$display_remote" 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): def test_mission_incoming_call_resolves_the_receiving_phone_user(monkeypatch):