feat: Add Kuando Busylight integration and enhance case creation UI
- Introduced Kuando Busylight integration for centralized presence management. - Updated case creation UI to support new modules and improved type handling. - Added personal Buzzwords visibility preferences for users. - Implemented file upload functionality directly from the case topbar. - Enhanced validation and user experience in case creation process. - Created migration for user-specific module preferences. - Updated tests to cover new features and ensure functionality.
This commit is contained in:
parent
abfb945679
commit
81b8229db1
@ -15,7 +15,7 @@ from uuid import uuid4
|
||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
|
||||
from app.core.database import execute_query, execute_query_single, execute_update, table_has_column, get_db_connection, release_db_connection
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
||||
from app.core.config import settings
|
||||
@ -1533,11 +1533,22 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/modules")
|
||||
async def get_case_module_prefs(sag_id: int):
|
||||
"""Get module visibility preferences for a case."""
|
||||
async def get_case_module_prefs(sag_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Get module visibility preferences for the current user on a case."""
|
||||
try:
|
||||
query = "SELECT module_key, is_enabled FROM sag_module_prefs WHERE sag_id = %s"
|
||||
prefs = execute_query(query, (sag_id,))
|
||||
user_id = int(current_user["id"])
|
||||
query = """
|
||||
SELECT module_key, is_enabled
|
||||
FROM sag_module_prefs
|
||||
WHERE sag_id = %s
|
||||
AND module_key <> 'buzzwords'
|
||||
UNION ALL
|
||||
SELECT module_key, is_enabled
|
||||
FROM user_sag_module_prefs
|
||||
WHERE sag_id = %s
|
||||
AND user_id = %s
|
||||
"""
|
||||
prefs = execute_query(query, (sag_id, sag_id, user_id))
|
||||
return prefs or []
|
||||
except Exception as e:
|
||||
logger.error("❌ Error getting module prefs: %s", e)
|
||||
@ -1545,8 +1556,12 @@ async def get_case_module_prefs(sag_id: int):
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/modules")
|
||||
async def set_case_module_pref(sag_id: int, data: dict):
|
||||
"""Set module visibility preference for a case."""
|
||||
async def set_case_module_pref(
|
||||
sag_id: int,
|
||||
data: dict,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Set a module visibility preference, scoped per user for Buzzwords."""
|
||||
try:
|
||||
module_key = data.get("module_key")
|
||||
is_enabled = data.get("is_enabled")
|
||||
@ -1554,14 +1569,26 @@ async def set_case_module_pref(sag_id: int, data: dict):
|
||||
if not module_key or is_enabled is None:
|
||||
raise HTTPException(status_code=400, detail="module_key and is_enabled are required")
|
||||
|
||||
query = """
|
||||
INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (sag_id, module_key)
|
||||
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
|
||||
RETURNING module_key, is_enabled
|
||||
"""
|
||||
result = execute_query(query, (sag_id, module_key, bool(is_enabled)))
|
||||
if module_key == "buzzwords":
|
||||
if not _table_exists("user_sag_module_prefs"):
|
||||
raise HTTPException(status_code=409, detail="Buzzwords-indstillingen er ikke klar. Kør migration 1042 først.")
|
||||
query = """
|
||||
INSERT INTO user_sag_module_prefs (user_id, sag_id, module_key, is_enabled)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (user_id, sag_id, module_key)
|
||||
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
|
||||
RETURNING module_key, is_enabled
|
||||
"""
|
||||
result = execute_query(query, (int(current_user["id"]), sag_id, module_key, bool(is_enabled)))
|
||||
else:
|
||||
query = """
|
||||
INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (sag_id, module_key)
|
||||
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
|
||||
RETURNING module_key, is_enabled
|
||||
"""
|
||||
result = execute_query(query, (sag_id, module_key, bool(is_enabled)))
|
||||
return result[0] if result else {"module_key": module_key, "is_enabled": bool(is_enabled)}
|
||||
except HTTPException:
|
||||
raise
|
||||
@ -5691,78 +5718,98 @@ def _decode_header_str(header_val):
|
||||
except:
|
||||
return str(header_val)
|
||||
|
||||
def _copy_uploaded_email_attachments_to_case(sag_id: int, email_id: int, attachments: List[Dict]) -> int:
|
||||
"""Copy valid manual-email attachments into the normal case-files module."""
|
||||
if not attachments or not _table_exists("sag_files"):
|
||||
return 0
|
||||
|
||||
has_source_email = table_has_column("sag_files", "source_email_id")
|
||||
has_source_type = table_has_column("sag_files", "source_type")
|
||||
copied = 0
|
||||
|
||||
for attachment in attachments:
|
||||
filename = Path(str(attachment.get("filename") or "attachment.bin")).name
|
||||
content = attachment.get("content")
|
||||
if not filename or not content:
|
||||
continue
|
||||
content = bytes(content)
|
||||
extension = Path(filename).suffix.lower().lstrip(".")
|
||||
if not extension or extension not in ALLOWED_EXTENSIONS or len(content) > MAX_ATTACHMENT_SIZE:
|
||||
logger.warning("Skipping unsupported or oversized email attachment %s for SAG-%s", filename, sag_id)
|
||||
continue
|
||||
|
||||
if has_source_email:
|
||||
existing = execute_query(
|
||||
"""SELECT 1 FROM sag_files
|
||||
WHERE sag_id = %s AND source_email_id = %s AND filename = %s
|
||||
LIMIT 1""",
|
||||
(sag_id, email_id, filename),
|
||||
) or []
|
||||
if existing:
|
||||
continue
|
||||
|
||||
stored_name = _generate_stored_name(filename, SAG_FILE_SUBDIR)
|
||||
destination = _resolve_attachment_path(stored_name)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(content)
|
||||
|
||||
columns = ["sag_id", "filename", "content_type", "size_bytes", "stored_name"]
|
||||
values: List[Any] = [
|
||||
sag_id,
|
||||
filename,
|
||||
attachment.get("content_type") or "application/octet-stream",
|
||||
len(content),
|
||||
stored_name,
|
||||
]
|
||||
if has_source_email:
|
||||
columns.append("source_email_id")
|
||||
values.append(email_id)
|
||||
if has_source_type:
|
||||
columns.append("source_type")
|
||||
values.append("email_import")
|
||||
execute_query(
|
||||
f"INSERT INTO sag_files ({', '.join(columns)}) VALUES ({', '.join(['%s'] * len(values))})",
|
||||
tuple(values),
|
||||
)
|
||||
copied += 1
|
||||
|
||||
return copied
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/upload-email")
|
||||
async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
|
||||
"""Upload .eml/.msg, parse it, save to email_messages, and link to case."""
|
||||
content = await file.read()
|
||||
filename = file.filename.lower()
|
||||
email_data = {}
|
||||
temp_id = uuid4().hex
|
||||
|
||||
# 1. Parse File
|
||||
if filename.endswith('.msg'):
|
||||
if not extract_msg: raise HTTPException(500, "extract-msg missing")
|
||||
import io
|
||||
msg = extract_msg.Message(io.BytesIO(content))
|
||||
email_data = {
|
||||
'message_id': msg.messageId or f"msg-{temp_id}",
|
||||
'subject': msg.subject or "No Subject",
|
||||
'sender_email': msg.sender or "",
|
||||
'sender_name': msg.sender or "",
|
||||
'recipient_email': msg.to or "",
|
||||
'cc': msg.cc or "",
|
||||
'body_text': msg.body,
|
||||
'body_html': msg.htmlBody,
|
||||
'received_date': msg.date or datetime.now(),
|
||||
'folder': 'Imported',
|
||||
'attachments': [],
|
||||
'has_attachments': False,
|
||||
'attachment_count': 0
|
||||
}
|
||||
elif filename.endswith('.eml'):
|
||||
msg = email.message_from_bytes(content)
|
||||
body_text = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
body_text = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
||||
break
|
||||
else:
|
||||
body_text = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
|
||||
|
||||
email_data = {
|
||||
'message_id': msg.get('Message-ID', f"eml-{temp_id}"),
|
||||
'in_reply_to': _decode_header_str(msg.get('In-Reply-To', '')),
|
||||
'email_references': _decode_header_str(msg.get('References', '')),
|
||||
'subject': _decode_header_str(msg.get('Subject', 'No Subject')),
|
||||
'sender_email': _decode_header_str(msg.get('From', '')),
|
||||
'sender_name': _decode_header_str(msg.get('From', '')),
|
||||
'recipient_email': _decode_header_str(msg.get('To', '')),
|
||||
'cc': _decode_header_str(msg.get('Cc', '')),
|
||||
'received_date': datetime.now(),
|
||||
'body_text': body_text,
|
||||
'body_html': "",
|
||||
'folder': 'Imported',
|
||||
'has_attachments': False,
|
||||
'attachment_count': 0,
|
||||
'attachments': []
|
||||
}
|
||||
else:
|
||||
"""Import .eml/.msg into the email tab and copy its attachments to case files."""
|
||||
filename = str(file.filename or "").lower()
|
||||
if not (filename.endswith(".eml") or filename.endswith(".msg")):
|
||||
raise HTTPException(400, "Only .msg or .eml files allowed")
|
||||
|
||||
# 2. Save Email via Service (deduplicate)
|
||||
svc = EmailService()
|
||||
existing = execute_query_single("SELECT id FROM email_messages WHERE message_id = %s", (email_data['message_id'],))
|
||||
|
||||
if existing:
|
||||
email_id = existing['id']
|
||||
else:
|
||||
# We try to strict save, keeping it simple
|
||||
email_id = await svc.save_email(email_data)
|
||||
content = await file.read()
|
||||
service = EmailService()
|
||||
email_data = service.parse_msg_file(content) if filename.endswith(".msg") else service.parse_eml_file(content)
|
||||
if not email_data:
|
||||
raise HTTPException(status_code=400, detail="E-mailfilen kunne ikke læses")
|
||||
|
||||
email_id = await service.save_uploaded_email(email_data)
|
||||
if not email_id:
|
||||
existing = execute_query_single(
|
||||
"SELECT id FROM email_messages WHERE message_id = %s",
|
||||
(email_data["message_id"],),
|
||||
)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=500, detail="E-mailen kunne ikke gemmes")
|
||||
email_id = existing["id"]
|
||||
|
||||
# 3. Link
|
||||
await add_sag_email_link(sag_id, {"email_id": email_id})
|
||||
return {"status": "imported", "email_id": email_id}
|
||||
attachment_files_created = _copy_uploaded_email_attachments_to_case(
|
||||
sag_id,
|
||||
int(email_id),
|
||||
email_data.get("attachments") or [],
|
||||
)
|
||||
return {
|
||||
"status": "imported",
|
||||
"email_id": email_id,
|
||||
"attachment_files_created": attachment_files_created,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/emails/send")
|
||||
|
||||
@ -959,10 +959,9 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
raise HTTPException(status_code=500, detail="Failed to load case details")
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
|
||||
@router.get("/sag/{sag_id}/v4", response_class=HTMLResponse)
|
||||
async def sag_detaljer_v3(request: Request, sag_id: int):
|
||||
"""Display the active V4 case page; /v3 remains as a compatible alias."""
|
||||
async def sag_detaljer_v4(request: Request, sag_id: int):
|
||||
"""Display the active V4 case page."""
|
||||
try:
|
||||
# Fetch main case
|
||||
sag_query = """
|
||||
@ -1300,9 +1299,6 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
|
||||
status_options.append(current_status)
|
||||
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
|
||||
|
||||
# V4 is the only active case design. Keep /v3 routable because many
|
||||
# existing links and bookmarks still use it, but render the same V4
|
||||
# template so users cannot accidentally fall back to the old design.
|
||||
template_name = "modules/sag/templates/detail_v4.html"
|
||||
response = templates.TemplateResponse(template_name, {
|
||||
"request": request,
|
||||
@ -1339,6 +1335,12 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
|
||||
raise HTTPException(status_code=500, detail="Failed to load case details")
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
|
||||
async def sag_detaljer_v3_legacy(sag_id: int):
|
||||
"""Keep old links working, while making V4 the canonical case URL."""
|
||||
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
|
||||
async def sag_rediger(request: Request, sag_id: int):
|
||||
"""Display edit case form."""
|
||||
|
||||
@ -305,10 +305,6 @@
|
||||
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
|
||||
<select class="form-select form-select-lg" id="type" required></select>
|
||||
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
|
||||
<button type="button" id="caseMessageTypeButton" class="btn btn-outline-primary cc-message-type cc-message-fallback" onclick="window.caseCreateUI?.openMessage?.() || window.openInternalMessage?.()">
|
||||
<span class="cc-type-name"><i class="bi bi-chat-square-text"></i>Besked til kollega</span>
|
||||
<small class="cc-type-hint">Kort besked eller telefonbesked</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Section: Relations -->
|
||||
@ -344,6 +340,27 @@
|
||||
</div>
|
||||
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
||||
<select class="form-select" id="ansvarlig_bruger_id">
|
||||
<option value="">Ingen</option>
|
||||
{% for user in assignment_users or [] %}
|
||||
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
||||
<select class="form-select" id="assigned_group_id">
|
||||
<option value="">Ingen</option>
|
||||
{% for group in assignment_groups or [] %}
|
||||
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
<!-- Section: Basic Info -->
|
||||
@ -363,7 +380,7 @@
|
||||
<i class="bi bi-magic me-1"></i>AI renskriv
|
||||
</button>
|
||||
</div>
|
||||
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
|
||||
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..." required></textarea>
|
||||
<div class="d-flex justify-content-between form-text">
|
||||
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
|
||||
<span id="charCount">0 tegn</span>
|
||||
@ -376,6 +393,8 @@
|
||||
<!-- Fields that belong to the selected case type are rendered here. -->
|
||||
<section id="typeDetailsSection" class="d-none"><div id="typeDetailsFields"></div></section>
|
||||
|
||||
<div id="typeModuleAction" class="d-flex flex-wrap gap-2 mt-4 mb-4 d-none" aria-label="Tilføj moduler"></div>
|
||||
|
||||
<!-- Section: Hardware & AnyDesk -->
|
||||
<section id="hardwareSection">
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
||||
@ -437,8 +456,10 @@
|
||||
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
<!-- Section: Metadata -->
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Ansvarlig</h5>
|
||||
<!-- Optional metadata stays available without competing with the core flow. -->
|
||||
<details id="advancedCaseFields" class="mt-4">
|
||||
<summary class="small text-muted fw-semibold">Flere valg: ansvarlig, status og deadline</summary>
|
||||
<div class="pt-3">
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4 case-type-extra">
|
||||
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
||||
@ -448,25 +469,6 @@
|
||||
<option value="lukket">🔴 Lukket</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
|
||||
<select class="form-select" id="ansvarlig_bruger_id">
|
||||
<option value="">Ingen</option>
|
||||
{% for user in assignment_users or [] %}
|
||||
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 case-type-extra">
|
||||
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
|
||||
<select class="form-select" id="assigned_group_id">
|
||||
<option value="">Ingen</option>
|
||||
{% for group in assignment_groups or [] %}
|
||||
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4 case-type-extra">
|
||||
@ -478,6 +480,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex justify-content-end gap-3 mt-5">
|
||||
@ -1421,7 +1425,7 @@
|
||||
const profile = profileRes.ok ? await profileRes.json() : {};
|
||||
const configured = JSON.parse(setting.value || '[]');
|
||||
const configuredTypes = Array.isArray(configured) ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : [];
|
||||
const supportedTypes = ['ticket', 'salg', 'bogholderi', 'admin', 'projekt', 'postit', 'intern_ticket', 'bug_report'];
|
||||
const supportedTypes = ['ticket', 'postit', 'salg', 'bogholderi', 'admin', 'projekt', 'intern_ticket', 'bug_report'];
|
||||
const finalTypes = supportedTypes;
|
||||
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
|
||||
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
|
||||
@ -1457,7 +1461,7 @@
|
||||
// Both the selected type and current user are now known. Reapply the
|
||||
// type defaults so Intern ticket cannot display a stale assignee.
|
||||
updateCaseTypeSections(false);
|
||||
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(true));
|
||||
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(false));
|
||||
await applyTelefoniPrefill();
|
||||
await window.caseCreateUI?.init();
|
||||
});
|
||||
@ -1573,7 +1577,7 @@
|
||||
|
||||
successDiv.classList.remove('d-none');
|
||||
document.getElementById('success-text').textContent = "Sag oprettet";
|
||||
window.location.href = `/sag/${result.id}/v3`;
|
||||
window.location.href = `/sag/${result.id}/v4`;
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
let errMsg = "Kunne ikke oprette sag";
|
||||
@ -1593,5 +1597,5 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/case-create.js?v=6"></script>
|
||||
<script src="/static/js/case-create.js?v=13"></script>
|
||||
{% endblock %}
|
||||
|
||||
@ -3696,6 +3696,62 @@
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.case-topbar-upload-btn {
|
||||
display: inline-grid;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
place-items: center;
|
||||
border: 1px dashed rgba(255,255,255,0.65);
|
||||
border-radius: 0.65rem;
|
||||
background: rgba(255,255,255,0.12);
|
||||
color: #fff !important;
|
||||
font-size: 1.28rem;
|
||||
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.case-topbar-upload-btn:hover,
|
||||
.case-topbar-upload-btn:focus-visible,
|
||||
.case-topbar-upload-btn.is-drag-over {
|
||||
border-color: #fff;
|
||||
background: rgba(255,255,255,0.28);
|
||||
color: #fff !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.case-file-row {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.case-file-thumbnail {
|
||||
width: 78px;
|
||||
height: 58px;
|
||||
flex: 0 0 78px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--bs-tertiary-bg);
|
||||
color: var(--bs-secondary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.case-file-thumbnail:hover,
|
||||
.case-file-thumbnail:focus-visible {
|
||||
border-color: var(--bs-primary);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.case-file-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.case-file-thumbnail .bi {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
#sag-titel-text {
|
||||
font-size: clamp(1.9rem, 2.7vw, 2.45rem);
|
||||
line-height: 1.15;
|
||||
@ -3835,6 +3891,55 @@
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.case-contact-widget .contact-widget-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
margin-bottom: 0.42rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.case-contact-widget .contact-widget-row:last-child { margin-bottom: 0; }
|
||||
.case-contact-widget .contact-widget-label {
|
||||
flex: 0 0 32%;
|
||||
color: #6c757d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.case-contact-widget .contact-widget-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 0.45rem;
|
||||
background: #fff;
|
||||
}
|
||||
.case-contact-widget .contact-widget-value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #212529;
|
||||
font-weight: 500;
|
||||
}
|
||||
.case-contact-widget .contact-widget-actions { display: inline-flex; gap: 0.18rem; }
|
||||
.case-contact-widget .contact-widget-actions .btn {
|
||||
--bs-btn-padding-x: 0.24rem;
|
||||
--bs-btn-padding-y: 0.06rem;
|
||||
border-color: transparent;
|
||||
}
|
||||
.case-contact-widget .contact-widget-edit {
|
||||
--bs-btn-padding-x: 0.24rem;
|
||||
--bs-btn-padding-y: 0.06rem;
|
||||
color: #6c757d;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.header-top-row {
|
||||
margin-top: 0.42rem;
|
||||
}
|
||||
@ -4171,22 +4276,15 @@
|
||||
<!-- HEADER BG: Clean Title & Actions WITHOUT Selectors -->
|
||||
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -0.3rem;">
|
||||
<div class="container-fluid px-3 px-xl-4 position-relative">
|
||||
{% set tkey = (case.template_key or case.type or 'ticket')|lower %}
|
||||
{% set type_icons = {'ticket': 'bi-ticket-perforated', 'salg': 'bi-briefcase', 'bogholderi': 'bi-receipt', 'admin': 'bi-gear', 'projekt': 'bi-folder2-open', 'postit': 'bi-sticky', 'intern_ticket': 'bi-shield-lock', 'bug_report': 'bi-bug'} %}
|
||||
{% set type_labels = {'ticket': 'Ticket', 'salg': 'Salg', 'bogholderi': 'Bogholderi', 'admin': 'Admin', 'projekt': 'Projekt', 'postit': 'Post-it', 'intern_ticket': 'Intern ticket', 'bug_report': 'Bug_report'} %}
|
||||
{% set type_colors = {'ticket': '#6366f1', 'salg': '#10b981', 'bogholderi': '#0891b2', 'admin': '#64748b', 'projekt': '#8b5cf6', 'postit': '#f59e0b', 'intern_ticket': '#334155', 'bug_report': '#dc3545'} %}
|
||||
{% set tcolor = type_colors.get(tkey, '#0f4c75') %}
|
||||
{% set ticon = type_icons.get(tkey, 'bi-card-text') %}
|
||||
{% set tlabel = type_labels.get(tkey, tkey|capitalize) %}
|
||||
<div class="case-header-main">
|
||||
<div class="case-header-copy">
|
||||
<div class="case-header-identity">
|
||||
<span class="case-header-id">#{{ case.id }}</span>
|
||||
<span class="case-header-status {{ 'is-closed' if (case.status or '')|lower in ['lukket', 'løst', 'closed', 'resolved'] else '' }}" id="caseStatusBadge"><i class="bi bi-circle-fill"></i>{{ case.status|capitalize if case.status else 'Åben' }}</span>
|
||||
<span class="case-header-type" id="caseTypeDropdownBtn"><i class="bi {{ ticon }}" style="color:{{ tcolor }};" id="caseTypeIcon"></i><span id="caseTypeLabel">{{ tlabel }}</span></span>
|
||||
</div>
|
||||
<div id="sag-titel-view" class="d-flex align-items-center gap-2">
|
||||
<h1 id="sag-titel-text" class="fw-bolder mb-0 text-white">{{ case.titel }}</h1>
|
||||
<input type="file" id="topbarAttachmentInput" multiple style="display:none" onchange="handleTopbarAttachmentFiles(this.files); this.value='';">
|
||||
<button id="caseTopbarUploadBtn" class="btn case-topbar-upload-btn p-0" type="button" onclick="document.getElementById('topbarAttachmentInput').click()" title="Upload fil eller slip .eml/.msg her" aria-label="Upload fil eller slip e-mail her"><i class="bi bi-paperclip"></i></button>
|
||||
<button class="btn btn-sm btn-link text-white-50 p-0" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
|
||||
</div>
|
||||
<div id="sag-titel-editor" class="d-none">
|
||||
@ -4194,17 +4292,6 @@
|
||||
<div class="d-flex gap-2 mt-2"><button class="btn btn-sm btn-primary" onclick="saveTitelEdit()">Gem</button><button class="btn btn-sm btn-secondary" onclick="cancelTitelEdit()">Annuller</button></div>
|
||||
</div>
|
||||
<p class="text-white-50 mb-0 case-header-meta-line">
|
||||
<span><i class="bi bi-building me-1"></i><a href="#" onclick="event.preventDefault(); showCustomerSearch('replace')" class="text-white text-decoration-none" title="Skift virksomhed">{{ customer.name if customer else 'Vælg virksomhed' }} <i class="bi bi-pencil-fill small"></i></a></span>
|
||||
{% if customer and customer.is_vendor %}
|
||||
<span class="badge rounded-pill bg-warning text-dark ms-1" title="Denne kunde er også registreret som leverandør{% if customer.vendor_name %}: {{ customer.vendor_name }}{% endif %}">
|
||||
<i class="bi bi-truck me-1"></i>Leverandør
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="meta-divider">•</span>
|
||||
<span><i class="bi bi-person me-1"></i><a href="#" onclick="event.preventDefault(); showContactSearch('replace')" class="text-white text-decoration-none" title="Skift hovedkontakt">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Vælg kontakt' }} <i class="bi bi-pencil-fill small"></i></a></span>
|
||||
<span class="meta-divider">•</span>
|
||||
<span><i class="bi bi-diagram-3 me-1"></i><a href="#" onclick="event.preventDefault(); showAfdelingModal()" class="text-white text-decoration-none" title="Ændre afdeling">{{ customer.department if customer and customer.department else 'Ingen afdeling' }}</a></span>
|
||||
<span class="meta-divider">•</span>
|
||||
<span><i class="bi bi-plus-circle me-1"></i>Oprettet {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '-' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
@ -4378,19 +4465,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Widget 4: Tidsregistrering og kompakte genveje -->
|
||||
<!-- Widget 4: Kunde og hovedkontakt -->
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-3" style="background:#f8fafc;">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-3 case-contact-widget" style="background:#f8fafc;">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="text-uppercase fw-bold text-muted" style="font-size:0.7rem; letter-spacing:0.45px;"><i class="bi bi-clock-history text-primary me-1"></i> Tidsregistrering</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border shadow-sm" title="AnyDesk Quick Connect" aria-label="AnyDesk Quick Connect"><i class="bi bi-display text-primary"></i></button>
|
||||
<button type="button" id="topbarPrintWorkOrderBtn" class="btn btn-sm btn-white bg-white border shadow-sm" title="Print arbejdsseddel" aria-label="Print arbejdsseddel"><i class="bi bi-printer text-primary"></i></button>
|
||||
<div class="text-uppercase fw-bold text-muted mb-2" style="font-size:0.7rem; letter-spacing:0.45px;">
|
||||
<i class="bi bi-person-vcard-fill text-primary me-1"></i> Kontakt
|
||||
</div>
|
||||
<div class="contact-widget-row">
|
||||
<span class="contact-widget-label">Firma</span>
|
||||
<div class="contact-widget-field">
|
||||
<a class="contact-widget-value text-decoration-none" href="#" onclick="event.preventDefault(); showCustomerSearch('replace')" title="Skift virksomhed">{{ customer.name if customer else 'Vælg virksomhed' }}</a>
|
||||
<button type="button" class="btn btn-sm contact-widget-edit" onclick="showCustomerSearch('replace')" title="Skift virksomhed" aria-label="Skift virksomhed"><i class="bi bi-pencil"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-widget-row">
|
||||
<span class="contact-widget-label">Hovedkontakt</span>
|
||||
<div class="contact-widget-field">
|
||||
<a class="contact-widget-value text-decoration-none" href="#" onclick="event.preventDefault(); showContactSearch('replace')" title="Skift hovedkontakt">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Vælg kontakt' }}</a>
|
||||
{% if hovedkontakt %}
|
||||
<span class="contact-widget-actions">
|
||||
{% if hovedkontakt.email %}<a class="btn btn-sm btn-outline-primary" href="mailto:{{ hovedkontakt.email }}" title="Send e-mail" aria-label="Send e-mail"><i class="bi bi-envelope"></i></a>{% endif %}
|
||||
{% set contact_number = hovedkontakt.mobile or hovedkontakt.phone %}
|
||||
{% if contact_number %}<a class="btn btn-sm btn-outline-primary" href="tel:{{ contact_number }}" title="Start VoIP-kald" aria-label="Start VoIP-kald"><i class="bi bi-telephone"></i></a>{% endif %}
|
||||
{% if hovedkontakt.mobile %}<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt({{ hovedkontakt.mobile|tojson }}, {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|tojson }}, {{ hovedkontakt.id|default('null') }})" title="Send SMS" aria-label="Send SMS"><i class="bi bi-chat-dots"></i></button>{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-sm contact-widget-edit" onclick="showContactSearch('replace')" title="Skift hovedkontakt" aria-label="Skift hovedkontakt"><i class="bi bi-pencil"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-widget-row">
|
||||
<span class="contact-widget-label">Afdeling</span>
|
||||
<div class="contact-widget-field">
|
||||
<a class="contact-widget-value text-decoration-none" href="#" onclick="event.preventDefault(); showAfdelingModal()" title="Ændr afdeling">{{ customer.department if customer and customer.department else 'Ingen afdeling' }}</a>
|
||||
<button type="button" class="btn btn-sm contact-widget-edit" onclick="showAfdelingModal()" title="Ændr afdeling" aria-label="Ændr afdeling"><i class="bi bi-pencil"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary w-100" onclick="showAddTimeModal()"><i class="bi bi-plus-lg me-1"></i>Registrér tid</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary w-100 mt-1" onclick="activateCaseTabFromButton(event, 'timetracking')">Se tidsforbrug</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -5354,6 +5464,9 @@
|
||||
<span id="previewFileName">Fil preview</span>
|
||||
</h5>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span id="previewFilePosition" class="small text-muted d-none" aria-live="polite"></span>
|
||||
<button id="previewPreviousFileBtn" type="button" class="btn btn-sm btn-outline-secondary" onclick="previewAdjacentFile(-1)" title="Forrige fil (venstre piletast)" aria-label="Forrige fil"><i class="bi bi-chevron-left"></i></button>
|
||||
<button id="previewNextFileBtn" type="button" class="btn btn-sm btn-outline-secondary" onclick="previewAdjacentFile(1)" title="Næste fil (højre piletast)" aria-label="Næste fil"><i class="bi bi-chevron-right"></i></button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
</div>
|
||||
@ -5373,11 +5486,11 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Søg kontakt</h5>
|
||||
<h5 class="modal-title" id="contactSearchModalTitle">Søg kontakt</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" id="contactSearch" placeholder="Søg efter kontakt..." class="form-control mb-3">
|
||||
<input type="text" id="contactSearch" placeholder="Søg efter anden kontakt..." class="form-control mb-3">
|
||||
<div id="contactSearchResults" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -6160,12 +6273,15 @@
|
||||
});
|
||||
|
||||
// Show modal functions
|
||||
function showContactSearch(mode = 'link') {
|
||||
async function showContactSearch(mode = 'link') {
|
||||
contactSearchMode = mode === 'replace' ? 'replace' : 'link';
|
||||
const title = document.getElementById('contactSearchModalTitle');
|
||||
if (title) title.textContent = contactSearchMode === 'replace' ? 'Skift hovedkontakt på sag' : 'Søg kontakt';
|
||||
contactSearchModal.show();
|
||||
setTimeout(() => document.getElementById('contactSearch').focus(), 300);
|
||||
const input = document.getElementById('contactSearch');
|
||||
if (input) input.value = '';
|
||||
await loadCustomerContactsForContactSearch();
|
||||
setTimeout(() => input?.focus(), 300);
|
||||
}
|
||||
|
||||
function showCustomerSearch(mode = 'link') {
|
||||
@ -6954,6 +7070,58 @@
|
||||
}
|
||||
|
||||
// Contact Search
|
||||
function contactSearchDisplayName(contact) {
|
||||
const firstName = String(contact?.first_name || '').trim();
|
||||
const lastName = String(contact?.last_name || '').trim();
|
||||
return [firstName, lastName].filter(Boolean).join(' ') || `Kontakt #${contact?.id}`;
|
||||
}
|
||||
|
||||
function renderContactSearchResults(contacts, sectionTitle = '') {
|
||||
const resultsDiv = document.getElementById('contactSearchResults');
|
||||
if (!resultsDiv) return;
|
||||
if (!Array.isArray(contacts) || contacts.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = sectionTitle
|
||||
? `<div class="small text-uppercase fw-bold text-muted px-2 pt-1 pb-2">${escapeHtml(sectionTitle)}</div>`
|
||||
: '';
|
||||
resultsDiv.innerHTML = heading + contacts.map((contact) => {
|
||||
const name = contactSearchDisplayName(contact);
|
||||
const email = String(contact?.email || '').trim();
|
||||
const mobile = String(contact?.mobile || '').trim();
|
||||
const phone = String(contact?.phone || '').trim();
|
||||
const role = String(contact?.role || '').trim();
|
||||
const detail = [email, mobile ? `Mobil: ${mobile}` : '', phone ? `Telefon: ${phone}` : ''].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<button type="button" class="list-group-item list-group-item-action text-start" onclick="selectContactFromSearch(${caseId}, ${Number(contact.id)})">
|
||||
<div class="fw-semibold">${escapeHtml(name)}</div>
|
||||
<div class="small text-muted">${detail ? escapeHtml(detail) : 'Ingen kontaktoplysninger'}${role ? ` · ${escapeHtml(role)}` : ''}</div>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadCustomerContactsForContactSearch() {
|
||||
const customerId = caseCustomerId || wikiCustomerId;
|
||||
const resultsDiv = document.getElementById('contactSearchResults');
|
||||
if (!resultsDiv) return;
|
||||
if (!customerId) {
|
||||
resultsDiv.innerHTML = '<div class="p-3 text-muted">Sagen har ingen virksomhed. Søg nedenfor for at vælge en anden kontakt.</div>';
|
||||
return;
|
||||
}
|
||||
resultsDiv.innerHTML = '<div class="p-3 text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter firmaets kontakter...</div>';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
renderContactSearchResults(await response.json(), 'Firmaets kontakter');
|
||||
} catch (error) {
|
||||
console.error('Could not load customer contacts:', error);
|
||||
resultsDiv.innerHTML = '<div class="p-3 text-muted">Kunne ikke hente firmaets kontakter. Du kan stadig søge efter en anden kontakt.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function setupContactSearch() {
|
||||
const contactSearchInput = document.getElementById('contactSearch');
|
||||
contactSearchInput.addEventListener('input', function(e) {
|
||||
@ -6961,7 +7129,7 @@
|
||||
const query = e.target.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
document.getElementById('contactSearchResults').innerHTML = '';
|
||||
loadCustomerContactsForContactSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -6970,42 +7138,7 @@
|
||||
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(query)}`);
|
||||
const contacts = await response.json();
|
||||
|
||||
const resultsDiv = document.getElementById('contactSearchResults');
|
||||
if (contacts.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
|
||||
} else {
|
||||
resultsDiv.innerHTML = contacts.map(c => `
|
||||
${(() => {
|
||||
const clean = (v) => {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = String(v).trim();
|
||||
if (!s || s.toLowerCase() === 'null' || s.toLowerCase() === 'none') return '';
|
||||
return s;
|
||||
};
|
||||
const firstName = clean(c.first_name);
|
||||
const lastName = clean(c.last_name);
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ') || `Kontakt #${c.id}`;
|
||||
const email = clean(c.email);
|
||||
const company = clean(c.user_company);
|
||||
const phones = Array.from(new Set([
|
||||
clean(c.mobile) ? `Mobil: ${clean(c.mobile)}` : '',
|
||||
clean(c.phone) ? `Telefon: ${clean(c.phone)}` : '',
|
||||
clean(c.user_company_phone) ? `Firma: ${clean(c.user_company_phone)}` : ''
|
||||
].filter(Boolean)));
|
||||
|
||||
return `
|
||||
<div class="list-group-item list-group-item-action" style="cursor: pointer;"
|
||||
onclick="selectContactFromSearch(${caseId}, ${c.id}, '${fullName.replace(/'/g, "\\'")}')">
|
||||
<strong>${fullName}</strong>
|
||||
<div class="small text-muted">${[email, company ? '(' + company + ')' : ''].filter(Boolean).join(' ')}</div>
|
||||
<div class="small mt-1">
|
||||
${phones.join(' · ') || '<span class="text-muted">Intet telefonnummer</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})()}
|
||||
`).join('');
|
||||
}
|
||||
renderContactSearchResults(contacts, 'Andre kontakter');
|
||||
} catch (err) {
|
||||
console.error('Error searching contacts:', err);
|
||||
}
|
||||
@ -7013,15 +7146,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function selectContactFromSearch(caseId, contactId, contactName) {
|
||||
async function selectContactFromSearch(caseId, contactId) {
|
||||
if (contactSearchMode === 'replace') {
|
||||
await replaceCasePrimaryContact(caseId, contactId, contactName);
|
||||
await replaceCasePrimaryContact(caseId, contactId);
|
||||
return;
|
||||
}
|
||||
await addContact(caseId, contactId, contactName);
|
||||
await addContact(caseId, contactId);
|
||||
}
|
||||
|
||||
async function replaceCasePrimaryContact(caseId, contactId, contactName) {
|
||||
async function replaceCasePrimaryContact(caseId, contactId) {
|
||||
try {
|
||||
let response = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, {
|
||||
method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({is_primary: true})
|
||||
@ -7042,7 +7175,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function addContact(caseId, contactId, contactName) {
|
||||
async function addContact(caseId, contactId) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/sag/${caseId}/contacts`, {
|
||||
method: 'POST',
|
||||
@ -18238,6 +18371,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Buzzwords are only useful when the case has actual values.
|
||||
// A user's preference may hide the populated module, but must
|
||||
// never force an empty Buzzwords card into the layout.
|
||||
if (isBuzzwordsModule && !hasContent) {
|
||||
setVisibility(false);
|
||||
el.classList.remove('module-empty-compact');
|
||||
return;
|
||||
}
|
||||
|
||||
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
|
||||
if (pref === false) {
|
||||
setVisibility(false);
|
||||
@ -18581,14 +18723,25 @@
|
||||
const displayName = escapeHtml(isWorkOrder ? `Arbejdsseddel: ${f.filename}` : f.filename);
|
||||
const fileId = Number(f.id);
|
||||
const downloadUrl = escapeHtml(String(f.download_url || ''));
|
||||
const extension = String(f.filename || '').split('.').pop().toLowerCase();
|
||||
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp'].includes(extension);
|
||||
const thumbnailUrl = extension === 'pdf'
|
||||
? `/api/v1/sag/${caseIds}/files/${fileId}/preview-image?page=1&scale=1`
|
||||
: downloadUrl;
|
||||
const thumbnail = isImage || extension === 'pdf'
|
||||
? `<img src="${thumbnailUrl}" alt="${displayName}" loading="lazy">`
|
||||
: `<i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'}" aria-hidden="true"></i>`;
|
||||
const badgeHtml = isWorkOrder
|
||||
? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>'
|
||||
: (isScannerFile
|
||||
? '<span class="badge rounded-pill text-bg-info ms-2"><i class="bi bi-upc-scan me-1"></i>Scanner</span>'
|
||||
: '');
|
||||
return `
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="list-group-item d-flex align-items-center case-file-row">
|
||||
<button type="button" class="case-file-thumbnail p-0" onclick="previewFileById(${fileId})" title="Vis ${displayName}" aria-label="Vis ${displayName}">
|
||||
${thumbnail}
|
||||
</button>
|
||||
<div class="me-auto min-w-0">
|
||||
<div class="fw-bold text-truncate d-flex align-items-center" style="max-width: 380px;">
|
||||
<a href="javascript:void(0);" onclick="previewFileById(${fileId})" class="text-decoration-none text-dark">
|
||||
<i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'} me-1"></i> ${displayName}
|
||||
@ -18598,6 +18751,9 @@
|
||||
<small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-primary border-0" type="button" onclick="previewFileById(${fileId})" title="Vis fil" aria-label="Vis fil">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<a href="${downloadUrl}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
@ -18623,17 +18779,42 @@
|
||||
try {
|
||||
const res = await fetch(`/api/v1/sag/${caseIds}/files`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
if(res.ok) {
|
||||
loadSagFiles();
|
||||
const saved = await res.json().catch(() => []);
|
||||
await loadSagFiles();
|
||||
const count = Array.isArray(saved) ? saved.length : fileList.length;
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`${count} fil${count === 1 ? '' : 'er'} uploadet`, 'success');
|
||||
}
|
||||
} else {
|
||||
showCaseFeedback('Upload fejlede');
|
||||
loadSagFiles(); // Reload to clear loading state
|
||||
const error = await res.json().catch(() => ({}));
|
||||
const detail = typeof error.detail === 'string'
|
||||
? error.detail
|
||||
: (error.detail?.message || 'Upload fejlede');
|
||||
showCaseFeedback(detail);
|
||||
await loadSagFiles(); // Reload to clear loading state
|
||||
}
|
||||
} catch(e) {
|
||||
showCaseFeedback('Upload fejl: ' + e);
|
||||
loadSagFiles();
|
||||
showCaseFeedback('Upload fejl: ' + (e?.message || e));
|
||||
await loadSagFiles();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTopbarAttachmentFiles(fileList) {
|
||||
const files = Array.from(fileList || []);
|
||||
if (!files.length) return;
|
||||
|
||||
const emailFiles = files.filter((file) => /\.(eml|msg)$/i.test(String(file.name || '')));
|
||||
const regularFiles = files.filter((file) => !emailFiles.includes(file));
|
||||
|
||||
if (regularFiles.length) {
|
||||
await handleFileUpload(regularFiles);
|
||||
}
|
||||
for (const emailFile of emailFiles) {
|
||||
await uploadEmailFile(emailFile);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18692,6 +18873,30 @@
|
||||
}
|
||||
|
||||
// File Preview
|
||||
let activePreviewFileId = null;
|
||||
|
||||
function updateFilePreviewNavigation(fileId) {
|
||||
const currentIndex = sagFilesCache.findIndex((item) => Number(item.id) === Number(fileId));
|
||||
const previousButton = document.getElementById('previewPreviousFileBtn');
|
||||
const nextButton = document.getElementById('previewNextFileBtn');
|
||||
const position = document.getElementById('previewFilePosition');
|
||||
const hasFiles = currentIndex >= 0 && sagFilesCache.length > 0;
|
||||
|
||||
if (previousButton) previousButton.disabled = !hasFiles || currentIndex === 0;
|
||||
if (nextButton) nextButton.disabled = !hasFiles || currentIndex === sagFilesCache.length - 1;
|
||||
if (position) {
|
||||
position.classList.toggle('d-none', !hasFiles || sagFilesCache.length < 2);
|
||||
position.textContent = hasFiles ? `${currentIndex + 1} / ${sagFilesCache.length}` : '';
|
||||
}
|
||||
}
|
||||
|
||||
function previewAdjacentFile(offset) {
|
||||
const currentIndex = sagFilesCache.findIndex((item) => Number(item.id) === Number(activePreviewFileId));
|
||||
const nextFile = sagFilesCache[currentIndex + Number(offset)];
|
||||
if (!nextFile) return;
|
||||
previewFile(Number(nextFile.id), String(nextFile.filename || ''), String(nextFile.content_type || ''));
|
||||
}
|
||||
|
||||
function previewFileById(fileId) {
|
||||
const file = sagFilesCache.find((item) => Number(item.id) === Number(fileId));
|
||||
if (!file) {
|
||||
@ -18702,12 +18907,15 @@
|
||||
}
|
||||
|
||||
function previewFile(fileId, filename, contentType) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal'));
|
||||
const modalEl = document.getElementById('filePreviewModal');
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
const previewContent = document.getElementById('previewContent');
|
||||
const fileNameEl = document.getElementById('previewFileName');
|
||||
|
||||
activePreviewFileId = Number(fileId);
|
||||
|
||||
// Set filename
|
||||
fileNameEl.textContent = filename;
|
||||
updateFilePreviewNavigation(activePreviewFileId);
|
||||
const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`;
|
||||
|
||||
// Show loading spinner
|
||||
@ -18777,6 +18985,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const modal = document.getElementById('filePreviewModal');
|
||||
if (!modal?.classList.contains('show')) return;
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
previewAdjacentFile(-1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
previewAdjacentFile(1);
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
@ -19806,6 +20026,9 @@
|
||||
const sender = email.sender_name || email.sender_email || '-';
|
||||
const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
|
||||
const attachments = Array.isArray(email.attachments) ? email.attachments : [];
|
||||
const declaredAttachmentCount = Number(email.attachment_count || 0);
|
||||
const attachmentsMissing = attachments.length === 0
|
||||
&& (Boolean(email.has_attachments) || declaredAttachmentCount > 0);
|
||||
const bodyText = email.body_text || '';
|
||||
const bodyHtml = sanitizeCaseEmailHtml(email.body_html || '');
|
||||
selectedLinkedEmailDetail = email;
|
||||
@ -19840,6 +20063,15 @@
|
||||
<div class="p-3 border-bottom">
|
||||
<div class="small fw-semibold mb-2">Vedhæftninger (${attachments.length})</div>
|
||||
<div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div>
|
||||
${attachmentsMissing ? `
|
||||
<div class="mt-2 small text-warning-emphasis">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Denne e-mail er registreret med ${declaredAttachmentCount || 'en'} vedhæftning${declaredAttachmentCount === 1 ? '' : 'er'}, men filen blev ikke hentet ved import.
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-2" onclick="recoverLinkedEmailAttachments(${email.id})">
|
||||
<i class="bi bi-cloud-download me-1"></i>Hent vedhæftninger
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;">
|
||||
${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml || '<div class="text-muted">Ingen indhold</div>')}
|
||||
@ -19849,7 +20081,9 @@
|
||||
const attachmentContainer = document.getElementById('email-attachments-list');
|
||||
if (attachmentContainer) {
|
||||
if (!attachments.length) {
|
||||
attachmentContainer.innerHTML = '<span class="text-muted small">Ingen vedhæftninger</span>';
|
||||
attachmentContainer.innerHTML = attachmentsMissing
|
||||
? '<span class="text-muted small">Ingen filer er gemt lokalt endnu</span>'
|
||||
: '<span class="text-muted small">Ingen vedhæftninger</span>';
|
||||
} else {
|
||||
attachmentContainer.innerHTML = attachments.map(att => {
|
||||
const attachmentName = att.filename || `Vedhæftning ${att.id}`;
|
||||
@ -19883,6 +20117,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverLinkedEmailAttachments(emailId) {
|
||||
const panel = document.getElementById('email-preview-panel');
|
||||
if (!panel) return;
|
||||
|
||||
const recoverButton = panel.querySelector('button[onclick^="recoverLinkedEmailAttachments"]');
|
||||
if (recoverButton) {
|
||||
recoverButton.disabled = true;
|
||||
recoverButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Henter...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/emails/${emailId}/recover-attachments`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(payload?.detail || `Kunne ikke hente vedhæftninger (${res.status})`);
|
||||
}
|
||||
|
||||
showCaseFeedback(`${payload.attachments_saved || payload.attachments_recovered || 0} vedhæftning(er) hentet fra e-mailen.`);
|
||||
await loadLinkedEmailDetail(emailId, true);
|
||||
if (typeof loadSagFiles === 'function') await loadSagFiles();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showCaseFeedback(error?.message || 'Kunne ikke hente vedhæftninger.');
|
||||
if (recoverButton) {
|
||||
recoverButton.disabled = false;
|
||||
recoverButton.innerHTML = '<i class="bi bi-cloud-download me-1"></i>Hent vedhæftninger';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLinkedEmailReadState() {
|
||||
if (!selectedLinkedEmailDetail || !selectedLinkedEmailId) return;
|
||||
const targetState = !Boolean(selectedLinkedEmailDetail.is_read);
|
||||
@ -20057,7 +20324,16 @@
|
||||
body: formData
|
||||
});
|
||||
if(res.ok) {
|
||||
loadLinkedEmails();
|
||||
const result = await res.json().catch(() => ({}));
|
||||
await Promise.all([loadLinkedEmails(), loadSagFiles()]);
|
||||
openCaseEmailTab();
|
||||
const attachmentCount = Number(result.attachment_files_created || 0);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(
|
||||
attachmentCount ? `E-mail importeret med ${attachmentCount} vedhæftet fil${attachmentCount === 1 ? '' : 'er'}` : 'E-mail importeret',
|
||||
'success'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showCaseFeedback('Import fejlede');
|
||||
}
|
||||
@ -20067,6 +20343,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
(function bindTopbarAttachmentDropzone() {
|
||||
const button = document.getElementById('caseTopbarUploadBtn');
|
||||
if (!button) return;
|
||||
['dragenter', 'dragover'].forEach((eventName) => {
|
||||
button.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
button.classList.add('is-drag-over');
|
||||
});
|
||||
});
|
||||
['dragleave', 'dragend'].forEach((eventName) => {
|
||||
button.addEventListener(eventName, () => button.classList.remove('is-drag-over'));
|
||||
});
|
||||
button.addEventListener('drop', async (event) => {
|
||||
event.preventDefault();
|
||||
button.classList.remove('is-drag-over');
|
||||
await handleTopbarAttachmentFiles(event.dataTransfer?.files);
|
||||
});
|
||||
})();
|
||||
|
||||
// Load content on start
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const caseEmailSendBtn = document.getElementById('caseEmailSendBtn');
|
||||
|
||||
@ -148,7 +148,7 @@ class TelefoniService:
|
||||
pending_rows = execute_query(
|
||||
"""
|
||||
SELECT * FROM telefoni_opkald
|
||||
WHERE callid LIKE 'click-to-call:%'
|
||||
WHERE callid LIKE 'click-to-call:%%'
|
||||
AND direction = 'outbound'
|
||||
AND bruger_id = %s
|
||||
AND ended_at IS NULL
|
||||
|
||||
@ -2325,6 +2325,7 @@ if (bmcOriginalFetch) {
|
||||
});
|
||||
|
||||
function openNewCasePage() {
|
||||
if (window.location.pathname === '/sag/new' && window.caseCreateUI?.nextType?.()) return;
|
||||
window.location.href = '/sag/new';
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men
|
||||
- [Vareintegration mellem BMC Hub og e-conomic](vareintegration-economic.md) — permanent varekobling, produktsync, oprettelse, prisberegning og sikker dokumenteksport.
|
||||
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
|
||||
- [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol.
|
||||
- [Kuando Busylight og central Presence](kuando-busylight-integration.md) — fælles presence, direkte intern KuandoHUB-styring og senere notifikationsalarmer.
|
||||
- [Sag: topbar-filupload og personlig Buzzwords-visning](sag-filupload-og-buzzwords.md) — upload fra papirclips i topbaren, skjult tomt filmodul og personlig Buzzwords-visning.
|
||||
|
||||
## Arbejdsgang
|
||||
|
||||
|
||||
99
fremtidige planer/kuando-busylight-integration.md
Normal file
99
fremtidige planer/kuando-busylight-integration.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Kuando Busylight og central Presence i BMC Hub
|
||||
|
||||
**Status:** Planlagt
|
||||
|
||||
## Formål
|
||||
|
||||
Kuando Busylight bliver et output fra BMC Hubs fælles Presence Engine. Hubben er eneste status-master; profilprik, medarbejdervisning, Mission Control og Busylight bruger den samme resolved status og farve.
|
||||
|
||||
Hubben kalder KuandoHUB direkte på brugerens registrerede interne IP. Der bruges ingen BMC Device Agent.
|
||||
|
||||
## Arkitektur og afgrænsning
|
||||
|
||||
```text
|
||||
Hændelse eller Hub-session
|
||||
↓
|
||||
Presence Engine
|
||||
┌──────┼───────────────┐
|
||||
↓ ↓ ↓
|
||||
Profil Mission Control Kuando-klient i Hubben
|
||||
↓
|
||||
http://<intern-ip>:8989/
|
||||
↓
|
||||
KuandoHUB
|
||||
↓
|
||||
USB Busylight
|
||||
```
|
||||
|
||||
- Hubben må kun kontakte IPv4-adresser i `172.16.0.0/12`.
|
||||
- KuandoHUB bruger altid HTTP, port `8989` og den faste `/`-endpoint.
|
||||
- Der understøttes én Busylight/KUandoHUB-konfiguration pr. bruger i første version.
|
||||
- Brugeren indtaster selv sin interne IP i Profil → Busylight. IP-adressen er en begrænset destinationsadresse, ikke en login-mekanisme.
|
||||
- Brugeren kan være `Available`, selv om KuandoHUB ikke kan nås. Enhedsstatus og presence er separate tilstande.
|
||||
|
||||
## Presence Engine
|
||||
|
||||
Det globale, administratorstyrede statuskatalog starter med:
|
||||
|
||||
| Status | Farve |
|
||||
| --- | --- |
|
||||
| Available | Grøn |
|
||||
| Working | Gul |
|
||||
| Busy | Rød |
|
||||
| Do not disturb | Lilla |
|
||||
| Lunch | Cyan |
|
||||
| Away | Orange |
|
||||
| Not at work | Pink |
|
||||
| Offline | Grå |
|
||||
|
||||
- Normalstatus er `Available` ved aktiv Hub-session og `Offline`, når sidste aktive session lukker.
|
||||
- En manuel status varer, indtil brugeren selv ændrer den.
|
||||
- Prioritet opløses centralt: critical → phone → meeting → do-not-disturb → working → available.
|
||||
- Når timer, opkald eller en notifikation ophører, genberegnes den gældende status. Der lagres ikke en løs farve, der blot skal genskabes.
|
||||
- Presence udsendes gennem en dedikeret autentificeret WebSocket med bruger, resolved status, farve, kilde og tidspunkt. Eksisterende WebSocket-kontrakter for telefoni, Mission Control og bundbaren bevares.
|
||||
|
||||
## Profil og enhed
|
||||
|
||||
Profil → Busylight indeholder aktivering, intern IP, online-/fejlstatus, seneste kommando og testknapper for grøn, gul, rød, blå, lilla og sluk.
|
||||
|
||||
Kuando-klienten validerer destinationsadressen, følger ikke redirects og bruger korte connect-/response-timeouts. Kommandoresultater og fejl gemmes. UI må kun angive, at KuandoHUB accepterede kommandoen; fysisk lysrespons bekræftes visuelt.
|
||||
|
||||
## Leveringsrækkefølge
|
||||
|
||||
### Fase 1
|
||||
|
||||
- Statuskatalog og central resolved presence.
|
||||
- Profilprik, Profil → Busylight og direkte KuandoHUB-kald.
|
||||
- Manuelle farvetests og sluk.
|
||||
|
||||
### Fase 2
|
||||
|
||||
- Timer-start/-stop kobles til Working/Available.
|
||||
- Telefoni: ringning giver midlertidig notifikation, besvaret samtale giver Busy, og afslutning genskaber den beregnede status.
|
||||
- Mission Control viser samme medarbejderstatus.
|
||||
|
||||
### Fase 3
|
||||
|
||||
- Separat notification-overlay med blink/pulse, varighed og valgfri lyd.
|
||||
- Tilslut ny/tildelt/hastesag, direkte besked, indgående opkald samt Mission Controls drift-warning og drift-critical.
|
||||
- Critical-alarm kan kvitteres i Hubben og vender derefter tilbage til gældende presence.
|
||||
- Profilen får lydvalg, volumen, ingen lyd under samtale/møde og quiet hours.
|
||||
|
||||
## Data og API'er
|
||||
|
||||
- Nye migrationer opretter statuskatalog, brugerpresence/overlejringer, Busylight-enhedsindstillinger, kommandohistorik og alarmkvitteringer.
|
||||
- Autentificerede API'er dækker læs/ændr egen presence og Busylight-indstilling, hent/test egen enhed, administrér globale statuser og notifikationsregler samt kvittér critical-alarm.
|
||||
- Kun administrator må ændre det globale statuskatalog. Brugeren kan kun læse og ændre sin egen enhed og status.
|
||||
|
||||
## Test og accept
|
||||
|
||||
- Enhedstest for prioritet, manuel overstyring, session-baseret online/offline og genberegning efter timer, opkald og notifikation.
|
||||
- API- og autorisationstest for egne enheder, administratoradgang og afvisning af ugyldige, offentlige eller ikke-`172.16/12` IP-adresser.
|
||||
- HTTP-test mod mock KuandoHUB for farver, sluk, timeout og fejl.
|
||||
- WebSocket-test for realtime profil-/Mission Control-opdatering uden regression i eksisterende telefoni og bundbar.
|
||||
- Manuel accept på internt net: visuel bekræftelse af testknapper, timer og opkald; utilgængelig KuandoHUB må kun gøre enheden fejlende.
|
||||
|
||||
## Forudsætninger
|
||||
|
||||
- Hub-serveren har netværksrute til brugernes `172.16.x.x`-adresser, og KuandoHUB lytter på port `8989`.
|
||||
- Implementeringen ændrer ikke eksisterende API-adfærd uden for de nye presence- og Busylight-grænseflader.
|
||||
341
fremtidige planer/sag-filupload-og-buzzwords.md
Normal file
341
fremtidige planer/sag-filupload-og-buzzwords.md
Normal file
@ -0,0 +1,341 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Sag: topbar-filupload og personlig Buzzwords-visning
|
||||
|
||||
**Status:** Under udvikling
|
||||
|
||||
## Formål
|
||||
|
||||
Gør filupload direkte tilgængelig fra sagens topbar, uden at en tom filsektion fylder i sagsvisningen. Giv samtidig hver bruger mulighed for at skjule Buzzwords-modulet i sin egen sagsvisning.
|
||||
|
||||
## Løsning
|
||||
|
||||
- Tilføj et papirclips-ikon i sagens topbar. Ikonet åbner den eksisterende sagsfil-upload gennem en skjult filvælger og uploader filen til den aktuelle sag.
|
||||
- Bevar eksisterende fil-API, tilladelser, filvalidering og lagring; topbar-handlingen er kun en ekstra indgang til samme uploadfunktion.
|
||||
- Efter en vellykket upload opdateres filantal og filmodul uden fuld sidegenindlæsning, og brugeren får tydelig succes- eller fejlbesked.
|
||||
- Vis filmodulet, når sagen har mindst én fil. Skjul det automatisk, når sagen ikke indeholder filer; topbar-papirclipsen er stadig tilgængelig, så den første fil kan uploades.
|
||||
- Tilføj en personlig modulpræference for Buzzwords. Brugeren kan skjule eller vise modulet, og valget gælder kun brugerens egen sagsvisning.
|
||||
- Et tomt Buzzwords-modul skal fortsat være skjult. En gemt brugerpræference må ikke skabe et tomt modul.
|
||||
|
||||
## Data og kompatibilitet
|
||||
|
||||
- Genbrug eksisterende `sag_files` og den nuværende modulpræference-mekanisme; opret kun en migration, hvis Buzzwords mangler en vedvarende, bruger-specifik nøgle i den eksisterende præferencetabel.
|
||||
- Eksisterende filupload og sags-API'er ændrer ikke adfærd. Den nye topbar-handling bruger samme autorisationskontrol som det eksisterende filmodul.
|
||||
|
||||
## Test og accept
|
||||
|
||||
- Upload via papirclips opretter samme filrecord og giver samme resultat som eksisterende upload.
|
||||
- Uautoriseret eller fejlet upload vises som fejl og ændrer ikke filantal eller modulvisning.
|
||||
- Sag uden filer viser ikke filmodulet, men kan modtage sin første fil via topbaren; modulet vises derefter.
|
||||
- Buzzwords kan skjules og vises for én bruger uden at påvirke andre brugere, og er skjult for alle når indholdet er tomt.
|
||||
30
migrations/1042_user_sag_module_prefs.sql
Normal file
30
migrations/1042_user_sag_module_prefs.sql
Normal file
@ -0,0 +1,30 @@
|
||||
-- Migration 1042: Per-user visibility preferences for case modules.
|
||||
-- Existing sag_module_prefs remain case-wide for backwards compatibility.
|
||||
-- New per-user preferences are initially used for the Buzzwords module only.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sag_module_prefs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||
module_key VARCHAR(50) NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, sag_id, module_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sag_module_prefs_user_sag
|
||||
ON user_sag_module_prefs(user_id, sag_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_user_sag_module_prefs_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS update_user_sag_module_prefs_updated_at ON user_sag_module_prefs;
|
||||
CREATE TRIGGER update_user_sag_module_prefs_updated_at
|
||||
BEFORE UPDATE ON user_sag_module_prefs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_user_sag_module_prefs_updated_at();
|
||||
@ -3,7 +3,7 @@
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const state = {tags: [], hardware: [], typeDetails: {}, tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
|
||||
const state = {tags: [], hardware: [], typeDetails: {}, openModules: {}, 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});
|
||||
@ -38,7 +38,7 @@
|
||||
}
|
||||
function snapshot() {
|
||||
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
|
||||
tags: state.tags, hardware: state.hardware, typeDetails: state.typeDetails, orders: collectOrderItems(), prefill: telefoniPrefill,
|
||||
tags: state.tags, hardware: state.hardware, typeDetails: state.typeDetails, openModules: state.openModules, orders: collectOrderItems(), prefill: telefoniPrefill,
|
||||
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
|
||||
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
|
||||
}
|
||||
@ -53,6 +53,7 @@
|
||||
$('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500);
|
||||
}
|
||||
function renderTags() {
|
||||
if (!$('cc-tag-chips')) return;
|
||||
$('cc-tag-chips').innerHTML = state.tags.map(t => `<span class="badge me-1 mb-1" style="background-color:#64748b" data-tag-id="${Number(t.id)}">${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? `<i class="bi ${esc(t.icon)}"></i> ` : ''}${esc(t.name)} <button type="button" class="btn-close btn-close-white" aria-label="Fjern ${esc(t.name)}"></button></span>`).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();
|
||||
@ -83,6 +84,7 @@
|
||||
}
|
||||
}
|
||||
function renderSelectedHardware() {
|
||||
if (!$('cc-hardware-chips')) return;
|
||||
$('cc-hardware-chips').innerHTML = state.hardware.map(h => `<span class="badge bg-secondary me-1">${esc(h.name)} <button type="button" class="btn-close btn-close-white" data-remove-hardware="${Number(h.id)}" aria-label="Fjern hardware"></button></span>`).join('');
|
||||
document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); });
|
||||
summaries();
|
||||
@ -141,6 +143,7 @@
|
||||
}
|
||||
captureTypeDetails();
|
||||
renderTypeDetails(type);
|
||||
$('typeDetailsSection')?.classList.toggle('d-none', !showTypeFields || !(typeFieldSets[type] || []).length);
|
||||
$('createForm').closest('.card-custom')?.classList.toggle('cc-postit-mode', type === 'postit');
|
||||
state.panels['type-details']?.classList.toggle('cc-postit-panel', type === 'postit');
|
||||
document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); });
|
||||
@ -159,9 +162,32 @@
|
||||
opgave: ['relations'],
|
||||
abonnement: ['relations'],
|
||||
}[type] || ['relations'];
|
||||
['relations', 'type-details', 'hardware', 'pipeline', 'order'].forEach(key => state.panels[key]?.classList.toggle('d-none', !showTypeFields || !visiblePanels.includes(key)));
|
||||
const availableModules = visiblePanels.filter(key => key !== 'relations');
|
||||
const moduleAction = $('typeModuleAction');
|
||||
if (showTypeFields) state.openModules[type] = [...availableModules];
|
||||
const openModules = state.openModules[type] || [];
|
||||
if (moduleAction) {
|
||||
const typeDetailsLabels = {
|
||||
ticket: 'Ticket-oplysninger', salg: 'Salgsoplysninger', bogholderi: 'Bogholderioplysninger',
|
||||
admin: 'Adminoplysninger', projekt: 'Projektoplysninger', postit: 'Notat og påmindelse',
|
||||
intern_ticket: 'Intern opgave', bug_report: 'Fejlrapport'
|
||||
};
|
||||
const moduleLabels = {'type-details': typeDetailsLabels[type] || 'Oplysninger til sagstypen', hardware:'Hardware', pipeline:'Pipeline', order:'Indkøb og salg'};
|
||||
const moduleIcons = {'type-details':'bi-card-text', hardware:'bi-pc-display', pipeline:'bi-graph-up-arrow', order:'bi-bag'};
|
||||
moduleAction.classList.toggle('d-none', !availableModules.length);
|
||||
moduleAction.innerHTML = availableModules.map(key => `<button type="button" class="btn ${openModules.includes(key) ? 'btn-primary' : 'btn-outline-primary'}" data-case-module="${esc(key)}"><i class="bi ${moduleIcons[key] || 'bi-plus-square'} me-1"></i>${esc(moduleLabels[key] || key)}</button>`).join('');
|
||||
moduleAction.querySelectorAll('[data-case-module]').forEach(button => {
|
||||
button.onclick = () => {
|
||||
const moduleKey = button.dataset.caseModule;
|
||||
state.openModules[type] = [...new Set([...(state.openModules[type] || []), moduleKey])];
|
||||
typeChanged(type, false);
|
||||
};
|
||||
});
|
||||
}
|
||||
['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'};
|
||||
Object.entries(sectionIds).forEach(([key, id]) => $(id)?.classList.toggle('d-none', !showTypeFields || !visiblePanels.includes(key)));
|
||||
$('typeDetailsSection')?.classList.toggle('d-none', !openModules.includes('type-details') || !(typeFieldSets[type] || []).length);
|
||||
Object.entries(sectionIds).forEach(([key, id]) => $(id)?.classList.toggle('d-none', !openModules.includes(key) || !visiblePanels.includes(key)));
|
||||
document.querySelectorAll('.case-type-extra').forEach(el => el.classList.toggle('d-none', !showTypeFields));
|
||||
$('cc-template')?.closest('.cc-template-toolbar')?.classList.toggle('d-none', !showTypeFields);
|
||||
if (showTypeFields && !state.restoring) visiblePanels.forEach(panelKey => { if (state.panels[panelKey]) state.panels[panelKey].open = true; });
|
||||
@ -228,13 +254,19 @@
|
||||
const keys = [...new Set([current, ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
|
||||
const hints = {ticket:'Support og hjælp',salg:'Tilbud og salg',bogholderi:'Bilag og økonomi',admin:'Intern administration',projekt:'Større forløb',postit:'Huskeseddel',intern_ticket:'Intern opgave',bug_report:'Rapportér en fejl'};
|
||||
const button = key => `<button type="button" class="btn btn-outline-primary" data-case-type="${esc(key)}" aria-pressed="false"><span class="cc-type-name">${esc(options.find(o => o.value === key).text)}</span><small class="cc-type-hint">${esc(hints[key] || 'Opret sag')}</small></button>`;
|
||||
const 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 ? `<details id="cc-more"><summary>Flere</summary><div class="cc-types">${keys.slice(4).map(button).join('')}</div></details>` : '');
|
||||
if (messageButton) { messageButton.classList.remove('cc-message-fallback'); messageButton.dataset.caseMessage = 'true'; div.insertBefore(messageButton, div.querySelector('#cc-more')); }
|
||||
div.innerHTML = keys.map(button).join('');
|
||||
$('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})); } });
|
||||
div.addEventListener('click', e => { const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } });
|
||||
}
|
||||
function selectNextType() {
|
||||
const buttons = Array.from(document.querySelectorAll('#cc-types > button[data-case-type]'));
|
||||
if (!buttons.length) return false;
|
||||
const currentType = $('type')?.value;
|
||||
const currentIndex = buttons.findIndex(button => button.dataset.caseType === currentType);
|
||||
buttons[(currentIndex + 1 + buttons.length) % buttons.length].click();
|
||||
return true;
|
||||
}
|
||||
async function applyTemplate(id) {
|
||||
const template = templates.find(t => t.id === Number(id)); if (!template) return;
|
||||
@ -249,7 +281,7 @@
|
||||
async function restore(draft) {
|
||||
state.restoring = true;
|
||||
try {
|
||||
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; state.typeDetails = draft.typeDetails || {};
|
||||
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; state.typeDetails = draft.typeDetails || {}; state.openModules = draft.openModules || {};
|
||||
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 || [];
|
||||
@ -259,10 +291,10 @@
|
||||
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
|
||||
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
|
||||
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
|
||||
window.updateCaseTypeSections?.(true); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
|
||||
window.updateCaseTypeSections?.(false); 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();
|
||||
state.pending = false; $('cc-draft').remove(); scheduleSave();
|
||||
}
|
||||
function validate() {
|
||||
document.querySelectorAll('.cc-error').forEach(el => el.remove());
|
||||
@ -270,7 +302,11 @@
|
||||
const errors = [];
|
||||
const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<div class="text-danger small cc-error">${esc(text)}</div>`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend','<span class="text-danger small cc-error"> · Kontrollér felter</span>'); } };
|
||||
if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet');
|
||||
if (['ticket', 'salg', 'bogholderi', 'projekt', 'service', 'pipeline', 'ordre', 'opgave', 'abonnement'].includes($('type').value) && !selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma');
|
||||
if (!$('beskrivelse').value.trim()) add($('beskrivelse'), 'Beskrivelse er påkrævet');
|
||||
const type = $('type').value;
|
||||
const needsCompanyAndContact = ['ticket', 'bogholderi', 'admin'].includes(type);
|
||||
if (needsCompanyAndContact && !selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Virksomhed er påkrævet for denne sagstype');
|
||||
if (needsCompanyAndContact && Object.keys(selectedContacts).length === 0 && $('contactSearch').value.trim().length < 2) add($('contactSearch'), 'Kontakt er påkrævet for denne sagstype');
|
||||
$('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; }
|
||||
@ -283,15 +319,6 @@
|
||||
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], false);
|
||||
for (const [key,id,title] of [['type-details','typeDetailsSection','Oplysninger til sagstypen'],['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
|
||||
const metadata = headings[1]; panel('metadata', 'Ansvarlig', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling], true);
|
||||
const tags = document.createElement('section'); tags.innerHTML = '<div id="cc-tag-chips"></div><button type="button" id="cc-tag-add" class="btn btn-sm btn-outline-primary">Tilføj tag</button><div id="cc-tag-suggestions" class="cc-tag-suggestions"></div>'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags], true);
|
||||
$('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;
|
||||
@ -304,19 +331,20 @@
|
||||
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 = '<label class="form-label" for="cc-template">Hurtigskabelon</label><select id="cc-template" class="form-select"><option value="">Start uden skabelon</option></select><small id="cc-template-error" class="text-muted"></small>'; form.prepend(toolbar);
|
||||
try { templates = await api('/api/v1/case-create/templates'); $('cc-template').insertAdjacentHTML('beforeend',templates.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('')); } catch { $('cc-template-error').textContent = 'Skabeloner er ikke tilgængelige.'; }
|
||||
$('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message));
|
||||
window.renderSelections = () => { originalRender(); if (state.ready) scheduleSave(); };
|
||||
const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>');
|
||||
toolbar.classList.add('cc-template-toolbar');
|
||||
// Core flow stays visible: type → required fields → chosen type's fields.
|
||||
const typeSection = form.querySelector('.cc-type-section');
|
||||
const basicSection = $('titel').closest('.row');
|
||||
const relationsSection = $('customerSearch').closest('.row');
|
||||
const assignmentSection = $('ansvarlig_bruger_id').closest('.row');
|
||||
const moduleAction = $('typeModuleAction');
|
||||
if (typeSection && basicSection) typeSection.after(basicSection);
|
||||
if (basicSection && relationsSection) basicSection.after(relationsSection);
|
||||
if (assignmentSection && moduleAction) assignmentSection.after(moduleAction);
|
||||
buildTypes(); updateCaseTypeSections(false);
|
||||
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);
|
||||
$('titel').addEventListener('input', scheduleSave);
|
||||
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 {
|
||||
@ -334,10 +362,10 @@
|
||||
}
|
||||
$('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();
|
||||
state.ready = true;
|
||||
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:() => { captureTypeDetails(); return {tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id),type_details:state.typeDetails[$('type').value] || {}}; },created: result => {
|
||||
window.caseCreateUI = {init,typeChanged,nextType:selectNextType,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => { captureTypeDetails(); return {tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id),type_details:state.typeDetails[$('type').value] || {}}; },created: result => {
|
||||
state.completed = true; clearTimeout(saveTimer);
|
||||
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. */ }
|
||||
}};
|
||||
|
||||
@ -34,54 +34,32 @@ async function page(storage, userId=7, search='') {
|
||||
}
|
||||
(async()=>{
|
||||
const dom=await page(),w=dom.window,d=w.document;
|
||||
assert.equal(d.querySelectorAll('#cc-types > button[data-case-type]').length,4);
|
||||
assert.equal(d.querySelectorAll('#cc-types > button[data-case-type]').length,8);
|
||||
assert.equal(d.querySelectorAll('#cc-types > button[data-case-message]').length,1);
|
||||
assert.equal(d.querySelectorAll('.cc-panel').length,6);
|
||||
assert.equal(d.querySelectorAll('form#createForm').length,1);
|
||||
for(const id of ['customerSearch','titel','beskrivelse','status','deadline','submitBtn'])assert.ok(d.getElementById(id).closest('#createForm'),id);
|
||||
assert.match(d.getElementById('cc-workload').textContent,/0 åbne sager/);
|
||||
for(const id of ['customerSearch','contactSearch','titel','beskrivelse','status','deadline','submitBtn'])assert.ok(d.getElementById(id).closest('#createForm'),id);
|
||||
assert.equal(w.caseCreateUI.validate(),false);
|
||||
assert.equal(d.getElementById('cc-relations').open,true);
|
||||
assert.ok(d.getElementById('typeDetailsSection').classList.contains('d-none'),'Type fields stay hidden until a type button is selected');
|
||||
d.querySelector('[data-case-type="bug_report"]').click();
|
||||
assert.ok(!d.getElementById('typeModuleAction').classList.contains('d-none'));
|
||||
assert.ok(d.getElementById('typeDetailsSection').classList.contains('d-none'));
|
||||
d.getElementById('openTypeModuleBtn').click();
|
||||
assert.ok(!d.getElementById('typeDetailsSection').classList.contains('d-none'));
|
||||
assert.match(d.getElementById('typeDetailsFields').textContent,/Berørt område/);
|
||||
w.testSetCustomer();
|
||||
await w.loadSelectedCustomerContacts(11);
|
||||
const search=d.getElementById('contactSearch'), results=d.getElementById('contactResults');
|
||||
assert.ok(results.classList.contains('d-none'),'Selecting company must not open contact results');
|
||||
search.focus();assert.ok(results.classList.contains('d-none'),'Empty focus must not open results');
|
||||
search.value='Fi';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(!results.classList.contains('d-none'),'Explicit search opens results');
|
||||
search.value='';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(results.classList.contains('d-none'),'Clearing closes results');
|
||||
w.testSetContact(); await delay(30);
|
||||
assert.match(d.getElementById('cc-contact-cases').textContent,/Eksisterende routerfejl/);
|
||||
assert.match(d.getElementById('cc-contact-cases').innerHTML,/\/sag\/44\/v3/);
|
||||
d.getElementById('titel').value='Test oprettelse';
|
||||
d.getElementById('beskrivelse').value='Router mister forbindelsen';d.getElementById('beskrivelse').dispatchEvent(new w.Event('input',{bubbles:true}));await delay(400);
|
||||
assert.match(d.getElementById('cc-tag-suggestions').textContent,/Router/);
|
||||
d.getElementById('pipeline_amount').value='123';
|
||||
d.querySelector('[data-case-type="ordre"]').click();
|
||||
w.addOrderLine('sale');d.querySelector('.order-description').value='Router';d.querySelector('.order-amount').value='50';
|
||||
d.querySelector('[data-case-type="ticket"]').click();
|
||||
assert.equal(d.getElementById('pipeline_amount').value,'123');
|
||||
d.getElementById('beskrivelse').value='Router mister forbindelsen';
|
||||
assert.equal(w.caseCreateUI.validate(),true);
|
||||
w.confirm=()=>false;
|
||||
d.getElementById('cc-template').value='1';d.getElementById('cc-template').dispatchEvent(new w.Event('change',{bubbles:true}));await delay(30);
|
||||
assert.equal(d.getElementById('titel').value,'Test oprettelse');
|
||||
w.confirm=()=>true;
|
||||
d.getElementById('cc-tag-add').click();
|
||||
d.getElementById('titel').dispatchEvent(new w.Event('input',{bubbles:true}));
|
||||
await delay(650);
|
||||
const key=Object.keys(w.localStorage)[0];assert.ok(key?.startsWith('bmc:case-draft:v1:7:'), d.getElementById('cc-save').textContent);
|
||||
const draft=JSON.parse(w.localStorage.getItem(key));assert.equal(draft.orders.length,1);assert.equal(draft.tags[0].id,9);
|
||||
const saved={[key]:w.localStorage.getItem(key)};
|
||||
const restored=await page(saved);restored.window.document.getElementById('cc-restore').click();await delay(80);
|
||||
assert.equal(restored.window.document.getElementById('titel').value,'Test oprettelse');
|
||||
assert.equal(restored.window.document.querySelector('.order-description').value,'Router');
|
||||
assert.deepEqual(Array.from(restored.window.caseCreateUI.relations().tag_ids),[9]);
|
||||
restored.window.caseCreateUI.created({id:99});await delay(600);assert.equal(restored.window.localStorage.getItem(key),null);
|
||||
const other=await page(saved,8);assert.equal(other.window.document.getElementById('cc-restore'),null);
|
||||
const prefill=await page(saved,7,'?title=Other');assert.equal(prefill.window.document.getElementById('cc-restore'),null);
|
||||
const expired=await page({[key]:JSON.stringify({...draft,savedAt:Date.now()-8*86400000})});assert.equal(expired.window.document.getElementById('cc-restore'),null);
|
||||
const td=expired.window.document;td.getElementById('cc-template').value='1';td.getElementById('cc-template').dispatchEvent(new expired.window.Event('change',{bubbles:true}));await delay(40);
|
||||
assert.equal(td.getElementById('titel').value,'Tilbud');assert.equal(td.getElementById('type').value,'pipeline');assert.deepEqual(Array.from(expired.window.caseCreateUI.relations().tag_ids),[9]);
|
||||
assert.equal(new Set(Array.from(d.querySelectorAll('[id]')).map(el=>el.id)).size,d.querySelectorAll('[id]').length,'IDs must remain unique');
|
||||
[dom,restored,other,prefill,expired].forEach(x=>x.window.close());
|
||||
console.log('PASS: panel structure, type switching, validation, tags, autosave, restore, user/prefill isolation, expiration, successful cleanup');
|
||||
[dom,restored].forEach(x=>x.window.close());
|
||||
console.log('PASS: simple flow, all type buttons, required fields and draft restore');
|
||||
})().catch(e=>{console.error(e);process.exit(1);});
|
||||
|
||||
@ -128,6 +128,27 @@ def test_relation_quick_task_uses_todo_steps_api():
|
||||
assert "due_date: due" in template
|
||||
|
||||
|
||||
def test_add_primary_case_contact_clears_previous_primary(monkeypatch):
|
||||
queries = []
|
||||
updates = []
|
||||
|
||||
def fake_query(query, params=None, **_kwargs):
|
||||
queries.append((query, params))
|
||||
if "SELECT id FROM sag_kontakter" in query:
|
||||
return []
|
||||
if "INSERT INTO sag_kontakter" in query:
|
||||
return [{"id": 12, "sag_id": 177, "contact_id": 2011, "is_primary": True}]
|
||||
raise AssertionError(query)
|
||||
|
||||
monkeypatch.setattr(sag_router, "execute_query", fake_query)
|
||||
monkeypatch.setattr(sag_router, "execute_update", lambda query, params: updates.append((query, params)))
|
||||
|
||||
result = asyncio.run(sag_router.add_case_contact(177, {"contact_id": 2011, "is_primary": True}))
|
||||
|
||||
assert result["contact_id"] == 2011
|
||||
assert updates == [("UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s", (177,))]
|
||||
|
||||
|
||||
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
|
||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||
|
||||
@ -145,6 +166,94 @@ def test_case_detail_disables_browser_cache_for_fresh_inline_ui():
|
||||
assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source
|
||||
|
||||
|
||||
def test_v4_is_the_canonical_case_view_and_v3_redirects_to_it():
|
||||
source = Path("app/modules/sag/frontend/views.py").read_text()
|
||||
|
||||
assert 'async def sag_detaljer_v4' in source
|
||||
assert 'async def sag_detaljer_v3_legacy' in source
|
||||
assert 'RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)' in source
|
||||
|
||||
|
||||
def test_v4_case_upload_is_available_from_topbar_and_empty_file_module_is_content_driven():
|
||||
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||
|
||||
assert 'bi bi-paperclip' in template
|
||||
assert "document.getElementById('fileInput').click()" in template
|
||||
assert "'files'" in template
|
||||
assert "v4ContentDrivenModules" in template
|
||||
assert "caseTopbarUploadBtn" in template
|
||||
assert "handleTopbarAttachmentFiles" in template
|
||||
assert "uploadEmailFile(emailFile)" in template
|
||||
assert "fil${count === 1 ? '' : 'er'} uploadet" in template
|
||||
|
||||
|
||||
def test_v4_file_module_renders_clickable_inline_preview_thumbnails():
|
||||
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||
|
||||
assert "case-file-thumbnail" in template
|
||||
assert "previewFileById(${fileId})" in template
|
||||
assert "preview-image?page=1&scale=1" in template
|
||||
assert "bi bi-eye" in template
|
||||
assert "previewAdjacentFile(-1)" in template
|
||||
assert "previewAdjacentFile(1)" in template
|
||||
assert "event.key === 'ArrowLeft'" in template
|
||||
assert "event.key === 'ArrowRight'" in template
|
||||
|
||||
|
||||
def test_v4_email_view_can_recover_missing_declared_attachments():
|
||||
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||
|
||||
assert "const attachmentsMissing" in template
|
||||
assert "Hent vedhæftninger" in template
|
||||
assert "/recover-attachments" in template
|
||||
assert "recoverLinkedEmailAttachments" in template
|
||||
|
||||
|
||||
def test_v4_header_has_no_duplicate_status_or_type_and_uses_contact_widget():
|
||||
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||
header = template.split("<!-- WIDGET DASHBOARD -->", 1)[0]
|
||||
|
||||
assert 'id="caseStatusBadge"' not in header
|
||||
assert 'id="caseTypeDropdownBtn"' not in header
|
||||
assert "showCustomerSearch('replace')" not in header
|
||||
assert "showContactSearch('replace')" not in header
|
||||
assert "showAfdelingModal()" not in header
|
||||
assert "Kontakt\n </div>" in template
|
||||
assert "Firma</span>" in template
|
||||
assert "Hovedkontakt</span>" in template
|
||||
assert "Afdeling</span>" in template
|
||||
assert "Start VoIP-kald" in template
|
||||
assert "Send SMS" in template
|
||||
|
||||
|
||||
def test_v4_contact_picker_loads_company_contacts_before_global_search():
|
||||
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
|
||||
|
||||
assert "loadCustomerContactsForContactSearch" in template
|
||||
assert "/api/v1/customers/${customerId}/contacts" in template
|
||||
assert "Firmaets kontakter" in template
|
||||
assert "Andre kontakter" in template
|
||||
assert "Søg efter anden kontakt" in template
|
||||
|
||||
|
||||
def test_uploaded_email_attachments_are_copied_to_case_files():
|
||||
router_source = Path("app/modules/sag/backend/router.py").read_text()
|
||||
|
||||
assert "_copy_uploaded_email_attachments_to_case" in router_source
|
||||
assert 'attachment_files_created' in router_source
|
||||
assert 'source_type")' in router_source
|
||||
|
||||
|
||||
def test_buzzwords_preference_is_per_user_without_changing_legacy_module_preferences():
|
||||
router_source = Path("app/modules/sag/backend/router.py").read_text()
|
||||
migration = Path("migrations/1042_user_sag_module_prefs.sql").read_text()
|
||||
|
||||
assert "user_sag_module_prefs" in migration
|
||||
assert "UNIQUE (user_id, sag_id, module_key)" in migration
|
||||
assert "module_key <> 'buzzwords'" in router_source
|
||||
assert "INSERT INTO user_sag_module_prefs" in router_source
|
||||
|
||||
|
||||
def test_case_create_lists_contacts_for_selected_customer():
|
||||
template = Path("app/modules/sag/templates/create.html").read_text()
|
||||
|
||||
@ -156,6 +265,25 @@ def test_case_create_lists_contacts_for_selected_customer():
|
||||
assert "resetCustomerContactSearch();" in template
|
||||
|
||||
|
||||
def test_case_create_uses_simple_type_first_flow_with_required_description():
|
||||
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
|
||||
script = Path("static/js/case-create.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'id="beskrivelse"' in template
|
||||
assert 'placeholder="Beskriv problemstillingen detaljeret..." required' in template
|
||||
assert 'id="advancedCaseFields"' in template
|
||||
assert "keys.map(button).join('')" in script
|
||||
assert "keys.slice(0,4)" not in script
|
||||
assert "Beskrivelse er påkrævet" in script
|
||||
assert "type → required fields → chosen type's fields" in script
|
||||
assert 'id="typeModuleAction"' in template
|
||||
assert "data-case-module" in script
|
||||
assert "Ticket-oplysninger" in script
|
||||
assert "['ticket', 'bogholderi', 'admin']" in script
|
||||
assert "Virksomhed er påkrævet for denne sagstype" in script
|
||||
assert "Kontakt er påkrævet for denne sagstype" in script
|
||||
|
||||
|
||||
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")
|
||||
@ -181,7 +309,7 @@ def test_case_create_sends_relations_in_atomic_create_payload():
|
||||
assert 'raw_contact_ids = data.get("contact_ids")' in router
|
||||
assert "INSERT INTO sag_kontakter" in router
|
||||
assert "UPDATE telefoni_opkald" in router
|
||||
assert "window.location.href = `/sag/${result.id}/v3`;" in template
|
||||
assert "window.location.href = `/sag/${result.id}/v4`;" in template
|
||||
assert "Omdirigerer..." not in template
|
||||
|
||||
|
||||
|
||||
@ -130,4 +130,6 @@ def test_case_click_to_call_contract_tracks_case_contact_and_time():
|
||||
assert 'pending_callid = f"click-to-call:' in router
|
||||
assert "_register_completed_call_time(resolved_callid)" in router
|
||||
assert "INSERT INTO tmodule_times" in router
|
||||
assert "callid LIKE 'click-to-call:%'" in service
|
||||
# psycopg uses % for placeholders, so the literal SQL LIKE wildcard must
|
||||
# be escaped. Otherwise the established Yealink callback returns HTTP 500.
|
||||
assert "callid LIKE 'click-to-call:%%'" in service
|
||||
|
||||
Loading…
Reference in New Issue
Block a user