diff --git a/MDfile/RELEASE_NOTES_v2.8.0.md b/MDfile/RELEASE_NOTES_v2.8.0.md new file mode 100644 index 0000000..fad375e --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.8.0.md @@ -0,0 +1,18 @@ +# BMC Hub v2.8.0 + +## Ny sag + +- Redesignet, mere fokuseret oprettelsesflow med kompakte typevalg og progressive paneler. +- Browser-kladdesystem, hurtigskabeloner, tagvælger og tastaturgenvej til oprettelse. +- Duplikatindsigt for kunde og valgt kontakt samt arbejdsbelastning for ansvarlig medarbejder. +- Brand- og type-tagforslag ud fra sagens titel og beskrivelse. + +## Interne beskeder + +- Nyt, mere overskueligt beskedflow mellem medarbejdere. +- Understøttelse af korte telefonbeskeder med valgfri kontaktperson og tilbageringningsnummer. + +## Database + +- Kør `migrations/238_case_create_templates.sql`. +- Kør `migrations/239_internal_phone_messages.sql`. diff --git a/app/modules/bottom_bar/backend/router.py b/app/modules/bottom_bar/backend/router.py index 6886d5b..46f7ae6 100644 --- a/app/modules/bottom_bar/backend/router.py +++ b/app/modules/bottom_bar/backend/router.py @@ -1,8 +1,8 @@ -from typing import Optional +from typing import Optional, Literal import logging from fastapi import APIRouter, Depends, HTTPException, Query, Request -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.core.auth_service import AuthService from app.core.auth_dependencies import get_current_user @@ -69,6 +69,10 @@ class BottomBarMessageCreatePayload(BaseModel): message: str recipient_user_id: Optional[int] = None requires_manual_ack: bool = False + message_kind: Literal['message', 'phone'] = 'message' + contact_id: Optional[int] = Field(default=None, gt=0) + caller_name: str = Field(default='', max_length=200) + callback_phone: str = Field(default='', max_length=80) class BottomBarMessageReadPayload(BaseModel): @@ -566,13 +570,17 @@ async def send_bottom_bar_message( if recipient_user_id == int(current_user_id): raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv") + if payload.contact_id is not None: + if not execute_query_single('SELECT id FROM contacts WHERE id=%s', (payload.contact_id,)): + raise HTTPException(status_code=400, detail='Kontaktpersonen findes ikke') + row = execute_query_single( """ - INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack) - VALUES (%s, %s, %s, %s) + INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack, message_kind, contact_id, caller_name, callback_phone) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at """, - (int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack)), + (int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack), payload.message_kind, payload.contact_id, payload.caller_name.strip(), payload.callback_phone.strip()), ) or {} return { @@ -582,6 +590,10 @@ async def send_bottom_bar_message( "from": _resolve_current_user_display_name(current_user), "to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}", "text": row.get("message_text") or message_text, + "message_kind": payload.message_kind, + "contact_id": payload.contact_id, + "caller_name": payload.caller_name.strip(), + "callback_phone": payload.callback_phone.strip(), "requires_manual_ack": bool(row.get("requires_manual_ack")), "created_at": row.get("created_at"), "is_own": True, diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py index 3907743..8d774e0 100644 --- a/app/modules/bottom_bar/backend/service.py +++ b/app/modules/bottom_bar/backend/service.py @@ -83,6 +83,10 @@ def ensure_bottom_bar_messages_schema() -> None: sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE, message_text TEXT NOT NULL, + message_kind VARCHAR(16) NOT NULL DEFAULT 'message', + contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL, + caller_name VARCHAR(200) NOT NULL DEFAULT '', + callback_phone VARCHAR(80) NOT NULL DEFAULT '', requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, read_at TIMESTAMP NULL @@ -92,6 +96,12 @@ def ensure_bottom_bar_messages_schema() -> None: logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically") else: columns = set(_table_columns("bottom_bar_messages")) + if not {'message_kind', 'contact_id', 'caller_name', 'callback_phone'}.issubset(columns): + execute_query("""ALTER TABLE bottom_bar_messages + ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message', + ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''""") if "requires_manual_ack" not in columns: execute_query( """ @@ -143,6 +153,8 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s m.sender_user_id, m.recipient_user_id, m.message_text, + m.message_kind, m.contact_id, m.caller_name, m.callback_phone, + NULLIF(CONCAT_WS(' ', contact.first_name, contact.last_name), '') AS contact_name, m.requires_manual_ack, m.created_at, receipt.read_at, @@ -152,9 +164,10 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s FROM bottom_bar_messages m JOIN users sender ON sender.user_id = m.sender_user_id LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id + LEFT JOIN contacts contact ON contact.id = m.contact_id LEFT JOIN bottom_bar_message_receipts receipt ON receipt.message_id = m.id - AND receipt.user_id = %s + AND receipt.user_id = CASE WHEN m.sender_user_id = %s AND m.recipient_user_id IS NOT NULL THEN m.recipient_user_id ELSE %s END WHERE m.sender_user_id = %s OR m.recipient_user_id = %s OR (m.recipient_user_id IS NULL AND EXISTS ( @@ -166,7 +179,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s ORDER BY m.created_at DESC, m.id DESC LIMIT %s """, - (int(user_id), int(user_id), int(user_id), int(user_id), safe_limit), + (int(user_id), int(user_id), int(user_id), int(user_id), int(user_id), safe_limit), ) or [] unread_row = execute_query_single( @@ -200,9 +213,15 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s "from": row.get("sender_name") or "Ukendt", "to": recipient_name, "text": row.get("message_text") or "", + "message_kind": row.get("message_kind") or "message", + "contact_id": row.get("contact_id"), + "contact_name": row.get("contact_name"), + "caller_name": row.get("caller_name") or "", + "callback_phone": row.get("callback_phone") or "", "requires_manual_ack": bool(row.get("requires_manual_ack")), "created_at": row.get("created_at").isoformat() if row.get("created_at") else None, "is_own": int(row.get("sender_user_id") or 0) == int(user_id), + "is_read": row.get("read_at") is not None, "is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id), "is_acknowledged": row.get("acknowledged_at") is not None, } diff --git a/app/modules/sag/backend/create_support.py b/app/modules/sag/backend/create_support.py new file mode 100644 index 0000000..729c31b --- /dev/null +++ b/app/modules/sag/backend/create_support.py @@ -0,0 +1,195 @@ +"""Creation helpers: transactional associations and advisory case lookups.""" +import json +import re +from difflib import SequenceMatcher +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, ConfigDict, Field, StrictInt +from psycopg2.extras import Json +from app.core.auth_dependencies import require_any_permission +from app.core.database import execute_query, execute_query_single + +read_access = require_any_permission('cases.view', 'tickets.view', 'cases.create', 'tickets.create', 'users.manage', 'system.admin') +admin_access = require_any_permission('users.manage', 'system.admin') +router = APIRouter(prefix='/case-create', dependencies=[Depends(read_access)]) + + +def ids(value, field): + if not isinstance(value, list) or any(isinstance(v, bool) or not isinstance(v, int) or v <= 0 for v in value): + raise HTTPException(400, f'{field} skal være en liste med positive heltal') + return list(dict.fromkeys(value)) + + +def attach_create_relations(cursor, case_id, data, user_id): + hardware_ids = ids(data.get('hardware_ids', []), 'hardware_ids') + tag_ids = ids(data.get('tag_ids', []), 'tag_ids') + if hardware_ids: + cursor.execute('SELECT id FROM hardware_assets WHERE id = ANY(%s) AND deleted_at IS NULL', (hardware_ids,)) + if {r['id'] for r in cursor.fetchall()} != set(hardware_ids): + raise HTTPException(400, 'En valgt hardware findes ikke længere') + for hardware_id in hardware_ids: + cursor.execute('INSERT INTO sag_hardware (sag_id, hardware_id) VALUES (%s, %s) ON CONFLICT DO NOTHING', (case_id, hardware_id)) + actions = [] + if tag_ids: + cursor.execute('SELECT t.id, t.name, t.tag_group_id, g.behavior FROM tags t LEFT JOIN tag_groups g ON g.id=t.tag_group_id WHERE t.id=ANY(%s) AND t.is_active=TRUE', (tag_ids,)) + tags = {r['id']: r for r in cursor.fetchall()} + if set(tags) != set(tag_ids): + raise HTTPException(400, 'Et valgt tag findes ikke længere eller er inaktivt') + # Same single/toggle group semantics as the global picker: last choice wins. + chosen = [] + for tag_id in tag_ids: + tag = tags[tag_id] + if tag['behavior'] in ('single', 'toggle'): + chosen = [i for i in chosen if tags[i]['tag_group_id'] != tag['tag_group_id']] + chosen.append(tag_id) + for tag_id in chosen: + cursor.execute("INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by) VALUES ('case', %s, %s, %s) ON CONFLICT DO NOTHING", (case_id, tag_id, user_id)) + cursor.execute("SELECT action_type, action_config FROM tag_workflows WHERE tag_id=%s AND trigger_event='on_add' AND is_active=TRUE ORDER BY id DESC LIMIT 1", (tag_id,)) + action = cursor.fetchone() + if action: + actions.append({'tag': {'id': tag_id, 'name': tags[tag_id]['name']}, 'action': {'type': action['action_type'], 'config': action['action_config'] or {}}, 'entity_type': 'case', 'entity_id': case_id}) + return actions + + +def closed_statuses(): + row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'") + try: + configured = json.loads((row or {}).get('value') or '[]') + values = [str(s['value']).strip().lower() for s in configured if isinstance(s, dict) and s.get('is_closed') and s.get('value')] + except (ValueError, TypeError): + values = [] + return values or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'] + + +def title_similarity(left, right): + def normalize(value): + return ' '.join(re.findall(r'\w+', value.casefold())) + left, right = normalize(left), normalize(right) + if not left or not right: + return 0 + a, b = set(left.split()), set(right.split()) + return max(SequenceMatcher(None, left, right).ratio(), len(a & b) / len(a | b)) + + +@router.get('/duplicates') +def duplicates(customer_id: int, title: str = Query(min_length=5, max_length=1000)): + rows = execute_query("""SELECT s.id,s.titel,s.status,COALESCE(u.full_name,u.username) AS ansvarlig_navn + FROM sag_sager s LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id + WHERE s.customer_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))""", (customer_id, closed_statuses())) or [] + ranked = [(title_similarity(title, r['titel'] or ''), r) for r in rows] + ranked.sort(key=lambda item: (-item[0], -item[1]['id'])) + return [dict(row, similarity=round(score, 3)) for score, row in ranked if score >= 0.45][:5] + + +@router.get('/workload') +def workload(user_id: int): + rows = execute_query("""SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name,COUNT(*) OVER() AS total + FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id + WHERE s.ansvarlig_bruger_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s)) + ORDER BY s.deadline ASC NULLS LAST,s.id DESC LIMIT 10""", (user_id, closed_statuses())) or [] + return {'total': rows[0]['total'] if rows else 0, 'items': rows} + + +@router.get('/contacts-open-cases') +def contacts_open_cases(contact_ids: list[int] = Query(min_length=1, max_length=20)): + """A small, advisory view used while selecting contacts on a new case.""" + contact_ids = ids(contact_ids, 'contact_ids') + rows = execute_query("""WITH ranked AS ( + SELECT sc.contact_id, s.id, s.titel, s.status, s.deadline, + COALESCE(u.full_name, u.username, 'Ingen ansvarlig') AS ansvarlig_navn, + COUNT(*) OVER (PARTITION BY sc.contact_id) AS total, + ROW_NUMBER() OVER (PARTITION BY sc.contact_id ORDER BY s.deadline ASC NULLS LAST, s.id DESC) AS position + FROM sag_kontakter sc + JOIN sag_sager s ON s.id=sc.sag_id + LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id + WHERE sc.contact_id=ANY(%s) AND sc.deleted_at IS NULL AND s.deleted_at IS NULL + AND NOT (LOWER(TRIM(s.status))=ANY(%s)) + ) SELECT contact_id,id,titel,status,deadline,ansvarlig_navn,total + FROM ranked WHERE position<=5 ORDER BY contact_id,position""", (contact_ids, closed_statuses())) or [] + result = {contact_id: {'total': 0, 'items': []} for contact_id in contact_ids} + for row in rows: + bucket = result[row['contact_id']] + bucket['total'] = row['total'] + bucket['items'].append({key: row[key] for key in ('id', 'titel', 'status', 'deadline', 'ansvarlig_navn')}) + return result + + +class PipelineDefaults(BaseModel): + model_config = ConfigDict(extra='forbid') + stage_id: Optional[int] = Field(default=None, gt=0) + amount: Optional[float] = Field(default=None, ge=0, allow_inf_nan=False) + probability: Optional[int] = Field(default=None, ge=0, le=100) + description: Optional[str] = Field(default=None, max_length=10000) + + +class TemplateValues(BaseModel): + model_config = ConfigDict(extra='forbid') + type: str = Field(default='ticket', min_length=1, max_length=80) + titel: str = Field(default='', max_length=1000) + beskrivelse: str = Field(default='', max_length=50000) + status: str = Field(default='åben', min_length=1, max_length=80) + assigned_group_id: Optional[int] = Field(default=None, gt=0) + tag_ids: list[StrictInt] = Field(default_factory=list, max_length=100) + pipeline: Optional[PipelineDefaults] = None + + +class CaseTemplate(BaseModel): + model_config = ConfigDict(extra='forbid') + name: str = Field(min_length=1, max_length=120) + icon: str = Field(default='bi-lightning', pattern=r'^bi-[a-z0-9-]+$', max_length=80) + is_active: bool = True + sort_order: int = 0 + values: TemplateValues + + +@router.get('/templates') +def templates(): + return execute_query('SELECT *, template_values AS "values" FROM case_create_templates WHERE is_active=TRUE ORDER BY sort_order,name,id') or [] + + +@router.get('/template-options', dependencies=[Depends(admin_access)]) +def template_options(): + return execute_query('SELECT id,name FROM groups ORDER BY name') or [] + + +@router.get('/templates/admin', dependencies=[Depends(admin_access)]) +def admin_templates(): + return execute_query('SELECT *, template_values AS "values" FROM case_create_templates ORDER BY sort_order,name,id') or [] + + +def template_args(data): + values = data.values.model_dump() + values['tag_ids'] = ids(values['tag_ids'], 'tag_ids') + if values['tag_ids']: + found = execute_query('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE', (values['tag_ids'],)) or [] + if {r['id'] for r in found} != set(values['tag_ids']): + raise HTTPException(400, 'Ugyldige tags') + if values['assigned_group_id'] and not execute_query_single('SELECT id FROM groups WHERE id=%s', (values['assigned_group_id'],)): + raise HTTPException(400, 'Ugyldig gruppe') + if values['pipeline'] and values['pipeline']['stage_id'] and not execute_query_single('SELECT id FROM pipeline_stages WHERE id=%s', (values['pipeline']['stage_id'],)): + raise HTTPException(400, 'Ugyldig pipeline-stage') + if not data.name.strip(): + raise HTTPException(400, 'Navn er påkrævet') + return (data.name.strip(), data.icon, data.is_active, data.sort_order, Json(values)) + + +@router.post('/templates', dependencies=[Depends(admin_access)]) +def create_template(data: CaseTemplate): + return execute_query_single('INSERT INTO case_create_templates (name,icon,is_active,sort_order,template_values) VALUES (%s,%s,%s,%s,%s) RETURNING *, template_values AS "values"', template_args(data)) + + +@router.put('/templates/{template_id}', dependencies=[Depends(admin_access)]) +def update_template(template_id: int, data: CaseTemplate): + row = execute_query_single('UPDATE case_create_templates SET name=%s,icon=%s,is_active=%s,sort_order=%s,template_values=%s,updated_at=NOW() WHERE id=%s RETURNING *, template_values AS "values"', template_args(data) + (template_id,)) + if not row: + raise HTTPException(404, 'Skabelonen findes ikke') + return row + + +@router.delete('/templates/{template_id}', dependencies=[Depends(admin_access)]) +def delete_template(template_id: int): + row = execute_query_single('DELETE FROM case_create_templates WHERE id=%s RETURNING id', (template_id,)) + if not row: + raise HTTPException(404, 'Skabelonen findes ikke') + return row diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 015bb79..9664db3 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -6,6 +6,7 @@ import re import hashlib import base64 import html +import math from pathlib import Path from datetime import datetime, timedelta, timezone from typing import Any, List, Optional, Dict @@ -1069,6 +1070,8 @@ async def create_sag(request: Request, data: dict): if pipeline_values["amount"] not in (None, ""): try: pipeline_values["amount"] = float(pipeline_values["amount"]) + if not math.isfinite(pipeline_values["amount"]) or pipeline_values["amount"] < 0: + raise ValueError("Invalid amount") except (TypeError, ValueError) as exc: raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc else: @@ -1123,6 +1126,8 @@ async def create_sag(request: Request, data: dict): raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"): raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus") + if any(value is not None and (not math.isfinite(value) or value < 0) for value in (normalized_items[-1][key] for key in ("quantity", "unit_price", "amount"))): + raise HTTPException(status_code=400, detail="Ordrelinjens tal skal være endelige og mindst nul") cursor.execute( """ diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 215654c..f79fc02 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -4,6 +4,21 @@ {% block extra_css %} {% endblock %} {% block content %} -
+
+
-
+
-

Opret Ny Sag

-

Udfyld formularen for at oprette en ny sag i systemet.

+
ET GODT STED AT STARTE

Opret en ny sag

Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.

+
@@ -162,10 +303,14 @@
-
+
Vælg sagstype for at vise de relevante felter.
+
@@ -553,10 +698,13 @@ const titleInput = document.getElementById('titel'); const descriptionInput = document.getElementById('beskrivelse'); - if (suggestedTitle) titleInput.value = suggestedTitle; + if (suggestedTitle) { + titleInput.value = suggestedTitle; + titleInput.dispatchEvent(new Event('input', { bubbles: true })); + } if (suggestedDescription) { descriptionInput.value = suggestedDescription; - descriptionInput.dispatchEvent(new Event('input')); + descriptionInput.dispatchEvent(new Event('input', { bubbles: true })); } bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide(); }); @@ -580,13 +728,20 @@ const contactInput = document.getElementById('contactSearch'); if (contactInput) { contactInput.addEventListener('input', (e) => handleSearch(e, 'contact')); + contactInput.addEventListener('keydown', e => { + if (e.key === 'Escape') { + clearTimeout(contactSearchTimeout); + document.getElementById('contactResults').classList.add('d-none'); + contactInput.blur(); + } + }); contactInput.addEventListener('focus', () => { - if (selectedCustomer) { + if (selectedCustomer && contactInput.value.trim().length >= 2) { renderCustomerContactResults(contactInput.value); } }); document.addEventListener('click', (e) => { - if (!e.target.closest('.search-position-relative')) { + if (!contactInput.closest('.search-position-relative').contains(e.target)) { const cr = document.getElementById('contactResults'); if(cr) cr.classList.add('d-none'); } @@ -605,6 +760,12 @@ clearTimeout(timeoutVar); + if (query.length < 2) { + resultsDiv.classList.add('d-none'); + resultsDiv.innerHTML = ''; + return; + } + if (type === 'contact' && selectedCustomer) { renderCustomerContactResults(query); return; @@ -616,6 +777,7 @@ } const timeout = setTimeout(async () => { + if (event.target.value.trim() !== query || document.activeElement !== event.target) return; try { // Show loading state resultsDiv.classList.remove('d-none'); @@ -632,6 +794,7 @@ return; } const data = await response.json(); + if (event.target.value.trim() !== query || document.activeElement !== event.target) return; if (!Array.isArray(data)) { resultsDiv.innerHTML = '
Fejl ved søgning
'; return; @@ -694,6 +857,10 @@ if (!resultsDiv || !selectedCustomer) return; const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK'); + if (normalizedQuery.length < 2 || document.activeElement !== document.getElementById('contactSearch')) { + resultsDiv.classList.add('d-none'); + return; + } const contacts = selectedCustomerContacts.filter((contact) => { if (!normalizedQuery) return true; return [ @@ -739,9 +906,9 @@ selectedCustomerContacts = []; contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`; - context.textContent = `Viser kontakter hos ${selectedCustomer.name}.`; + context.textContent = `Søg blandt kontakter hos ${selectedCustomer.name} (mindst 2 tegn).`; resultsDiv.innerHTML = '
Henter firmaets kontakter...
'; - resultsDiv.classList.remove('d-none'); + resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2); try { const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' }); @@ -755,7 +922,7 @@ if (loadToken !== customerContactsLoadToken) return; console.error('Failed to load customer contacts:', error); resultsDiv.innerHTML = '
Firmaets kontakter kunne ikke hentes.
'; - resultsDiv.classList.remove('d-none'); + resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2); } } @@ -835,6 +1002,7 @@ } loadHardwareForContacts(); + window.caseCreateUI?.contactsChanged(); } function readTelefoniPrefill() { @@ -926,6 +1094,7 @@ delete selectedContactsCompanies[id]; renderSelections(); loadHardwareForContacts(); + window.caseCreateUI?.contactsChanged(); } function renderSelections() { @@ -1167,14 +1336,13 @@ function updateCaseTypeSections() { const type = document.getElementById('type')?.value || 'ticket'; - document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket'); - document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline'); - document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre'); + ['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none')); + window.caseCreateUI?.typeChanged(type); const help = document.getElementById('caseTypeHelp'); - if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.' + if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.' : type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.' : type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.' - : 'Denne sagstype bruger kun de fælles sagsfelter.'; + : 'Tilføj hardware, pipeline, tags og køb/salg efter behov.'; } function renderOrderLinesEmptyState() { @@ -1249,7 +1417,7 @@ const setting = typesRes.ok ? await typesRes.json() : { value: '[]' }; const profile = profileRes.ok ? await profileRes.json() : {}; const configured = JSON.parse(setting.value || '[]'); - const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : []; + const types = Array.isArray(configured) && configured.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels); if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline'); if (!types.includes('abonnement')) types.push('abonnement'); const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels); @@ -1279,18 +1447,18 @@ } // --- Initialization --- - document.addEventListener('DOMContentLoaded', () => { + document.addEventListener('DOMContentLoaded', async () => { initializeSearch(); - loadCaseTypesSelect(); - loadPipelineStages(); - selectCurrentUserAsResponsible(); + await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]); document.getElementById('type')?.addEventListener('change', updateCaseTypeSections); - applyTelefoniPrefill(); + await applyTelefoniPrefill(); + await window.caseCreateUI?.init(); }); // --- Form Submission --- document.getElementById('createForm').addEventListener('submit', async (e) => { e.preventDefault(); + if (window.caseCreateUI && !window.caseCreateUI.validate()) return; // UI Reset const errorDiv = document.getElementById('error'); @@ -1371,7 +1539,7 @@ data.internet_connection_ids = [telefoniPrefill.internetConnectionId]; } - if (data.type === 'pipeline') { + if (['pipeline_stage_id', 'pipeline_amount', 'pipeline_probability', 'pipeline_description'].some(id => document.getElementById(id).value !== '')) { data.pipeline = { stage_id: document.getElementById('pipeline_stage_id').value || null, amount: document.getElementById('pipeline_amount').value || null, @@ -1379,9 +1547,10 @@ description: document.getElementById('pipeline_description').value || null }; } - if (data.type === 'ordre') { + if (document.querySelector('#orderLines .order-line')) { data.order_items = collectOrderItems(); } + Object.assign(data, window.caseCreateUI?.relations() || {}); try { const response = await fetch('/api/v1/sag', { @@ -1393,6 +1562,7 @@ if (response.ok) { const result = await response.json(); + window.caseCreateUI?.created(result); successDiv.classList.remove('d-none'); document.getElementById('success-text').textContent = "Sag oprettet"; @@ -1416,4 +1586,5 @@ } }); + {% endblock %} diff --git a/app/settings/frontend/case_templates.html b/app/settings/frontend/case_templates.html new file mode 100644 index 0000000..10de3d6 --- /dev/null +++ b/app/settings/frontend/case_templates.html @@ -0,0 +1,30 @@ +
+
Sagsskabeloner
+

Fælles hurtigskabeloner til Opret ny sag.

+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
Pipeline-standarder
+
+
+
+
+
+
+
+
+ +
+ diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index e4e636a..bd341a2 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -192,6 +192,7 @@ Opgave-templates + Sagsskabeloner BMC Office Import @@ -675,6 +676,9 @@
+
+ {% include "settings/frontend/case_templates.html" %} +
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index da7f58d..9215007 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -1440,6 +1440,7 @@ } {% block extra_css %}{% endblock %} + {% set _xff = request.headers.get('x-forwarded-for') if request and request.headers else '' %} {% set _xff_first = _xff.split(',')[0].strip() if _xff else '' %} @@ -1996,13 +1997,15 @@ if (bmcOriginalFetch) { } - + - +{% include "shared/frontend/internal_message.html" %} + + diff --git a/main.py b/main.py index e6df237..505d6da 100644 --- a/main.py +++ b/main.py @@ -502,6 +502,8 @@ app.include_router(bug_reports_api.router, prefix="/api/v1", tags=["Bug Reports" # Module Routers app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"]) app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"]) +from app.modules.sag.backend.create_support import router as case_create_support_router +app.include_router(case_create_support_router, prefix="/api/v1", tags=["Cases"]) app.include_router(sag_reminders_api.router, tags=["Reminders"]) # No prefix - endpoints have full path app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"]) app.include_router(locations_api, prefix="/api/v1", tags=["Locations"]) diff --git a/migrations/238_case_create_templates.sql b/migrations/238_case_create_templates.sql new file mode 100644 index 0000000..c2f13b9 --- /dev/null +++ b/migrations/238_case_create_templates.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS case_create_templates ( + id SERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + icon VARCHAR(80) NOT NULL DEFAULT 'bi-lightning', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + template_values JSONB NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(template_values) = 'object'), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); diff --git a/migrations/239_internal_phone_messages.sql b/migrations/239_internal_phone_messages.sql new file mode 100644 index 0000000..aacf478 --- /dev/null +++ b/migrations/239_internal_phone_messages.sql @@ -0,0 +1,5 @@ +ALTER TABLE bottom_bar_messages + ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message', + ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''; diff --git a/static/js/bottom-bar.js b/static/js/bottom-bar.js index f262253..5eee426 100644 --- a/static/js/bottom-bar.js +++ b/static/js/bottom-bar.js @@ -47,6 +47,7 @@ activeThreadKey: '' }; const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1'; + window.addEventListener('hub:internal-message-sent', function () { fetchBottomBarState(); }); let notesApiUnavailable = false; let driftSummaryRefreshTimer = null; let markMessagesReadPromise = null; @@ -198,7 +199,7 @@ messageItems.forEach(function (item) { const own = !!item.is_own; - const partnerId = own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0); + const partnerId = item.recipient_user_id == null ? 0 : (own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0)); const key = partnerId > 0 ? ('user:' + partnerId) : 'broadcast'; const label = partnerId > 0 ? String(own ? (item.to || ('Bruger #' + partnerId)) : (item.from || ('Bruger #' + partnerId))) @@ -309,20 +310,8 @@ } function getRenderableMessageThreads(activeThread) { - const seen = new Set(); - const out = []; - - if (activeThread && activeThread.key) { - out.push(activeThread); - seen.add(activeThread.key); - } - - getMessageThreads().forEach(function (thread) { - if (seen.has(thread.key)) return; - seen.add(thread.key); - out.push(thread); - }); - + const out = getMessageThreads(); + if (activeThread?.key && !out.some(thread => thread.key === activeThread.key)) out.unshift(activeThread); return out; } @@ -917,39 +906,8 @@ const activeThread = ensureActiveMessageThread(); syncChatRecipientToActiveThread(activeThread); const messageItems = activeThread && Array.isArray(activeThread.items) ? activeThread.items : []; - if (messageItems.length > 0) { - return messageItems.map(function (m) { - const own = !!m.is_own; - const unreadBadge = m.is_unread ? ' Ny' : ''; - const importantBadge = m.requires_manual_ack ? ' Vigtig' : ''; - const ackBadge = own && m.requires_manual_ack - ? (m.is_acknowledged - ? ' Bekræftet læst' - : ' Afventer læst-bekræftelse') - : ''; - const targetMeta = own && m.to ? '
Til: ' + esc(m.to) + '
' : ''; - const replyTargetId = own ? Number(m.recipient_user_id || 0) : Number(m.sender_user_id || 0); - const replyBtn = replyTargetId > 0 - ? '
' - : ''; - const ackBtn = (!own && m.requires_manual_ack && m.is_unread) - ? '
' - : ''; - return '' - + '
' - + targetMeta - + '
' - + '' + esc(m.from) + ': ' - + esc(m.text) - + unreadBadge - + importantBadge - + ackBadge - + replyBtn - + ackBtn - + '
'; - }); - } - return ['Ingen beskeder i denne tråd endnu.']; + if (messageItems.length > 0) return messageItems.map(window.BmcMessageUI.message); + return ['
Her starter samtalen

Send en kort besked eller giv en telefonbesked videre.

']; } if (key === 'tasks') { @@ -1229,6 +1187,8 @@ } let messageFocusState = null; + const previousMessages = innerContent.querySelector('.bb-messages-list'); + const previousScroll = previousMessages ? {key:previousMessages.dataset.threadKey, top:previousMessages.scrollTop, bottom:previousMessages.scrollHeight-previousMessages.scrollTop-previousMessages.clientHeight < 40} : null; if (activeKey === 'messages') { const activeEl = document.activeElement; const activeId = activeEl && activeEl.id ? activeEl.id : ''; @@ -1329,17 +1289,18 @@ const threadItems = getRenderableMessageThreads(activeThread); syncChatRecipientToActiveThread(activeThread); - if (threadItems.length > 0) { + { const threadList = document.createElement('div'); threadList.className = 'bb-message-threads'; + threadList.innerHTML = '
Samtaler
'; + if (!threadItems.length) threadList.insertAdjacentHTML('beforeend', '

Dine samtaler vises her.

'); threadItems.forEach(function (thread) { const button = document.createElement('button'); button.type = 'button'; button.className = 'bb-message-thread' + (activeThread && thread.key === activeThread.key ? ' is-active' : ''); button.setAttribute('data-bb-thread-key', thread.key); - button.innerHTML = '' - + '' + escapeHtml(thread.label || 'Samtale') + '' - + (thread.unread > 0 ? '' + Number(thread.unread || 0) + '' : ''); + button.setAttribute('aria-current', activeThread && thread.key === activeThread.key ? 'true' : 'false'); + button.innerHTML = window.BmcMessageUI.thread(thread); threadList.appendChild(button); }); chatContainer.appendChild(threadList); @@ -1347,6 +1308,9 @@ const replyBox = document.createElement('div'); replyBox.className = 'bb-messages-composer'; + const conversation = document.createElement('div'); + conversation.className = 'msg-conversation'; + conversation.innerHTML = '
' + escapeHtml(activeThread?.label || 'Vælg en samtale') + '' + (!activeThread ? 'Brug blyanten til at skrive til en kollega' : activeThread.partnerUserId ? 'Intern samtale' : 'Fælles beskeder til alle på vagt') + '
'; const threadMeta = activeThread ? '
' + (activeThread.partnerUserId ? ('Samtale med ' + escapeHtml(activeThread.label || 'Bruger')) : 'Besked til alle på vagt') @@ -1359,27 +1323,23 @@
` : ''; replyBox.innerHTML = ` - ${threadMeta} ${replyBanner} -
- - +
+ +
- -
- - -
+ `; - chatContainer.appendChild(ul); - chatContainer.appendChild(replyBox); + conversation.appendChild(ul); + conversation.appendChild(replyBox); + chatContainer.appendChild(conversation); innerContent.appendChild(chatContainer); + ul.dataset.threadKey = activeThread?.key || ''; + window.requestAnimationFrame(function () { ul.scrollTop = previousScroll && previousScroll.key === ul.dataset.threadKey && !previousScroll.bottom ? previousScroll.top : ul.scrollHeight; }); const recipientSelect = document.getElementById('chatRecipient'); const input = document.getElementById('chatInputQuick'); @@ -1396,6 +1356,9 @@ }); } if (input) { + input.addEventListener('keydown', function (event) { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); event.stopPropagation(); document.getElementById('btnSendMsg')?.click(); } + }); input.addEventListener('input', function () { chatComposerState.draft = input.value || ''; }); @@ -3513,7 +3476,7 @@ if (activeKey === 'messages') { renderTabPanel(); } - const tabInner = document.getElementById('bbTabInnerContent'); + const tabInner = document.querySelector('.bb-messages-list'); if (tabInner) { tabInner.scrollTop = tabInner.scrollHeight + 500; } diff --git a/static/js/case-create.js b/static/js/case-create.js new file mode 100644 index 0000000..73d5f83 --- /dev/null +++ b/static/js/case-create.js @@ -0,0 +1,256 @@ +/* Progressive case creation. Existing searches and submit retain their API contracts. */ +(() => { + 'use strict'; + const $ = id => document.getElementById(id); + const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const state = {tags: [], hardware: [], tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false}; + let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = []; + async function api(url, options) { + const response = await fetch(url, {credentials: 'include', ...options}); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(typeof body.detail === 'string' ? body.detail : 'Kunne ikke hente oplysninger'); + return body; + } + function panel(id, title, elements, open = false) { + const detail = document.createElement('details'); + detail.className = 'cc-panel'; detail.id = `cc-${id}`; detail.open = open; + const icon = {relations:'bi-people',tags:'bi-tags',hardware:'bi-pc-display',pipeline:'bi-graph-up-arrow',order:'bi-bag',metadata:'bi-sliders'}[id]; + detail.innerHTML = `${esc(title)}
`; + elements[0].before(detail); + elements.forEach(el => detail.lastElementChild.append(el)); + state.panels[id] = detail; + detail.addEventListener('toggle', scheduleSave); + return detail; + } + function summaries() { + const values = { + relations: `${selectedCustomer?.name || 'Vælg firma'} · ${Object.keys(selectedContacts).length} kontakter`, + tags: state.tags.map(t => t.name).join(', ') || 'Ingen tags', + hardware: `${state.hardware.length} enheder valgt`, + pipeline: [$('pipeline_stage_id').selectedOptions[0]?.text, $('pipeline_amount').value ? `${$('pipeline_amount').value} kr.` : ''].filter(Boolean).join(' · '), + order: `${document.querySelectorAll('#orderLines .order-line').length} linjer`, + metadata: [$('status').selectedOptions[0]?.text, $('ansvarlig_bruger_id').selectedOptions[0]?.text, $('deadline').value.replace('T', ' ')].filter(Boolean).join(' · ') + }; + Object.entries(values).forEach(([key, value]) => { if (state.panels[key]) state.panels[key].querySelector('.cc-summary').textContent = value; }); + } + function fieldSnapshot() { + return Object.fromEntries(Array.from($('createForm').querySelectorAll('input[id],select[id],textarea[id]')).filter(el => !['customerSearch','contactSearch'].includes(el.id)).map(el => [el.id, el.type === 'checkbox' ? el.checked : el.value])); + } + function snapshot() { + return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies, + tags: state.tags, hardware: state.hardware, orders: collectOrderItems(), prefill: telefoniPrefill, + customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value, + panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))}; + } + function save() { + if (!state.ready || state.pending || state.restoring || state.completed || !state.key) return; + try { localStorage.setItem(state.key, JSON.stringify(snapshot())); $('cc-save').textContent = 'Kladde gemt lokalt'; } + catch (error) { console.error('Kunne ikke gemme kladde', error); $('cc-save').textContent = 'Kladde kunne ikke gemmes i browseren'; } + } + function scheduleSave() { + summaries(); + if (!state.ready || state.pending || state.restoring || state.completed) return; + $('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500); + } + function renderTags() { + $('cc-tag-chips').innerHTML = state.tags.map(t => `${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? ` ` : ''}${esc(t.name)} `).join(''); + state.tags.forEach(t => { const chip = $('cc-tag-chips').querySelector(`[data-tag-id="${Number(t.id)}"]`); if (/^#[0-9a-f]{6}$/i.test(t.color)) chip.style.backgroundColor = t.color; }); + summaries(); + renderTagSuggestions(); + } + function renderTagSuggestions() { + const box = $('cc-tag-suggestions'); + if (!box) return; + const haystack = `${$('titel')?.value || ''} ${$('beskrivelse')?.value || ''}`.toLocaleLowerCase('da-DK'); + if (haystack.trim().length < 3 || !state.tagCatalog.length) { box.innerHTML = ''; return; } + const selected = new Set(state.tags.map(tag => Number(tag.id))); + const suggestions = state.tagCatalog.filter(tag => { + if (selected.has(Number(tag.id)) || !['brand', 'type'].includes(tag.type)) return false; + const words = Array.isArray(tag.catch_words) ? tag.catch_words : []; + return words.some(word => String(word).trim() && haystack.includes(String(word).trim().toLocaleLowerCase('da-DK'))); + }).slice(0, 8); + box.innerHTML = suggestions.length ? `
Foreslået ud fra teksten
${suggestions.map(tag => ``).join('')}
` : ''; + } + function scheduleTagSuggestions() { + clearTimeout(tagSuggestionTimer); + tagSuggestionTimer = setTimeout(renderTagSuggestions, 350); + } + async function addTag(tag) { + if (!state.tags.some(t => t.id === tag.id)) { + const groups = await api('/api/v1/tags/groups').catch(() => []); + if (groups.some(g => g.id === tag.tag_group_id && ['single','toggle'].includes(g.behavior))) state.tags = state.tags.filter(t => t.tag_group_id !== tag.tag_group_id); + state.tags.push(tag); renderTags(); scheduleSave(); + } + } + function renderSelectedHardware() { + $('cc-hardware-chips').innerHTML = state.hardware.map(h => `${esc(h.name)} `).join(''); + document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); }); + summaries(); + } + async function duplicates() { + const token = ++duplicateToken, customerId = selectedCustomer?.id, title = $('titel').value.trim(); + $('cc-duplicates').innerHTML = ''; + if (!customerId || title.length < 5) return; + try { + const rows = await api(`/api/v1/case-create/duplicates?customer_id=${customerId}&title=${encodeURIComponent(title)}`); + if (token !== duplicateToken) return; + $('cc-duplicates').innerHTML = rows.length ? `
Mulige eksisterende sager${rows.map(r => `
SAG-${r.id} · ${esc(r.titel)} · ${esc(r.status)} · ${esc(r.ansvarlig_navn || 'Ingen ansvarlig')}
`).join('')}
` : ''; + } catch { if (token === duplicateToken) $('cc-duplicates').textContent = 'Duplikatkontrol er midlertidigt utilgængelig. Du kan stadig oprette sagen.'; } + } + function scheduleDuplicates() { ++duplicateToken; clearTimeout(duplicateTimer); duplicateTimer = setTimeout(duplicates, 500); } + async function contactOpenCases() { + const box = $('cc-contact-cases'); + if (!box) return; + const contacts = Object.values(selectedContacts); + const token = ++contactCasesToken; + if (!contacts.length) { box.hidden = true; box.innerHTML = ''; return; } + box.hidden = false; + box.innerHTML = 'Tjekker kontaktens åbne sager…'; + const query = contacts.map(contact => `contact_ids=${encodeURIComponent(contact.id)}`).join('&'); + try { + const data = await api(`/api/v1/case-create/contacts-open-cases?${query}`); + if (token !== contactCasesToken) return; + const groups = contacts.map(contact => ({contact, data: data[contact.id] || {total:0, items:[]}})); + const total = groups.reduce((sum, group) => sum + Number(group.data.total || 0), 0); + const title = total ? `${total} åben${total === 1 ? '' : 'ne'} sag${total === 1 ? '' : 'er'} på valgt kontakt` : 'Ingen åbne sager på valgt kontakt'; + box.innerHTML = `
Dublettjek${title}
${total ? `
${groups.map(({contact,data}) => data.total ? `
${esc(contact.name)}${data.items.map(item => `
SAG-${Number(item.id)} · ${esc(item.titel)}${esc(item.status || '')} · ${esc(item.ansvarlig_navn || 'Ingen ansvarlig')}${item.deadline ? ` · ${esc(new Date(item.deadline).toLocaleDateString('da-DK'))}` : ''}
`).join('')}${data.total > data.items.length ? `Viser ${data.items.length} af ${data.total} åbne sager` : ''}
` : '').join('')}
` : '
Du kan oprette sagen uden risiko for en kendt, åben sag på kontakten.
'}`; + } catch { + if (token === contactCasesToken) box.innerHTML = 'Kunne ikke tjekke kontaktens åbne sager. Du kan stadig oprette sagen.'; + } + } + async function workload() { + const token = ++workloadToken, id = $('ansvarlig_bruger_id').value; + $('cc-workload').textContent = id ? 'Henter åbne sager…' : 'Vælg en ansvarlig for at se åbne sager.'; + if (!id) return; + try { + const data = await api(`/api/v1/case-create/workload?user_id=${id}`); + if (token !== workloadToken) return; + $('cc-workload').innerHTML = `
${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}${data.items.map(r => `
SAG-${r.id} · ${esc(r.titel)}${esc(r.customer_name || '')} · ${esc(r.status)} ${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}
`).join('') || '

Ingen åbne sager.

'}${data.total > 10 ? `Se alle ${data.total} sager` : ''}
`; + } catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; } + } + function typeChanged(type) { + document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); }); + const key = {ticket:'hardware', pipeline:'pipeline', ordre:'order'}[type]; + if (key && state.panels[key] && !state.restoring) state.panels[key].open = true; + if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`)); + scheduleSave(); + } + function buildTypes() { + const options = Array.from($('type').options), current = $('type').value; + const keys = [...new Set([current, 'ticket','pipeline','ordre','opgave', ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k)); + const hints = {ticket:'Support og hjælp',pipeline:'Muligheder og tilbud',ordre:'Indkøb og salg',opgave:'Noget der skal løses',projekt:'Større forløb',service:'Service og vedligehold',abonnement:'Løbende aftaler'}; + const button = key => ``; + const messageButton = $('caseMessageTypeButton'); + $('type').classList.add('d-none'); $('type').removeAttribute('required'); + const div = document.createElement('div'); div.id = 'cc-types'; div.className = 'cc-types'; div.setAttribute('role', 'group'); div.setAttribute('aria-label', 'Sagstype'); + div.innerHTML = keys.slice(0,4).map(button).join('') + (keys.length > 4 ? `
Flere
${keys.slice(4).map(button).join('')}
` : ''); + if (messageButton) { messageButton.classList.remove('cc-message-fallback'); messageButton.dataset.caseMessage = 'true'; div.insertBefore(messageButton, div.querySelector('#cc-more')); } + $('type').after(div); + div.addEventListener('click', e => { const message = e.target.closest('[data-case-message]'); if (message) { window.caseCreateUI?.openMessage(); return; } const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } }); + } + async function applyTemplate(id) { + const template = templates.find(t => t.id === Number(id)); if (!template) return; + const values = template.values, fields = {type: values.type, titel: values.titel, beskrivelse: values.beskrivelse, status: values.status, assigned_group_id: values.assigned_group_id ?? ''}; + Object.entries(values.pipeline || {}).forEach(([k,v]) => { fields[`pipeline_${k === 'stage_id' ? 'stage_id' : k}`] = v ?? ''; }); + if (Object.entries(fields).some(([k,v]) => $(k)?.value && String(v) !== $(k).value) && !confirm('Skabelonen ændrer udfyldte felter. Vil du bruge den?')) return; + Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; }); + const tags = await api('/api/v1/tags?is_active=true'); + for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); } + updateCaseTypeSections(); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates(); + } + async function restore(draft) { + state.restoring = true; + try { + selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; + telefoniPrefill = draft.prefill || telefoniPrefill; + Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } }); + state.tags = draft.tags || []; state.hardware = draft.hardware || []; + $('orderLines').innerHTML = ''; + (draft.orders || []).forEach(item => { addOrderLine(item.type); const row = $('orderLines').lastElementChild; Object.entries({description:'description',quantity:'quantity',unit:'unit',unit_price:'unit-price',amount:'amount',currency:'currency',status:'status',external_ref:'reference',purchase_purpose:'purpose'}).forEach(([k,cls]) => { const el = row.querySelector(`.order-${cls}`); if (el) el.value = item[k] ?? ''; }); }); + renderSelections(); await loadHardwareForContacts(); renderTags(); renderSelectedHardware(); renderOrderLinesEmptyState(); + await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null); + if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id); + $('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || ''; + updateCaseTypeSections(); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; }); + $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; + } finally { state.restoring = false; } + state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload(); + } + function validate() { + document.querySelectorAll('.cc-error').forEach(el => el.remove()); + document.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid')); + const errors = []; + const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `
${esc(text)}
`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend',' · Kontrollér felter'); } }; + if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet'); + if (!selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma'); + $('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); }); + document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } }); + if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; } + return true; + } + async function init() { + const form = $('createForm'); + try { + const setting = await api('/api/v1/settings/case_statuses'); + const statuses = JSON.parse(setting.value || '[]'); + if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); }); + } catch { /* Keep the built-in statuses available. */ } + const headings = Array.from(form.querySelectorAll(':scope > h5')); + panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true); + for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]); + const metadata = headings[1]; panel('metadata', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]); + const tags = document.createElement('section'); tags.innerHTML = '
'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags]); + $('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag); + window.setTagPickerContext?.('case', null, addTag); + $('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } }; + $('cc-tag-suggestions').onclick = e => { const button = e.target.closest('[data-suggested-tag]'); const tag = state.tagCatalog.find(item => item.id === Number(button?.dataset.suggestedTag)); if (tag) addTag(tag).catch(error => showCreateError(error.message)); }; + const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips); + chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } }; + const originalRenderHardware = window.renderHardwareList; + window.renderHardwareList = items => { originalRenderHardware(items); Array.from($('hardwareList').children).forEach((row,i) => { if (!items[i]) return; const item = items[i]; const input = document.createElement('input'); input.type = 'checkbox'; input.className = 'form-check-input me-2'; input.dataset.hardwareChoice = item.id; input.setAttribute('aria-label', `Vælg ${item.model || item.brand || item.id}`); input.onchange = () => { state.hardware = state.hardware.filter(h => h.id !== item.id); if (input.checked) state.hardware.push({id:item.id,name:item.model || item.brand || `Hardware #${item.id}`}); renderSelectedHardware(); scheduleSave(); }; row.prepend(input); }); renderSelectedHardware(); }; + window.quickCreateHardware = async () => { + const name = $('hardwareNameInput').value.trim(), customerId = selectedCustomer?.id || getSingleContactCompanyId(); + if (!name || !customerId) { showCreateError('Udfyld hardwarenavn og vælg et firma'); return; } + const anydeskId = $('hardwareAnyDeskIdInput').value.trim(); + const button = $('hardwareNameInput').closest('.card-body').querySelector('button'); button.disabled = true; + try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; } + }; + const originalRender = window.renderSelections; + window.renderSelections = () => { originalRender(); if (state.ready) { scheduleSave(); scheduleDuplicates(); contactOpenCases(); } }; + const contactCases = document.createElement('aside'); contactCases.id = 'cc-contact-cases'; contactCases.className = 'cc-contact-cases'; contactCases.hidden = true; $('selectedContacts').after(contactCases); + const workloadEl = document.createElement('div'); workloadEl.id = 'cc-workload'; workloadEl.className = 'cc-workload'; state.panels.metadata.after(workloadEl); + const duplicateEl = document.createElement('div'); duplicateEl.id = 'cc-duplicates'; $('titel').closest('.row').after(duplicateEl); + const toolbar = document.createElement('div'); toolbar.className = 'mb-3'; toolbar.innerHTML = ''; form.prepend(toolbar); + try { templates = await api('/api/v1/case-create/templates'); $('cc-template').insertAdjacentHTML('beforeend',templates.map(t => ``).join('')); } catch { $('cc-template-error').textContent = 'Skabeloner er ikke tilgængelige.'; } + $('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message)); + const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','Klargør kladde…'); + toolbar.classList.add('cc-template-toolbar'); + buildTypes(); updateCaseTypeSections(); + form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0)); + $('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); }); + $('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload); + document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } }); + window.addEventListener('pagehide', save); + try { + const user = await api('/api/v1/auth/me'); + if (!(user.user_id || user.id)) throw new Error('Mangler bruger'); + const params = new URLSearchParams(location.search); params.sort(); + state.key = `bmc:case-draft:v1:${user.user_id || user.id}:${params.toString() || 'new'}`; + const raw = localStorage.getItem(state.key); let draft; + try { draft = raw ? JSON.parse(raw) : null; } catch { localStorage.removeItem(state.key); } + if (draft && (draft.version !== 1 || !Number.isFinite(draft.savedAt) || !draft.fields || !Array.isArray(draft.orders) || Date.now()-draft.savedAt > 7*86400000)) { localStorage.removeItem(state.key); draft=null; } + if (draft) { + state.pending = true; const prompt = document.createElement('div'); prompt.id = 'cc-draft'; prompt.className = 'alert alert-info'; prompt.innerHTML = 'Der findes en lokal kladde. '; form.prepend(prompt); + $('cc-restore').onclick = () => restore(draft).catch(e => showCreateError(e.message)); + $('cc-discard').onclick = () => { localStorage.removeItem(state.key); state.pending=false; prompt.remove(); scheduleSave(); }; + } + $('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser'; + } catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; } + state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []); + state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts(); + } + window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => ({tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id)}),created: result => { + state.completed = true; clearTimeout(saveTimer); + try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ } + }}; +})(); diff --git a/static/js/case-template-admin.js b/static/js/case-template-admin.js new file mode 100644 index 0000000..54b2480 --- /dev/null +++ b/static/js/case-template-admin.js @@ -0,0 +1,39 @@ +(() => { + const $ = id => document.getElementById(`cta-${id}`); + const esc = v => String(v ?? '').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + let rows = [], tags = [], selected = []; + async function api(path, options) { const r = await fetch(`/api/v1/${path}`, {credentials:'include',...options}); const data = await r.json(); if (!r.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Kontrollér skabelonens felter'); return data; } + function message(text, error=false) { $('message').textContent=text; $('message').className=error?'alert alert-danger':'alert alert-success'; } + function renderTags() { $('tags').innerHTML=selected.map(id=>``).join(''); } + function edit(row) { + $('form').reset(); const v=row?.values || {}; + for (const [key,value] of Object.entries({id:row?.id || '',name:row?.name || '',icon:row?.icon || 'bi-lightning',sort:row?.sort_order || 0,type:v.type || 'ticket',status:v.status || 'åben',group:v.assigned_group_id || '',title:v.titel || '',description:v.beskrivelse || '',stage:v.pipeline?.stage_id || '',amount:v.pipeline?.amount ?? '',probability:v.pipeline?.probability ?? '', 'pipeline-description':v.pipeline?.description || ''})) $(key).value=value; + $('active').checked=row?.is_active ?? true; selected=[...(v.tag_ids || [])]; renderTags(); + } + async function load() { rows=await api('case-create/templates/admin'); $('list').innerHTML=rows.map(r=>`
`).join('') || '

Ingen skabeloner endnu.

'; } + async function init() { + try { + const [types, groups, stages, tagRows] = await Promise.all([api('settings/case_types'),api('case-create/template-options'),api('pipeline/stages'),api('tags?is_active=true')]); + let typeKeys; try { typeKeys=JSON.parse(types.value || '[]'); } catch { typeKeys=[]; } + if (!Array.isArray(typeKeys) || !typeKeys.length) typeKeys=['ticket','pipeline','ordre','opgave','projekt','service','abonnement']; + $('type').innerHTML=[...new Set([...typeKeys,'pipeline','abonnement'])].map(t=>``).join(''); + $('group').insertAdjacentHTML('beforeend',groups.map(g=>``).join('')); + $('stage').insertAdjacentHTML('beforeend',stages.map(s=>``).join('')); + tags=tagRows; await load(); edit(null); + const setting = await api('settings/case_statuses'); + const statuses = JSON.parse(setting.value || '[]'); + if (Array.isArray(statuses)) statuses.forEach(s => { const value=typeof s==='string'?s:s.value; if(value && !Array.from($('status').options).some(o=>o.value===value)) $('status').add(new Option(typeof s==='string'?s:(s.label || value),value)); }); + } catch(e) { message(e.message,true); } + $('new').onclick=()=>edit(null); + $('tags').onclick=e=>{ const b=e.target.closest('[data-tag]'); if(b){selected=selected.filter(id=>id!==Number(b.dataset.tag));renderTags();} }; + $('add-tag').onclick=()=>window.tagPicker.showSelection(t=>{if(!selected.includes(t.id))selected.push(t.id);renderTags();}); + $('list').onclick=async e=>{try{const b=e.target.closest('[data-edit],[data-delete]');if(!b)return;if(b.dataset.edit)edit(rows.find(r=>r.id===Number(b.dataset.edit)));else if(confirm('Slet denne fælles skabelon?')){await api(`case-create/templates/${b.dataset.delete}`,{method:'DELETE'});await load();edit(null);}}catch(err){message(err.message,true);}}; + $('form').onsubmit=async e=>{e.preventDefault();const button=$('form').querySelector('[type=submit]');button.disabled=true;try{ + const number=k=>$(k).value===''?null:Number($(k).value); + const pipeline={stage_id:number('stage'),amount:number('amount'),probability:number('probability'),description:$('pipeline-description').value || null}; + const body={name:$('name').value.trim(),icon:$('icon').value,sort_order:number('sort') || 0,is_active:$('active').checked,values:{type:$('type').value,titel:$('title').value,beskrivelse:$('description').value,status:$('status').value,assigned_group_id:number('group'),tag_ids:selected,pipeline:Object.values(pipeline).some(v=>v!==null)?pipeline:null}}; + const id=$('id').value;const saved=await api(`case-create/templates${id?`/${id}`:''}`,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});await load();edit(saved);message('Skabelon gemt'); + }catch(err){message(err.message,true);}finally{button.disabled=false;}}; + } + document.addEventListener('DOMContentLoaded',init); +})(); diff --git a/static/js/internal-message.js b/static/js/internal-message.js new file mode 100644 index 0000000..e5e1ebf --- /dev/null +++ b/static/js/internal-message.js @@ -0,0 +1,68 @@ +(() => { + 'use strict'; + const $ = id => document.getElementById(id); + let kind = 'message', contact = null, searchTimer, searchVersion = 0, sending = false; + async function api(url, options = {}) { + const response = await fetch(`/api/v1/${url}`, {credentials:'include',...options}); + const data = await response.json().catch(()=>({})); + if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Handlingen mislykkedes. Prøv igen.'); + return data; + } + function showError(message) { $('im-error').textContent=message; $('im-error').classList.remove('d-none'); } + function setKind(value) { + kind=value; + $('im-phone-fields').classList.toggle('d-none',kind!=='phone'); + document.querySelectorAll('[data-internal-kind]').forEach(b=>{const active=b.dataset.internalKind===kind;b.classList.toggle('btn-primary',active);b.classList.toggle('btn-outline-primary',!active);b.setAttribute('aria-pressed',String(active));}); + $('im-text').placeholder=kind==='phone'?'Fx: Har ringet om tilbuddet og vil gerne ringes op i eftermiddag.':'Hvad skal din kollega vide?'; + } + function selectContact(value) { + contact=value; ++searchVersion; clearTimeout(searchTimer); + $('im-contact').replaceChildren(); $('im-contact-search').value=''; $('im-contact-results').classList.add('d-none'); + if (!contact) return; + $('im-contact-panel').open = true; + const chip=document.createElement('button');chip.type='button';chip.className='btn btn-sm btn-outline-primary';chip.textContent=`${contact.name} ×`;chip.setAttribute('aria-label',`Fjern ${contact.name}`);chip.onclick=()=>selectContact(null);$('im-contact').append(chip); + if (!$('im-caller').value) $('im-caller').value=contact.name; + if (!$('im-phone').value) $('im-phone').value=contact.phone || ''; + } + window.openInternalMessage = async (context={}) => { + if (sending) return; + $('im-error').classList.add('d-none'); + if (!$('im-text').value.trim() && context.kind) setKind(context.kind); + if (!$('im-text').value.trim() && context.contact) selectContact(context.contact); + bootstrap.Modal.getOrCreateInstance($('internalMessageModal')).show(); + try { + const [payload, me]=await Promise.all([api('users?is_active=true'),api('auth/me')]); + const selected=!$('im-text').value.trim() && context.recipient ? String(context.recipient) : $('im-recipient').value; + const users=Array.isArray(payload)?payload:(payload.data || []); + $('im-recipient').replaceChildren(new Option('Vælg medarbejder…','')); + users.filter(u=>Number(u.id || u.user_id)!==Number(me.id || me.user_id)).forEach(u=>$('im-recipient').add(new Option(u.full_name || u.username || u.email, u.id || u.user_id))); + $('im-recipient').value=selected; + } catch(error) { showError(error.message); } + }; + document.querySelectorAll('[data-internal-kind]').forEach(b=>b.onclick=()=>setKind(b.dataset.internalKind)); + $('internalMessageModal').addEventListener('hidden.bs.modal',()=>{++searchVersion;clearTimeout(searchTimer);$('im-contact-results').classList.add('d-none');}); + $('im-contact-search').addEventListener('input',()=>{ + clearTimeout(searchTimer);const version=++searchVersion,query=$('im-contact-search').value.trim(); + $('im-contact-results').classList.add('d-none'); + if(query.length<2)return; + searchTimer=setTimeout(async()=>{try{ + const rows=await api(`search/contacts?q=${encodeURIComponent(query)}`); + if(version!==searchVersion || document.activeElement!==$('im-contact-search'))return; + $('im-contact-results').replaceChildren(); + if(!Array.isArray(rows) || !rows.length){$('im-contact-results').textContent='Ingen kontakter fundet. Du kan sende uden kontaktperson.';} + else rows.slice(0,10).forEach(r=>{const name=[r.first_name,r.last_name].filter(Boolean).join(' ') || r.name || `Kontakt #${r.id}`,phone=r.mobile || r.phone || '';const b=document.createElement('button');b.type='button';b.className='list-group-item list-group-item-action';b.textContent=`${name}${phone?' · '+phone:''}`;b.onclick=()=>selectContact({id:r.id,name,phone});$('im-contact-results').append(b);}); + $('im-contact-results').classList.remove('d-none'); + }catch(error){if(version===searchVersion)showError(error.message);}},300); + }); + $('internalMessageForm').addEventListener('submit',async e=>{ + e.preventDefault();if(sending)return; + if(!$('im-recipient').value || !$('im-text').value.trim()){showError('Vælg en modtager og skriv en besked.');return;} + sending=true;$('im-send').disabled=true;$('im-send').textContent='Sender…';$('im-error').classList.add('d-none'); + try { + await api('bottom-bar/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:$('im-text').value.trim(),recipient_user_id:Number($('im-recipient').value),requires_manual_ack:$('im-ack').checked,message_kind:kind,contact_id:contact?.id || null,caller_name:kind==='phone'?$('im-caller').value.trim():'',callback_phone:kind==='phone'?$('im-phone').value.trim():''})}); + $('internalMessageForm').reset();selectContact(null);$('im-contact-panel').open=false;setKind('message');bootstrap.Modal.getOrCreateInstance($('internalMessageModal')).hide(); + window.dispatchEvent(new CustomEvent('hub:internal-message-sent')); + if(window.showNotification)window.showNotification('Besked sendt til din kollega','success'); + }catch(error){showError(error.message);}finally{sending=false;$('im-send').disabled=false;$('im-send').innerHTML='Send besked';} + }); +})(); diff --git a/static/js/message-ui.js b/static/js/message-ui.js new file mode 100644 index 0000000..e4d4025 --- /dev/null +++ b/static/js/message-ui.js @@ -0,0 +1,23 @@ +/* Presentational helpers shared by the internal inbox. No network or mutations. */ +window.BmcMessageUI = (() => { + const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const initials = name => String(name || '?').trim().split(/\s+/).slice(0,2).map(p=>p[0]).join('').toUpperCase(); + const time = value => { const d=new Date(value); return value && !Number.isNaN(d.getTime()) ? d.toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}) : ''; }; + function message(m) { + const own=!!m.is_own, phone=m.message_kind==='phone'; + const number=String(m.callback_phone || '').replace(/[^+0-9*#,;]/g,''); + const person=m.caller_name || m.contact_name || 'Telefonbesked'; + const contact=Number(m.contact_id)>0?` ${escape(m.contact_name || 'Åbn kontakt')}`:''; + const replyId=own?Number(m.recipient_user_id):Number(m.sender_user_id); + const stamp=time(m.created_at); + const status=own?(m.is_acknowledged?'Bekræftet læst':m.is_read?'Læst':'Sendt'):''; + const phoneCard=phone?`
TELEFONBESKED${escape(person)}${m.callback_phone?`${escape(m.callback_phone)}`:''}
${number?` Ring tilbage`:''}
`:''; + return `
${phoneCard}
${escape(m.text)}
${contact}
${status?` ${status}`:''}${m.requires_manual_ack && !m.is_acknowledged?'Læsebekræftelse ønskes':''}${!own && m.requires_manual_ack && !m.is_acknowledged?``:''}${replyId>0?``:''}
`; + } + function thread(t, active) { + const last=t.items?.[t.items.length-1]; + const preview=last?`${last.message_kind==='phone'?'Telefonbesked · ':''}${last.text || ''}`:'Start en samtale'; + return `${t.partnerUserId?escape(initials(t.label)):''}${escape(t.label)}${escape(preview)}${t.unread?`${Number(t.unread)}`:''}`; + } + return {message,thread,escape,initials}; +})(); diff --git a/static/js/tag-picker.js b/static/js/tag-picker.js index 22e0854..1049fa1 100644 --- a/static/js/tag-picker.js +++ b/static/js/tag-picker.js @@ -256,6 +256,11 @@ class TagPicker { async selectTag(tag) { if (!tag) return; + if (this.selectionOnly) { + this.onSelectCallback?.(tag, null); + this.hide(); + return; + } console.log(`🏷️ Selecting tag ${tag.name} for context: ${this.contextType} #${this.contextId}`); @@ -356,6 +361,7 @@ class TagPicker { } show(contextType = null, contextId = null, onSelect = null) { + this.selectionOnly = false; // Use provided context OR fall back to page defaults // Note: arguments are undefined if not passed, so check for null/undefined this.contextType = (contextType !== null && contextType !== undefined) ? contextType : this.defaultContextType; @@ -380,6 +386,13 @@ class TagPicker { this.defaultOnSelectCallback = onSelect; } + showSelection(onSelect) { + this.show(null, null, onSelect); + this.selectionOnly = true; + this.contextType = null; + this.contextId = null; + } + hide() { const modalInstance = bootstrap.Modal.getInstance(this.modal); if (modalInstance) { @@ -413,6 +426,18 @@ class TagPicker { // Initialize global tag picker window.tagPicker = new TagPicker(); +// Deliver creation-time workflow events on the saved case, where handlers exist. +document.addEventListener('DOMContentLoaded', () => { + const match = location.pathname.match(/^\/sag\/(\d+)(?:\/v3)?\/?$/); + if (!match) return; + const key = `bmc:case-tag-actions:${match[1]}`; + try { + const actions = JSON.parse(sessionStorage.getItem(key) || '[]'); + sessionStorage.removeItem(key); + setTimeout(() => actions.forEach(detail => window.dispatchEvent(new CustomEvent('hub:tag-action', {detail}))), 0); + } catch { /* Invalid or unavailable session storage must not block the page. */ } +}); + // Helper function to show tag picker with context window.showTagPicker = function(entityType, entityId, onSelect = null) { window.tagPicker.show(entityType, entityId, onSelect); diff --git a/static/messages.css b/static/messages.css new file mode 100644 index 0000000..e48ebd2 --- /dev/null +++ b/static/messages.css @@ -0,0 +1,82 @@ +/* Internal messages: a quiet inbox, a readable conversation, one composer. */ +.global-bottom-bar .bb-messages-layout{display:grid;grid-template-columns:248px minmax(0,1fr);gap:0;border:1px solid var(--border-color,#dce4e9);border-radius:14px;background:var(--bg-card,#fff);height:100%;min-height:0;overflow:hidden} +.global-bottom-bar .bb-message-threads{display:flex;flex-direction:column;gap:3px;overflow:auto;border-right:1px solid var(--border-color,#e2e8ed);padding:0 8px 10px;background:color-mix(in srgb,var(--bg-card,#fff) 97%,#59798c);scrollbar-width:thin} +.msg-inbox-heading{display:flex;align-items:center;justify-content:space-between;padding:16px 8px 12px;font-size:.72rem;font-weight:650;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#718293)} +.msg-inbox-heading button,.msg-header-action{border:0;background:transparent;color:var(--text-secondary,#657b8b);border-radius:8px;padding:7px;cursor:pointer} +.msg-inbox-heading button:hover,.msg-header-action:hover{background:var(--accent-light,#eaf1f4)} +.global-bottom-bar .bb-message-thread{width:100%;border:0;border-radius:10px;padding:12px 9px;gap:10px;white-space:normal;box-shadow:none;text-align:left;background:transparent;flex-shrink:0;min-height:66px} +.global-bottom-bar .bb-message-thread:hover{background:var(--bg-hover,#edf2f5)} +.global-bottom-bar .bb-message-thread.is-active{background:color-mix(in srgb,var(--bg-card,#fff) 90%,#278e87);color:var(--text-primary,#233f50);box-shadow:none;border:0} +.msg-avatar{display:grid;place-items:center;width:34px;height:34px;border-radius:11px;flex-shrink:0;background:color-mix(in srgb,var(--bg-card,#fff) 84%,#607a8e);font-size:.7rem;letter-spacing:.03em;color:var(--text-primary,#3d5868)} +.msg-avatar.is-group{background:color-mix(in srgb,var(--bg-card,#fff) 88%,#319387);color:var(--text-primary,#347369)} +.msg-thread-copy{min-width:0;flex:1;display:block} +.msg-thread-top{display:flex;align-items:center;justify-content:space-between;gap:8px} +.msg-thread-top strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:.79rem} +.msg-thread-top time{font-size:.62rem;flex-shrink:0;color:var(--text-secondary,#7d8d99);font-weight:400} +.msg-thread-preview{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#7d8d99);font-weight:400;font-size:.71rem;margin-top:4px} +.global-bottom-bar .bb-message-thread-count,.global-bottom-bar .bb-message-thread.is-active .bb-message-thread-count{background:#267f79;color:white;font-size:.63rem;min-width:17px;height:17px;padding:0 4px} +.msg-conversation{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden} +.msg-conversation-header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--border-color,#e6ebef)} +.msg-conversation-header strong{font-size:.87rem;font-weight:650;color:var(--text-primary,#233f50)} +.msg-conversation-header small{display:block;font-size:.68rem;color:var(--text-secondary,#7a8c99);margin-top:3px} +.msg-header-action{display:flex;gap:7px;align-items:center;font-size:.7rem;white-space:nowrap} +.global-bottom-bar .bb-messages-list{display:flex;flex-direction:column;gap:17px;flex:1;min-height:0;overflow-y:auto;padding:20px!important;margin:0;background:color-mix(in srgb,var(--bg-card,#fff) 98%,#678190)} +.global-bottom-bar .bb-messages-list>li{display:block!important;flex-shrink:0;margin:0;padding:0!important;border:0!important;box-shadow:none!important;background:transparent!important;list-style:none} +.msg-message{max-width:84%;width:fit-content;min-width:130px} +.msg-message.is-own{margin-left:auto} +.msg-byline{display:flex;align-items:center;gap:9px;padding:0 3px 5px;font-size:.64rem;color:var(--text-secondary,#7a8a97)} +.msg-byline>span:first-child{font-weight:600;color:var(--text-primary,#466072)} +.msg-message.is-own .msg-byline{justify-content:flex-end} +.msg-new-dot{width:5px;height:5px;border-radius:50%;background:#2c9589} +.msg-bubble{padding:12px 15px;border:1px solid var(--border-color,#e0e7ec);border-radius:3px 13px 13px;background:var(--bg-card,#fff);color:var(--text-primary,#304b5d)} +.msg-message.is-own .msg-bubble{background:color-mix(in srgb,var(--bg-card,#fff) 92%,#349b90);border-color:color-mix(in srgb,var(--border-color,#e0e7ec) 65%,#349b90);border-radius:13px 3px 13px 13px} +.msg-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.82rem;line-height:1.65} +.msg-message-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:5px;padding:0 3px;min-height:18px;color:var(--text-secondary,#81909c);font-size:.61rem} +.is-own .msg-message-actions{justify-content:flex-end} +.msg-message-actions button{font-size:.64rem;background:transparent;border:0;padding:1px 0;color:var(--text-secondary,#6b8291)} +.msg-message-actions .msg-ack{color:var(--text-primary,#266e65);font-weight:600} +.msg-reply:hover,.msg-ack:hover{text-decoration:underline} +.msg-contact{display:inline-flex;gap:5px;margin-top:9px;text-decoration:none;font-size:.68rem;color:var(--text-primary,#397480)} +.msg-call{display:flex;gap:10px;align-items:center;padding-bottom:12px;margin-bottom:10px;border-bottom:1px solid var(--border-color,#dce5e8)} +.msg-call-icon{width:33px;height:33px;border-radius:10px;display:grid;place-items:center;background:color-mix(in srgb,var(--bg-card,#fff) 83%,#cda65e);color:var(--text-primary,#80662f);flex-shrink:0} +.msg-call>div{min-width:0;flex:1} +.msg-call small{display:block;font-size:.54rem;letter-spacing:.08em;color:var(--text-secondary,#88919a);margin-bottom:3px} +.msg-call strong{display:block;font-weight:600;font-size:.8rem;overflow-wrap:anywhere} +.msg-call>div>span{display:block;font-size:.71rem;color:var(--text-secondary,#7a8c99);margin-top:3px} +.msg-call-action{display:flex;align-items:center;gap:5px;flex-shrink:0;text-decoration:none;color:var(--text-primary,#286e65);font-size:.66rem;padding:6px 8px;border:1px solid var(--border-color,#d4e0e4);border-radius:7px} +.global-bottom-bar .bb-messages-composer{margin:0;padding:12px 18px 10px;flex-shrink:0;border:0;border-top:1px solid var(--border-color,#e5ebee);background:var(--bg-card,#fff);border-radius:0} +.msg-compose-field{display:flex;align-items:flex-end;border:1px solid var(--border-color,#dbe4ea);border-radius:11px;background:var(--bg-card,#fff);padding:8px;gap:9px} +.msg-compose-field:focus-within{border-color:#639f99;box-shadow:0 0 0 2px #349b9012} +.msg-compose-field textarea{width:100%;min-width:0;resize:vertical;max-height:110px;min-height:43px;border:0;background:transparent;outline:none;padding:3px 5px;color:var(--text-primary,#304b5d);font-size:.82rem;line-height:1.6} +.msg-send{height:33px;width:33px;border:0;border-radius:9px;background:#267b74;color:white;flex-shrink:0} +.msg-send:disabled{opacity:.5} +.msg-compose-footer{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:7px;font-size:.63rem;color:var(--text-secondary,#84919c)} +.msg-compose-footer label{display:flex;gap:6px;align-items:center;cursor:pointer} +.msg-compose-footer input{accent-color:#267b74} +.msg-empty{display:grid;place-items:center;text-align:center;color:var(--text-secondary,#81919e);padding:24px 10px} +.msg-empty>span{width:46px;height:46px;border-radius:15px;background:var(--accent-light,#edf4f4);display:grid;place-items:center;margin-bottom:13px;font-size:1.25rem} +.msg-empty strong{font-size:.84rem;color:var(--text-primary,#3d5667)} +.msg-empty p{font-size:.73rem;max-width:260px;margin:7px 0} +/* Compose dialog shares the inbox palette and places the message first. */ +#internalMessageModal .modal-dialog{max-width:640px} +#internalMessageModal .modal-content{border:1px solid var(--border-color,#dce4e9)!important;border-radius:18px!important;background:var(--bg-card,#fff);overflow:hidden} +#internalMessageModal .modal-header{padding:24px 28px 20px!important;align-items:flex-start} +#internalMessageModal .modal-header h4{font-size:1.3rem;letter-spacing:-.03em;font-weight:650;color:var(--text-primary,#233f50)} +#internalMessageModal .modal-header .text-uppercase{font-size:.6rem!important;letter-spacing:.12em;display:block;margin-bottom:7px} +#internalMessageModal .modal-body{padding:0 28px 8px!important} +#internalMessageModal .form-label{font-size:.76rem;font-weight:550;color:var(--text-primary,#445f71);margin-bottom:7px} +#internalMessageModal .form-control,#internalMessageModal .form-select{font-size:.83rem;border:1px solid var(--border-color,#dce4e9);border-radius:9px;background:var(--bg-card,#fff);color:var(--text-primary,#304b5d);padding:10px 12px} +#internalMessageModal .form-control:focus,#internalMessageModal .form-select:focus{border-color:#639f99;box-shadow:0 0 0 3px #349b9012} +#internalMessageModal #im-text{min-height:145px;line-height:1.7;resize:vertical} +#internalMessageModal [role=group]{display:flex;gap:4px!important;background:var(--bg-hover,#f1f5f6);padding:4px;border-radius:10px;margin-bottom:22px!important} +#internalMessageModal [data-internal-kind]{flex:1;border:0!important;background:transparent;color:var(--text-secondary,#7c8b96);border-radius:7px;font-size:.78rem;padding:10px} +#internalMessageModal [data-internal-kind][aria-pressed=true]{background:var(--bg-card,#fff);color:var(--text-primary,#2d635d);box-shadow:0 1px 5px #163b4912} +#internalMessageModal #im-phone-fields{padding:14px 4px;background:var(--bg-hover,#f5f7f8);border-radius:10px;margin:0 0 17px} +#internalMessageModal .modal-footer{padding:16px 28px 22px!important;background:var(--bg-hover,#f7f9fa);gap:8px} +#internalMessageModal .modal-footer .btn{border-radius:9px;font-size:.8rem;padding:10px 19px} +#internalMessageModal #im-send{background:#267b74;border-color:#267b74} +#internalMessageModal .form-check-label,#internalMessageModal p.small{font-size:.7rem!important;color:var(--text-secondary,#7c8c98)} +#internalMessageModal #im-contact .btn{font-size:.7rem;border-radius:7px;border-color:var(--border-color,#d6e4e5);color:var(--text-primary,#397480)} +#internalMessageModal .form-check-input{accent-color:#267b74} +@media(max-width:820px){.global-bottom-bar .bb-messages-layout{grid-template-columns:195px minmax(0,1fr)}.msg-avatar{display:none}.msg-conversation-header{padding:11px 13px}.global-bottom-bar .bb-messages-list{padding:14px!important}.msg-message{max-width:94%}.msg-compose-footer>small{display:none}.msg-call{flex-wrap:wrap}.msg-call-action{margin-left:43px}} +@media(max-width:600px){.global-bottom-bar .bb-messages-layout{display:flex;flex-direction:column}.global-bottom-bar .bb-message-threads{flex-direction:row;flex-shrink:0;overflow-x:auto;max-height:66px;border-right:0;border-bottom:1px solid var(--border-color,#e2e8ed);padding:5px;gap:4px}.msg-inbox-heading{padding:0 5px}.msg-inbox-heading>span{display:none}.global-bottom-bar .bb-message-thread{min-height:50px;width:160px;padding:7px 9px}.msg-thread-top time{display:none}.msg-conversation{flex:1}.msg-header-action span{display:none}.global-bottom-bar .bb-messages-composer{padding:8px 10px}.msg-compose-field textarea{min-height:32px}.msg-message-actions{gap:6px}.msg-ack-hint{display:none}#internalMessageModal .modal-body{padding:0 20px 8px!important}#internalMessageModal .modal-header{padding:22px 20px 18px!important}} diff --git a/tests/test_case_create_dom.cjs b/tests/test_case_create_dom.cjs new file mode 100644 index 0000000..6072d6b --- /dev/null +++ b/tests/test_case_create_dom.cjs @@ -0,0 +1,87 @@ +// Run with NODE_PATH pointing to a jsdom installation (no application dependency). +const {JSDOM} = require('jsdom'); +const fs = require('node:fs'); +const assert = require('node:assert/strict'); +const source = fs.readFileSync('app/modules/sag/templates/create.html','utf8'); +const script = source.match(/',message_kind:'phone',caller_name:'Anne',callback_phone:'+45 12345678',contact_id:4,created_at:'2026-09-08T11:00:00'}, {id:3,sender_user_id:1,recipient_user_id:2,to:'Kollega',from:'Dig',text:'Jeg ringer',is_own:true,is_read:true,created_at:'2026-09-08T11:01:00'}]; + w.inboxTest.render(items,'user:2'); + assert.equal(w.inboxTest.threads().length,2,'Broadcast is separate from sender conversation'); + assert.equal(d.querySelectorAll('.msg-message').length,2); + assert.equal(d.querySelectorAll('.msg-call').length,1); + assert.equal(d.querySelector('.msg-call-action').getAttribute('href'),'tel:+4512345678'); + assert.equal(d.querySelector('.msg-bubble script'),null,'Messages must be escaped'); + assert.match(d.querySelector('.is-own .msg-delivery').textContent,/Læst/); + assert.equal(d.getElementById('chatInputQuick').tagName,'TEXTAREA'); + let sends=0;d.getElementById('btnSendMsg').onclick=()=>sends++; + d.getElementById('chatInputQuick').dispatchEvent(new w.KeyboardEvent('keydown',{key:'Enter',bubbles:true}));assert.equal(sends,0); + d.getElementById('chatInputQuick').dispatchEvent(new w.KeyboardEvent('keydown',{key:'Enter',ctrlKey:true,bubbles:true}));assert.equal(sends,1); + dom.window.close();console.log('PASS: inbox layout, empty-state safety, separate broadcasts, phone cards, escaping, read status, multiline and shortcut'); +})().catch(e=>{console.error(e);process.exit(1);}); diff --git a/tests/test_sag_module.py b/tests/test_sag_module.py index 99f9553..544343c 100644 --- a/tests/test_sag_module.py +++ b/tests/test_sag_module.py @@ -159,7 +159,7 @@ def test_case_create_lists_contacts_for_selected_customer(): def test_case_create_defaults_responsible_to_current_user(): template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8") router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8") - assert "selectCurrentUserAsResponsible();" in template + assert "selectCurrentUserAsResponsible()" in template assert "raw_responsible = data.get" in router assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router