diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 652d9af..86efb84 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -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") diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index a8912cc..8e8c956 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -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.""" diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 50f8729..a5c2059 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -305,10 +305,6 @@
Vælg sagstype for at vise de relevante felter.
- @@ -344,6 +340,27 @@
+
+
+ + +
+
+ + +
+
+
@@ -363,7 +380,7 @@ AI renskriv - +
AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger. 0 tegn @@ -376,6 +393,8 @@
+
+
Hardware (AnyDesk)
@@ -437,8 +456,10 @@
- -
Ansvarlig
+ +
+ Flere valg: ansvarlig, status og deadline +
@@ -448,25 +469,6 @@
-
- - -
- -
- - -
@@ -478,6 +480,8 @@
+ +
@@ -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 => ``).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 @@ } }); - + {% endblock %} diff --git a/app/modules/sag/templates/detail_v4.html b/app/modules/sag/templates/detail_v4.html index 025ecf0..4fe741a 100644 --- a/app/modules/sag/templates/detail_v4.html +++ b/app/modules/sag/templates/detail_v4.html @@ -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 @@
- {% 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) %}
#{{ case.id }} - {{ case.status|capitalize if case.status else 'Åben' }} - {{ tlabel }}

{{ case.titel }}

+ +
@@ -4194,17 +4292,6 @@

- {{ customer.name if customer else 'Vælg virksomhed' }} - {% if customer and customer.is_vendor %} - - Leverandør - - {% endif %} - • - {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Vælg kontakt' }} - • - {{ customer.department if customer and customer.department else 'Ingen afdeling' }} - • Oprettet {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '-' }}

@@ -4378,19 +4465,42 @@
- +
-
+
-
-
Tidsregistrering
-
- - +
+ Kontakt +
+ +
+ Hovedkontakt +
+ {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Vælg kontakt' }} + {% if hovedkontakt %} + + {% if hovedkontakt.email %}{% endif %} + {% set contact_number = hovedkontakt.mobile or hovedkontakt.phone %} + {% if contact_number %}{% endif %} + {% if hovedkontakt.mobile %}{% endif %} + + {% endif %} + +
+
+ - -
@@ -5354,6 +5464,9 @@ Fil preview
+ + +
@@ -5373,11 +5486,11 @@