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:
parent
4058451565
commit
3387f6c475
@ -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,
|
||||||
|
|||||||
@ -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), []),
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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""",
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 ───────────────────────────────────────────
|
||||||
|
|||||||
41
app/modules/sag/templates/detail_v5.html
Normal file
41
app/modules/sag/templates/detail_v5.html
Normal 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 %}
|
||||||
@ -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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 %}
|
||||||
|
|||||||
@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
456
design_prototypes/sag_v5/arbejdsrum.html
Normal file
456
design_prototypes/sag_v5/arbejdsrum.html
Normal 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>
|
||||||
470
design_prototypes/sag_v5/journal.html
Normal file
470
design_prototypes/sag_v5/journal.html
Normal 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>
|
||||||
449
design_prototypes/sag_v5/sagsbord.html
Normal file
449
design_prototypes/sag_v5/sagsbord.html
Normal 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>
|
||||||
4
migrations/1053_sag_internal_comments.sql
Normal file
4
migrations/1053_sag_internal_comments.sql
Normal 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
49
static/css/case-v5.css
Normal 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}}
|
||||||
@ -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
269
static/js/case-v5.js
Normal 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();
|
||||||
|
})();
|
||||||
58
tests/test_mission_call_state.py
Normal file
58
tests/test_mission_call_state.py
Normal 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
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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
163
tests/test_sag_v5.py
Normal 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 '<img' in note and '<script>' 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'
|
||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user