release: v2.8.0 case creation and messaging
This commit is contained in:
parent
9ca562745a
commit
9ed709c9e8
18
MDfile/RELEASE_NOTES_v2.8.0.md
Normal file
18
MDfile/RELEASE_NOTES_v2.8.0.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# BMC Hub v2.8.0
|
||||||
|
|
||||||
|
## Ny sag
|
||||||
|
|
||||||
|
- Redesignet, mere fokuseret oprettelsesflow med kompakte typevalg og progressive paneler.
|
||||||
|
- Browser-kladdesystem, hurtigskabeloner, tagvælger og tastaturgenvej til oprettelse.
|
||||||
|
- Duplikatindsigt for kunde og valgt kontakt samt arbejdsbelastning for ansvarlig medarbejder.
|
||||||
|
- Brand- og type-tagforslag ud fra sagens titel og beskrivelse.
|
||||||
|
|
||||||
|
## Interne beskeder
|
||||||
|
|
||||||
|
- Nyt, mere overskueligt beskedflow mellem medarbejdere.
|
||||||
|
- Understøttelse af korte telefonbeskeder med valgfri kontaktperson og tilbageringningsnummer.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
- Kør `migrations/238_case_create_templates.sql`.
|
||||||
|
- Kør `migrations/239_internal_phone_messages.sql`.
|
||||||
@ -1,8 +1,8 @@
|
|||||||
from typing import Optional
|
from typing import Optional, Literal
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
from app.core.auth_dependencies import get_current_user
|
from app.core.auth_dependencies import get_current_user
|
||||||
@ -69,6 +69,10 @@ class BottomBarMessageCreatePayload(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
recipient_user_id: Optional[int] = None
|
recipient_user_id: Optional[int] = None
|
||||||
requires_manual_ack: bool = False
|
requires_manual_ack: bool = False
|
||||||
|
message_kind: Literal['message', 'phone'] = 'message'
|
||||||
|
contact_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
caller_name: str = Field(default='', max_length=200)
|
||||||
|
callback_phone: str = Field(default='', max_length=80)
|
||||||
|
|
||||||
|
|
||||||
class BottomBarMessageReadPayload(BaseModel):
|
class BottomBarMessageReadPayload(BaseModel):
|
||||||
@ -566,13 +570,17 @@ async def send_bottom_bar_message(
|
|||||||
if recipient_user_id == int(current_user_id):
|
if recipient_user_id == int(current_user_id):
|
||||||
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv")
|
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv")
|
||||||
|
|
||||||
|
if payload.contact_id is not None:
|
||||||
|
if not execute_query_single('SELECT id FROM contacts WHERE id=%s', (payload.contact_id,)):
|
||||||
|
raise HTTPException(status_code=400, detail='Kontaktpersonen findes ikke')
|
||||||
|
|
||||||
row = execute_query_single(
|
row = execute_query_single(
|
||||||
"""
|
"""
|
||||||
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack)
|
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack, message_kind, contact_id, caller_name, callback_phone)
|
||||||
VALUES (%s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at
|
RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at
|
||||||
""",
|
""",
|
||||||
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack)),
|
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack), payload.message_kind, payload.contact_id, payload.caller_name.strip(), payload.callback_phone.strip()),
|
||||||
) or {}
|
) or {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -582,6 +590,10 @@ async def send_bottom_bar_message(
|
|||||||
"from": _resolve_current_user_display_name(current_user),
|
"from": _resolve_current_user_display_name(current_user),
|
||||||
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
|
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
|
||||||
"text": row.get("message_text") or message_text,
|
"text": row.get("message_text") or message_text,
|
||||||
|
"message_kind": payload.message_kind,
|
||||||
|
"contact_id": payload.contact_id,
|
||||||
|
"caller_name": payload.caller_name.strip(),
|
||||||
|
"callback_phone": payload.callback_phone.strip(),
|
||||||
"requires_manual_ack": bool(row.get("requires_manual_ack")),
|
"requires_manual_ack": bool(row.get("requires_manual_ack")),
|
||||||
"created_at": row.get("created_at"),
|
"created_at": row.get("created_at"),
|
||||||
"is_own": True,
|
"is_own": True,
|
||||||
|
|||||||
@ -83,6 +83,10 @@ def ensure_bottom_bar_messages_schema() -> None:
|
|||||||
sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
message_text TEXT NOT NULL,
|
message_text TEXT NOT NULL,
|
||||||
|
message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
|
||||||
|
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
|
||||||
|
caller_name VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
callback_phone VARCHAR(80) NOT NULL DEFAULT '',
|
||||||
requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
|
requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
read_at TIMESTAMP NULL
|
read_at TIMESTAMP NULL
|
||||||
@ -92,6 +96,12 @@ def ensure_bottom_bar_messages_schema() -> None:
|
|||||||
logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
|
logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
|
||||||
else:
|
else:
|
||||||
columns = set(_table_columns("bottom_bar_messages"))
|
columns = set(_table_columns("bottom_bar_messages"))
|
||||||
|
if not {'message_kind', 'contact_id', 'caller_name', 'callback_phone'}.issubset(columns):
|
||||||
|
execute_query("""ALTER TABLE bottom_bar_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
|
||||||
|
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''""")
|
||||||
if "requires_manual_ack" not in columns:
|
if "requires_manual_ack" not in columns:
|
||||||
execute_query(
|
execute_query(
|
||||||
"""
|
"""
|
||||||
@ -143,6 +153,8 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
|||||||
m.sender_user_id,
|
m.sender_user_id,
|
||||||
m.recipient_user_id,
|
m.recipient_user_id,
|
||||||
m.message_text,
|
m.message_text,
|
||||||
|
m.message_kind, m.contact_id, m.caller_name, m.callback_phone,
|
||||||
|
NULLIF(CONCAT_WS(' ', contact.first_name, contact.last_name), '') AS contact_name,
|
||||||
m.requires_manual_ack,
|
m.requires_manual_ack,
|
||||||
m.created_at,
|
m.created_at,
|
||||||
receipt.read_at,
|
receipt.read_at,
|
||||||
@ -152,9 +164,10 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
|||||||
FROM bottom_bar_messages m
|
FROM bottom_bar_messages m
|
||||||
JOIN users sender ON sender.user_id = m.sender_user_id
|
JOIN users sender ON sender.user_id = m.sender_user_id
|
||||||
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
|
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
|
||||||
|
LEFT JOIN contacts contact ON contact.id = m.contact_id
|
||||||
LEFT JOIN bottom_bar_message_receipts receipt
|
LEFT JOIN bottom_bar_message_receipts receipt
|
||||||
ON receipt.message_id = m.id
|
ON receipt.message_id = m.id
|
||||||
AND receipt.user_id = %s
|
AND receipt.user_id = CASE WHEN m.sender_user_id = %s AND m.recipient_user_id IS NOT NULL THEN m.recipient_user_id ELSE %s END
|
||||||
WHERE m.sender_user_id = %s
|
WHERE m.sender_user_id = %s
|
||||||
OR m.recipient_user_id = %s
|
OR m.recipient_user_id = %s
|
||||||
OR (m.recipient_user_id IS NULL AND EXISTS (
|
OR (m.recipient_user_id IS NULL AND EXISTS (
|
||||||
@ -166,7 +179,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
|||||||
ORDER BY m.created_at DESC, m.id DESC
|
ORDER BY m.created_at DESC, m.id DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
(int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
|
(int(user_id), int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
|
||||||
) or []
|
) or []
|
||||||
|
|
||||||
unread_row = execute_query_single(
|
unread_row = execute_query_single(
|
||||||
@ -200,9 +213,15 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
|||||||
"from": row.get("sender_name") or "Ukendt",
|
"from": row.get("sender_name") or "Ukendt",
|
||||||
"to": recipient_name,
|
"to": recipient_name,
|
||||||
"text": row.get("message_text") or "",
|
"text": row.get("message_text") or "",
|
||||||
|
"message_kind": row.get("message_kind") or "message",
|
||||||
|
"contact_id": row.get("contact_id"),
|
||||||
|
"contact_name": row.get("contact_name"),
|
||||||
|
"caller_name": row.get("caller_name") or "",
|
||||||
|
"callback_phone": row.get("callback_phone") or "",
|
||||||
"requires_manual_ack": bool(row.get("requires_manual_ack")),
|
"requires_manual_ack": bool(row.get("requires_manual_ack")),
|
||||||
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
|
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
|
||||||
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
|
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
|
||||||
|
"is_read": row.get("read_at") is not None,
|
||||||
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
|
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
|
||||||
"is_acknowledged": row.get("acknowledged_at") is not None,
|
"is_acknowledged": row.get("acknowledged_at") is not None,
|
||||||
}
|
}
|
||||||
|
|||||||
195
app/modules/sag/backend/create_support.py
Normal file
195
app/modules/sag/backend/create_support.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
"""Creation helpers: transactional associations and advisory case lookups."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, StrictInt
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
from app.core.auth_dependencies import require_any_permission
|
||||||
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
|
read_access = require_any_permission('cases.view', 'tickets.view', 'cases.create', 'tickets.create', 'users.manage', 'system.admin')
|
||||||
|
admin_access = require_any_permission('users.manage', 'system.admin')
|
||||||
|
router = APIRouter(prefix='/case-create', dependencies=[Depends(read_access)])
|
||||||
|
|
||||||
|
|
||||||
|
def ids(value, field):
|
||||||
|
if not isinstance(value, list) or any(isinstance(v, bool) or not isinstance(v, int) or v <= 0 for v in value):
|
||||||
|
raise HTTPException(400, f'{field} skal være en liste med positive heltal')
|
||||||
|
return list(dict.fromkeys(value))
|
||||||
|
|
||||||
|
|
||||||
|
def attach_create_relations(cursor, case_id, data, user_id):
|
||||||
|
hardware_ids = ids(data.get('hardware_ids', []), 'hardware_ids')
|
||||||
|
tag_ids = ids(data.get('tag_ids', []), 'tag_ids')
|
||||||
|
if hardware_ids:
|
||||||
|
cursor.execute('SELECT id FROM hardware_assets WHERE id = ANY(%s) AND deleted_at IS NULL', (hardware_ids,))
|
||||||
|
if {r['id'] for r in cursor.fetchall()} != set(hardware_ids):
|
||||||
|
raise HTTPException(400, 'En valgt hardware findes ikke længere')
|
||||||
|
for hardware_id in hardware_ids:
|
||||||
|
cursor.execute('INSERT INTO sag_hardware (sag_id, hardware_id) VALUES (%s, %s) ON CONFLICT DO NOTHING', (case_id, hardware_id))
|
||||||
|
actions = []
|
||||||
|
if tag_ids:
|
||||||
|
cursor.execute('SELECT t.id, t.name, t.tag_group_id, g.behavior FROM tags t LEFT JOIN tag_groups g ON g.id=t.tag_group_id WHERE t.id=ANY(%s) AND t.is_active=TRUE', (tag_ids,))
|
||||||
|
tags = {r['id']: r for r in cursor.fetchall()}
|
||||||
|
if set(tags) != set(tag_ids):
|
||||||
|
raise HTTPException(400, 'Et valgt tag findes ikke længere eller er inaktivt')
|
||||||
|
# Same single/toggle group semantics as the global picker: last choice wins.
|
||||||
|
chosen = []
|
||||||
|
for tag_id in tag_ids:
|
||||||
|
tag = tags[tag_id]
|
||||||
|
if tag['behavior'] in ('single', 'toggle'):
|
||||||
|
chosen = [i for i in chosen if tags[i]['tag_group_id'] != tag['tag_group_id']]
|
||||||
|
chosen.append(tag_id)
|
||||||
|
for tag_id in chosen:
|
||||||
|
cursor.execute("INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by) VALUES ('case', %s, %s, %s) ON CONFLICT DO NOTHING", (case_id, tag_id, user_id))
|
||||||
|
cursor.execute("SELECT action_type, action_config FROM tag_workflows WHERE tag_id=%s AND trigger_event='on_add' AND is_active=TRUE ORDER BY id DESC LIMIT 1", (tag_id,))
|
||||||
|
action = cursor.fetchone()
|
||||||
|
if action:
|
||||||
|
actions.append({'tag': {'id': tag_id, 'name': tags[tag_id]['name']}, 'action': {'type': action['action_type'], 'config': action['action_config'] or {}}, 'entity_type': 'case', 'entity_id': case_id})
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def closed_statuses():
|
||||||
|
row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'")
|
||||||
|
try:
|
||||||
|
configured = json.loads((row or {}).get('value') or '[]')
|
||||||
|
values = [str(s['value']).strip().lower() for s in configured if isinstance(s, dict) and s.get('is_closed') and s.get('value')]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
values = []
|
||||||
|
return values or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done']
|
||||||
|
|
||||||
|
|
||||||
|
def title_similarity(left, right):
|
||||||
|
def normalize(value):
|
||||||
|
return ' '.join(re.findall(r'\w+', value.casefold()))
|
||||||
|
left, right = normalize(left), normalize(right)
|
||||||
|
if not left or not right:
|
||||||
|
return 0
|
||||||
|
a, b = set(left.split()), set(right.split())
|
||||||
|
return max(SequenceMatcher(None, left, right).ratio(), len(a & b) / len(a | b))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/duplicates')
|
||||||
|
def duplicates(customer_id: int, title: str = Query(min_length=5, max_length=1000)):
|
||||||
|
rows = execute_query("""SELECT s.id,s.titel,s.status,COALESCE(u.full_name,u.username) AS ansvarlig_navn
|
||||||
|
FROM sag_sager s LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
|
||||||
|
WHERE s.customer_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))""", (customer_id, closed_statuses())) or []
|
||||||
|
ranked = [(title_similarity(title, r['titel'] or ''), r) for r in rows]
|
||||||
|
ranked.sort(key=lambda item: (-item[0], -item[1]['id']))
|
||||||
|
return [dict(row, similarity=round(score, 3)) for score, row in ranked if score >= 0.45][:5]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/workload')
|
||||||
|
def workload(user_id: int):
|
||||||
|
rows = execute_query("""SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name,COUNT(*) OVER() AS total
|
||||||
|
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
|
||||||
|
WHERE s.ansvarlig_bruger_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))
|
||||||
|
ORDER BY s.deadline ASC NULLS LAST,s.id DESC LIMIT 10""", (user_id, closed_statuses())) or []
|
||||||
|
return {'total': rows[0]['total'] if rows else 0, 'items': rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/contacts-open-cases')
|
||||||
|
def contacts_open_cases(contact_ids: list[int] = Query(min_length=1, max_length=20)):
|
||||||
|
"""A small, advisory view used while selecting contacts on a new case."""
|
||||||
|
contact_ids = ids(contact_ids, 'contact_ids')
|
||||||
|
rows = execute_query("""WITH ranked AS (
|
||||||
|
SELECT sc.contact_id, s.id, s.titel, s.status, s.deadline,
|
||||||
|
COALESCE(u.full_name, u.username, 'Ingen ansvarlig') AS ansvarlig_navn,
|
||||||
|
COUNT(*) OVER (PARTITION BY sc.contact_id) AS total,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY sc.contact_id ORDER BY s.deadline ASC NULLS LAST, s.id DESC) AS position
|
||||||
|
FROM sag_kontakter sc
|
||||||
|
JOIN sag_sager s ON s.id=sc.sag_id
|
||||||
|
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
|
||||||
|
WHERE sc.contact_id=ANY(%s) AND sc.deleted_at IS NULL AND s.deleted_at IS NULL
|
||||||
|
AND NOT (LOWER(TRIM(s.status))=ANY(%s))
|
||||||
|
) SELECT contact_id,id,titel,status,deadline,ansvarlig_navn,total
|
||||||
|
FROM ranked WHERE position<=5 ORDER BY contact_id,position""", (contact_ids, closed_statuses())) or []
|
||||||
|
result = {contact_id: {'total': 0, 'items': []} for contact_id in contact_ids}
|
||||||
|
for row in rows:
|
||||||
|
bucket = result[row['contact_id']]
|
||||||
|
bucket['total'] = row['total']
|
||||||
|
bucket['items'].append({key: row[key] for key in ('id', 'titel', 'status', 'deadline', 'ansvarlig_navn')})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineDefaults(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='forbid')
|
||||||
|
stage_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
amount: Optional[float] = Field(default=None, ge=0, allow_inf_nan=False)
|
||||||
|
probability: Optional[int] = Field(default=None, ge=0, le=100)
|
||||||
|
description: Optional[str] = Field(default=None, max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateValues(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='forbid')
|
||||||
|
type: str = Field(default='ticket', min_length=1, max_length=80)
|
||||||
|
titel: str = Field(default='', max_length=1000)
|
||||||
|
beskrivelse: str = Field(default='', max_length=50000)
|
||||||
|
status: str = Field(default='åben', min_length=1, max_length=80)
|
||||||
|
assigned_group_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
tag_ids: list[StrictInt] = Field(default_factory=list, max_length=100)
|
||||||
|
pipeline: Optional[PipelineDefaults] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CaseTemplate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra='forbid')
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
icon: str = Field(default='bi-lightning', pattern=r'^bi-[a-z0-9-]+$', max_length=80)
|
||||||
|
is_active: bool = True
|
||||||
|
sort_order: int = 0
|
||||||
|
values: TemplateValues
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/templates')
|
||||||
|
def templates():
|
||||||
|
return execute_query('SELECT *, template_values AS "values" FROM case_create_templates WHERE is_active=TRUE ORDER BY sort_order,name,id') or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/template-options', dependencies=[Depends(admin_access)])
|
||||||
|
def template_options():
|
||||||
|
return execute_query('SELECT id,name FROM groups ORDER BY name') or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/templates/admin', dependencies=[Depends(admin_access)])
|
||||||
|
def admin_templates():
|
||||||
|
return execute_query('SELECT *, template_values AS "values" FROM case_create_templates ORDER BY sort_order,name,id') or []
|
||||||
|
|
||||||
|
|
||||||
|
def template_args(data):
|
||||||
|
values = data.values.model_dump()
|
||||||
|
values['tag_ids'] = ids(values['tag_ids'], 'tag_ids')
|
||||||
|
if values['tag_ids']:
|
||||||
|
found = execute_query('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE', (values['tag_ids'],)) or []
|
||||||
|
if {r['id'] for r in found} != set(values['tag_ids']):
|
||||||
|
raise HTTPException(400, 'Ugyldige tags')
|
||||||
|
if values['assigned_group_id'] and not execute_query_single('SELECT id FROM groups WHERE id=%s', (values['assigned_group_id'],)):
|
||||||
|
raise HTTPException(400, 'Ugyldig gruppe')
|
||||||
|
if values['pipeline'] and values['pipeline']['stage_id'] and not execute_query_single('SELECT id FROM pipeline_stages WHERE id=%s', (values['pipeline']['stage_id'],)):
|
||||||
|
raise HTTPException(400, 'Ugyldig pipeline-stage')
|
||||||
|
if not data.name.strip():
|
||||||
|
raise HTTPException(400, 'Navn er påkrævet')
|
||||||
|
return (data.name.strip(), data.icon, data.is_active, data.sort_order, Json(values))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/templates', dependencies=[Depends(admin_access)])
|
||||||
|
def create_template(data: CaseTemplate):
|
||||||
|
return execute_query_single('INSERT INTO case_create_templates (name,icon,is_active,sort_order,template_values) VALUES (%s,%s,%s,%s,%s) RETURNING *, template_values AS "values"', template_args(data))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put('/templates/{template_id}', dependencies=[Depends(admin_access)])
|
||||||
|
def update_template(template_id: int, data: CaseTemplate):
|
||||||
|
row = execute_query_single('UPDATE case_create_templates SET name=%s,icon=%s,is_active=%s,sort_order=%s,template_values=%s,updated_at=NOW() WHERE id=%s RETURNING *, template_values AS "values"', template_args(data) + (template_id,))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, 'Skabelonen findes ikke')
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/templates/{template_id}', dependencies=[Depends(admin_access)])
|
||||||
|
def delete_template(template_id: int):
|
||||||
|
row = execute_query_single('DELETE FROM case_create_templates WHERE id=%s RETURNING id', (template_id,))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, 'Skabelonen findes ikke')
|
||||||
|
return row
|
||||||
@ -6,6 +6,7 @@ import re
|
|||||||
import hashlib
|
import hashlib
|
||||||
import base64
|
import base64
|
||||||
import html
|
import html
|
||||||
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, List, Optional, Dict
|
from typing import Any, List, Optional, Dict
|
||||||
@ -1069,6 +1070,8 @@ async def create_sag(request: Request, data: dict):
|
|||||||
if pipeline_values["amount"] not in (None, ""):
|
if pipeline_values["amount"] not in (None, ""):
|
||||||
try:
|
try:
|
||||||
pipeline_values["amount"] = float(pipeline_values["amount"])
|
pipeline_values["amount"] = float(pipeline_values["amount"])
|
||||||
|
if not math.isfinite(pipeline_values["amount"]) or pipeline_values["amount"] < 0:
|
||||||
|
raise ValueError("Invalid amount")
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
|
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
|
||||||
else:
|
else:
|
||||||
@ -1123,6 +1126,8 @@ async def create_sag(request: Request, data: dict):
|
|||||||
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
|
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
|
||||||
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
|
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
|
||||||
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
|
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
|
||||||
|
if any(value is not None and (not math.isfinite(value) or value < 0) for value in (normalized_items[-1][key] for key in ("quantity", "unit_price", "amount"))):
|
||||||
|
raise HTTPException(status_code=400, detail="Ordrelinjens tal skal være endelige og mindst nul")
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -4,6 +4,21 @@
|
|||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
|
.cc-panel { border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; margin: 0 0 12px; background: var(--bg-surface, #fff); }
|
||||||
|
.cc-panel > summary { padding: 14px 16px; cursor: pointer; font-weight: 600; }
|
||||||
|
.cc-summary { display: block; color: var(--text-secondary, #65758a); font-weight: 400; margin-left: 18px; overflow-wrap: anywhere; }
|
||||||
|
.cc-panel-body { padding: 8px 16px 16px; }
|
||||||
|
.cc-panel-body > h5, .cc-panel-body > section > h5, .cc-panel-body > section > hr, #createForm > hr { display: none; }
|
||||||
|
.cc-types { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
|
.cc-types .btn { min-height: 44px; }
|
||||||
|
#cc-more > summary { padding: 10px; cursor: pointer; }
|
||||||
|
.cc-actions { position: sticky; bottom: 55px; z-index: 1020; padding: 16px; border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; background: var(--bg-surface, #fff); box-shadow: 0 -4px 18px #0001; flex-wrap: wrap; }
|
||||||
|
.cc-workload { margin: 12px 0; padding: 12px; border-radius: 8px; background: var(--bg-hover, #f5f7fa); }
|
||||||
|
.cc-workload summary { cursor: pointer; }
|
||||||
|
.cc-work-item { padding: 8px 0; border-bottom: 1px solid var(--border-color, #dce3eb); }
|
||||||
|
.cc-work-item small { display: block; }
|
||||||
|
[data-bs-theme="dark"] #createForm .bg-light { background: var(--bg-surface) !important; color: var(--text-primary); }
|
||||||
|
@media(max-width: 600px) { .cc-actions { bottom: 55px; padding: 10px; gap: 8px !important; } .cc-actions #cc-save { width: 100%; } .cc-actions .btn { padding: 10px 16px !important; } .cc-panel-body { padding: 8px 12px; } }
|
||||||
/* Gradient Header for the Card */
|
/* Gradient Header for the Card */
|
||||||
.card-header-custom {
|
.card-header-custom {
|
||||||
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
|
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
|
||||||
@ -136,18 +151,144 @@
|
|||||||
.case-top-alerts .alert-danger {
|
.case-top-alerts .alert-danger {
|
||||||
border-left-color: #e03131;
|
border-left-color: #e03131;
|
||||||
}
|
}
|
||||||
|
.cc-page { max-width: 1440px; padding-bottom: 100px !important; }
|
||||||
|
.cc-pagebar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:0 0 20px; }
|
||||||
|
.cc-pagebar > a { color:var(--text-secondary,#63758c); text-decoration:none; font-size:.88rem; }
|
||||||
|
.cc-pagebar .btn { border-radius:12px; font-size:.85rem; padding:10px 16px; }
|
||||||
|
.cc-page .card-custom { border:1px solid var(--border-color,#dce5ed); border-radius:22px; box-shadow:0 16px 48px #143b5610; }
|
||||||
|
.cc-page .card-header-custom { display:flex; justify-content:space-between; align-items:center; gap:20px; padding:34px 36px; border-radius:21px 21px 0 0; background:linear-gradient(115deg,#122f46,#164b62); }
|
||||||
|
.cc-eyebrow { display:block; color:#9ed7de; font-size:.66rem; font-weight:700; letter-spacing:.15em; margin-bottom:10px; }
|
||||||
|
.cc-page h1 { font-size:1.9rem; font-weight:700; letter-spacing:-.04em; margin-bottom:10px; }
|
||||||
|
.cc-page .card-header-custom p { color:#c5dce6; font-size:.9rem; max-width:520px; line-height:1.6; }
|
||||||
|
.cc-hero-icon { display:grid; place-items:center; flex-shrink:0; width:72px; height:72px; border-radius:22px; background:#ffffff0d; border:1px solid #ffffff25; font-size:2rem; color:#a9e4df; }
|
||||||
|
.cc-page .card-custom > .card-body { padding:32px 42px !important; }
|
||||||
|
.cc-page .form-control,.cc-page .form-select { border-radius:10px; min-height:43px; background-color:var(--bg-surface,#fff); border-color:var(--border-color,#dbe4ec); }
|
||||||
|
.cc-page .input-group-text { border-radius:10px 0 0 10px; background:transparent !important; color:#7891a5; }
|
||||||
|
.cc-page .input-group .form-control { border-top-left-radius:0; border-bottom-left-radius:0; }
|
||||||
|
.cc-page textarea.form-control { line-height:1.7; padding:14px 16px; }
|
||||||
|
.cc-page .form-label { font-size:.82rem; }
|
||||||
|
.cc-page .form-text { font-size:.75rem; line-height:1.5; }
|
||||||
|
.cc-page .cc-type-section > label { font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--text-secondary,#63758c); }
|
||||||
|
.cc-page #cc-types { align-items:stretch; gap:10px; }
|
||||||
|
.cc-page #cc-types > button { flex:1; border:1px solid var(--border-color,#dbe4ec); color:var(--text-primary,#26465d); border-radius:13px; text-align:left; padding:14px; background:var(--bg-surface,#fff); transition:border-color .15s,background .15s; }
|
||||||
|
.cc-type-name { display:block; font-size:.9rem; font-weight:600; }
|
||||||
|
.cc-type-hint { display:block; color:var(--text-secondary,#708397); margin-top:5px; font-size:.7rem; font-weight:400; }
|
||||||
|
.cc-page #cc-types > button.active { border-color:#258783; background:var(--accent-light,#eaf6f4); box-shadow:0 0 0 1px #258783; }
|
||||||
|
.cc-page #cc-types > .cc-message-type { border-style:dashed; border-color:#86aaa8; background:color-mix(in srgb,var(--accent-light,#eaf6f4) 62%,var(--bg-surface,#fff)); }
|
||||||
|
.cc-page #cc-types > .cc-message-type:hover { border-style:solid; background:var(--accent-light,#eaf6f4); }
|
||||||
|
.cc-message-type .cc-type-name { display:flex; align-items:center; gap:7px; }
|
||||||
|
.cc-message-type .cc-type-name::after { content:'INTERN'; padding:2px 5px; border-radius:4px; background:#267b74; color:#fff; font-size:.52rem; font-weight:700; letter-spacing:.08em; }
|
||||||
|
.cc-message-fallback { display:flex; width:100%; margin-top:12px; text-align:left; padding:14px; border-radius:13px; }
|
||||||
|
.cc-page #cc-types > button:hover { border-color:#258783; }
|
||||||
|
.cc-page #cc-more { flex-basis:100%; font-size:.78rem; }
|
||||||
|
.cc-page #cc-more > summary { padding:4px 0; color:var(--text-secondary,#63758c); }
|
||||||
|
.cc-page #cc-more .cc-types { padding-top:10px; }
|
||||||
|
.cc-page .cc-panel { border-radius:13px; margin-bottom:12px; }
|
||||||
|
.cc-page .cc-panel > summary { display:flex; align-items:center; gap:12px; list-style:none; padding:15px 17px; }
|
||||||
|
.cc-page .cc-panel > summary::-webkit-details-marker { display:none; }
|
||||||
|
.cc-page .cc-panel > summary::after { content:'⌄'; margin-left:auto; color:#8196a8; transition:transform .15s; }
|
||||||
|
.cc-page .cc-panel[open] > summary::after { transform:rotate(180deg); }
|
||||||
|
.cc-panel-icon { display:grid; place-items:center; flex-shrink:0; width:35px; height:35px; border-radius:10px; color:#3e777e; background:var(--accent-light,#eef5f6); }
|
||||||
|
.cc-panel-label { min-width:0; font-size:.86rem; }
|
||||||
|
.cc-page .cc-summary { margin:3px 0 0; font-size:.72rem; }
|
||||||
|
.cc-page .cc-panel[open] { border-color:#a7c5cc; }
|
||||||
|
.cc-page .cc-panel-body { padding:8px 18px 18px; }
|
||||||
|
.cc-page .cc-actions { border-radius:14px; box-shadow:0 8px 30px #143b561c; margin-top:28px !important; }
|
||||||
|
.cc-page .cc-actions .btn { border-radius:10px; }
|
||||||
|
.cc-page #submitBtn { background:#176967; border-color:#176967; }
|
||||||
|
.cc-page #cc-save { color:var(--text-secondary,#63758c); font-size:.73rem; }
|
||||||
|
.cc-template-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding-bottom:20px; margin-bottom:20px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
|
||||||
|
.cc-template-toolbar .form-label { margin:0; }
|
||||||
|
.cc-template-toolbar .form-select { flex:1; width:auto; max-width:320px; min-width:180px; font-size:.83rem; }
|
||||||
|
.cc-page .cc-workload { background:var(--bg-hover,#f5f8fa); border:1px solid var(--border-color,#e3eaf0); font-size:.8rem; }
|
||||||
|
.cc-contact-cases { margin-top:14px; padding:0; border:1px solid #bdd9da; border-radius:12px; background:var(--bg-surface,#fff); overflow:hidden; font-size:.8rem; box-shadow:0 4px 14px #164b620c; }
|
||||||
|
.cc-contact-cases[hidden] { display:none; }
|
||||||
|
.cc-contact-cases__header { display:flex; align-items:center; gap:10px; padding:11px 13px; background:#edf8f7; color:#195f5e; }
|
||||||
|
.cc-contact-cases__header-icon { display:grid; place-items:center; width:29px; height:29px; border-radius:8px; background:#cfeceb; font-size:.85rem; }
|
||||||
|
.cc-contact-cases__eyebrow { display:block; color:#4d8382; font-size:.62rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; }
|
||||||
|
.cc-contact-cases__title { display:block; font-weight:700; line-height:1.25; }
|
||||||
|
.cc-contact-cases__body { padding:6px 13px 10px; }
|
||||||
|
.cc-contact-cases__group { padding-top:8px; }
|
||||||
|
.cc-contact-cases__group-name { display:block; color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin:0 0 4px; }
|
||||||
|
.cc-contact-cases__item { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; border-bottom:1px solid var(--border-color,#e6eff0); }
|
||||||
|
.cc-contact-cases__item:last-child { border-bottom:0; }
|
||||||
|
.cc-contact-cases__item a { color:var(--text-primary,#213547); font-weight:600; text-decoration:none; }
|
||||||
|
.cc-contact-cases__item a:hover { color:#176967; text-decoration:underline; }
|
||||||
|
.cc-contact-cases__item small { color:var(--text-secondary,#65758a); white-space:nowrap; font-size:.69rem; }
|
||||||
|
.cc-contact-cases__more { display:block; padding-top:6px; color:var(--text-secondary,#65758a); font-size:.69rem; }
|
||||||
|
.cc-contact-cases__empty { padding:11px 13px; color:var(--text-secondary,#65758a); }
|
||||||
|
.cc-tag-suggestions { margin:10px 0 4px; padding-top:10px; border-top:1px solid var(--border-color,#e3eaf0); }
|
||||||
|
.cc-tag-suggestions__label { color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin-bottom:6px; }
|
||||||
|
.cc-tag-suggestions .btn { border-radius:999px; font-size:.72rem; padding:3px 8px; }
|
||||||
|
[data-bs-theme="dark"] .cc-contact-cases { background:var(--bg-surface); border-color:#326b6b; }
|
||||||
|
[data-bs-theme="dark"] .cc-contact-cases__header { background:#173e3e; color:#c9edeb; }
|
||||||
|
@media(min-width:1200px) {
|
||||||
|
.cc-page #cc-relations .row { grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); display:grid; gap:28px !important; }
|
||||||
|
.cc-page #cc-relations .row > [class*="col-"] { width:auto; max-width:none; padding:0; }
|
||||||
|
.cc-page .cc-panel-body { padding:14px 24px 24px; }
|
||||||
|
.cc-contact-cases__item { display:grid; grid-template-columns:minmax(0, 1fr) auto; column-gap:14px; align-items:center; }
|
||||||
|
.cc-contact-cases__item small { text-align:right; margin:0; }
|
||||||
|
}
|
||||||
|
@media(max-width:600px) { .cc-page .card-header-custom { padding:25px 20px; } .cc-hero-icon { display:none; } .cc-page h1 { font-size:1.6rem; } .cc-page .card-custom > .card-body { padding:20px 16px !important; } .cc-page #cc-types > button { flex:1 1 40%; } .cc-pagebar { align-items:flex-start; } .cc-pagebar .btn { max-width:190px; } }
|
||||||
|
|
||||||
|
/* Calm, task-first layout: the form is the product, not the decoration. */
|
||||||
|
.cc-page { max-width:1240px; }
|
||||||
|
.cc-page .card-custom { border-radius:16px; box-shadow:0 8px 28px #143b560d; }
|
||||||
|
.cc-page .card-header-custom { min-height:auto; padding:20px 28px; border-radius:15px 15px 0 0; background:var(--bg-surface,#fff); color:var(--text-primary,#213547); border-bottom:1px solid var(--border-color,#dce5ed); }
|
||||||
|
.cc-page .card-header-custom p { display:none; }
|
||||||
|
.cc-page .card-header-custom .cc-eyebrow { color:#267b74; margin:0 0 4px; font-size:.59rem; }
|
||||||
|
.cc-page .card-header-custom h1 { margin:0; font-size:1.35rem; letter-spacing:-.02em; color:inherit; }
|
||||||
|
.cc-page .cc-hero-icon { width:42px; height:42px; border-radius:12px; font-size:1.15rem; color:#267b74; background:var(--accent-light,#eaf6f4); border:0; }
|
||||||
|
.cc-page .card-custom > .card-body { padding:24px 28px 32px !important; }
|
||||||
|
.cc-template-toolbar { padding:12px 14px; margin:0 0 16px !important; border:1px solid var(--border-color,#e3eaf0); border-radius:10px; background:var(--bg-hover,#f7fafb); }
|
||||||
|
.cc-template-toolbar .form-label { font-weight:600; font-size:.76rem; }
|
||||||
|
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
|
||||||
|
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
|
||||||
|
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
|
||||||
|
.cc-page #caseTypeHelp { display:none; }
|
||||||
|
.cc-page #cc-types { gap:7px; }
|
||||||
|
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
|
||||||
|
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
|
||||||
|
.cc-page .cc-type-hint { display:none; }
|
||||||
|
.cc-page #cc-types > button.active { box-shadow:none; background:#176967; border-color:#176967; color:#fff; }
|
||||||
|
.cc-page #cc-types > button.active .cc-type-name::after { background:#fff3; color:#fff; }
|
||||||
|
.cc-page #cc-types > .cc-message-type { margin-left:auto; border-style:solid; background:transparent; }
|
||||||
|
.cc-page #cc-more { flex-basis:auto; }
|
||||||
|
.cc-page #cc-more > summary { padding:8px 4px; }
|
||||||
|
.cc-page .cc-panel { border-radius:10px; margin-bottom:8px; box-shadow:none; }
|
||||||
|
.cc-page .cc-panel > summary { padding:12px 14px; }
|
||||||
|
.cc-panel-icon { width:30px; height:30px; border-radius:8px; font-size:.82rem; }
|
||||||
|
.cc-page .cc-panel-label { font-size:.8rem; }
|
||||||
|
.cc-page .cc-summary { display:inline; margin-left:7px; font-size:.69rem; }
|
||||||
|
.cc-page .cc-panel-body { padding:8px 14px 16px; }
|
||||||
|
.cc-page .cc-panel-body > .row { margin-bottom:0 !important; }
|
||||||
|
.cc-page #cc-relations[open] { border-color:#9dc7c3; }
|
||||||
|
.cc-page #cc-metadata { margin-top:16px; }
|
||||||
|
.cc-page #cc-workload { margin:8px 0 16px; padding:10px 14px; }
|
||||||
|
.cc-page .cc-actions { margin-top:20px !important; padding:12px 14px; }
|
||||||
|
/* The panels have very different heights. Keep them in a single stable flow
|
||||||
|
instead of pretending they are equal dashboard cards. */
|
||||||
|
@media(min-width:992px) {
|
||||||
|
.cc-page .cc-panel:not(#cc-relations):not(#cc-metadata) { width:100%; display:block; margin-right:0; }
|
||||||
|
}
|
||||||
|
@media(max-width:700px) {
|
||||||
|
.cc-page .card-header-custom { padding:16px 18px; }
|
||||||
|
.cc-page .card-custom > .card-body { padding:18px 14px 28px !important; }
|
||||||
|
.cc-page #cc-types > .cc-message-type { margin-left:0; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container py-4">
|
<div class="container py-4 cc-page">
|
||||||
|
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-lg-8">
|
<div class="col-12">
|
||||||
<!-- Main Card -->
|
<!-- Main Card -->
|
||||||
<div class="card card-custom">
|
<div class="card card-custom">
|
||||||
<div class="card-header-custom">
|
<div class="card-header-custom">
|
||||||
<h2 class="mb-0 fs-4 fw-bold"><i class="bi bi-plus-circle me-2"></i>Opret Ny Sag</h2>
|
<div><span class="cc-eyebrow">ET GODT STED AT STARTE</span><h1>Opret en ny sag</h1><p class="mb-0">Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.</p></div>
|
||||||
<p class="mb-0 opacity-75 small mt-1">Udfyld formularen for at oprette en ny sag i systemet.</p>
|
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
@ -162,10 +303,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="createForm" novalidate>
|
<form id="createForm" novalidate>
|
||||||
<div class="mb-4 p-3 rounded-3 border bg-light">
|
<div class="mb-4 cc-type-section">
|
||||||
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Section: Relations -->
|
<!-- Section: Relations -->
|
||||||
@ -553,10 +698,13 @@
|
|||||||
const titleInput = document.getElementById('titel');
|
const titleInput = document.getElementById('titel');
|
||||||
const descriptionInput = document.getElementById('beskrivelse');
|
const descriptionInput = document.getElementById('beskrivelse');
|
||||||
|
|
||||||
if (suggestedTitle) titleInput.value = suggestedTitle;
|
if (suggestedTitle) {
|
||||||
|
titleInput.value = suggestedTitle;
|
||||||
|
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
if (suggestedDescription) {
|
if (suggestedDescription) {
|
||||||
descriptionInput.value = suggestedDescription;
|
descriptionInput.value = suggestedDescription;
|
||||||
descriptionInput.dispatchEvent(new Event('input'));
|
descriptionInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
}
|
}
|
||||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
|
||||||
});
|
});
|
||||||
@ -580,13 +728,20 @@
|
|||||||
const contactInput = document.getElementById('contactSearch');
|
const contactInput = document.getElementById('contactSearch');
|
||||||
if (contactInput) {
|
if (contactInput) {
|
||||||
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
|
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
|
||||||
|
contactInput.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
clearTimeout(contactSearchTimeout);
|
||||||
|
document.getElementById('contactResults').classList.add('d-none');
|
||||||
|
contactInput.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
contactInput.addEventListener('focus', () => {
|
contactInput.addEventListener('focus', () => {
|
||||||
if (selectedCustomer) {
|
if (selectedCustomer && contactInput.value.trim().length >= 2) {
|
||||||
renderCustomerContactResults(contactInput.value);
|
renderCustomerContactResults(contactInput.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!e.target.closest('.search-position-relative')) {
|
if (!contactInput.closest('.search-position-relative').contains(e.target)) {
|
||||||
const cr = document.getElementById('contactResults');
|
const cr = document.getElementById('contactResults');
|
||||||
if(cr) cr.classList.add('d-none');
|
if(cr) cr.classList.add('d-none');
|
||||||
}
|
}
|
||||||
@ -605,6 +760,12 @@
|
|||||||
|
|
||||||
clearTimeout(timeoutVar);
|
clearTimeout(timeoutVar);
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
resultsDiv.classList.add('d-none');
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'contact' && selectedCustomer) {
|
if (type === 'contact' && selectedCustomer) {
|
||||||
renderCustomerContactResults(query);
|
renderCustomerContactResults(query);
|
||||||
return;
|
return;
|
||||||
@ -616,6 +777,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeout = setTimeout(async () => {
|
const timeout = setTimeout(async () => {
|
||||||
|
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
|
||||||
try {
|
try {
|
||||||
// Show loading state
|
// Show loading state
|
||||||
resultsDiv.classList.remove('d-none');
|
resultsDiv.classList.remove('d-none');
|
||||||
@ -632,6 +794,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
|
||||||
if (!Array.isArray(data)) {
|
if (!Array.isArray(data)) {
|
||||||
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
|
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
|
||||||
return;
|
return;
|
||||||
@ -694,6 +857,10 @@
|
|||||||
if (!resultsDiv || !selectedCustomer) return;
|
if (!resultsDiv || !selectedCustomer) return;
|
||||||
|
|
||||||
const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK');
|
const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK');
|
||||||
|
if (normalizedQuery.length < 2 || document.activeElement !== document.getElementById('contactSearch')) {
|
||||||
|
resultsDiv.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const contacts = selectedCustomerContacts.filter((contact) => {
|
const contacts = selectedCustomerContacts.filter((contact) => {
|
||||||
if (!normalizedQuery) return true;
|
if (!normalizedQuery) return true;
|
||||||
return [
|
return [
|
||||||
@ -739,9 +906,9 @@
|
|||||||
|
|
||||||
selectedCustomerContacts = [];
|
selectedCustomerContacts = [];
|
||||||
contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`;
|
contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`;
|
||||||
context.textContent = `Viser kontakter hos ${selectedCustomer.name}.`;
|
context.textContent = `Søg blandt kontakter hos ${selectedCustomer.name} (mindst 2 tegn).`;
|
||||||
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter firmaets kontakter...</div>';
|
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter firmaets kontakter...</div>';
|
||||||
resultsDiv.classList.remove('d-none');
|
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
|
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
|
||||||
@ -755,7 +922,7 @@
|
|||||||
if (loadToken !== customerContactsLoadToken) return;
|
if (loadToken !== customerContactsLoadToken) return;
|
||||||
console.error('Failed to load customer contacts:', error);
|
console.error('Failed to load customer contacts:', error);
|
||||||
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Firmaets kontakter kunne ikke hentes.</div>';
|
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Firmaets kontakter kunne ikke hentes.</div>';
|
||||||
resultsDiv.classList.remove('d-none');
|
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -835,6 +1002,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadHardwareForContacts();
|
loadHardwareForContacts();
|
||||||
|
window.caseCreateUI?.contactsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTelefoniPrefill() {
|
function readTelefoniPrefill() {
|
||||||
@ -926,6 +1094,7 @@
|
|||||||
delete selectedContactsCompanies[id];
|
delete selectedContactsCompanies[id];
|
||||||
renderSelections();
|
renderSelections();
|
||||||
loadHardwareForContacts();
|
loadHardwareForContacts();
|
||||||
|
window.caseCreateUI?.contactsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSelections() {
|
function renderSelections() {
|
||||||
@ -1167,14 +1336,13 @@
|
|||||||
|
|
||||||
function updateCaseTypeSections() {
|
function updateCaseTypeSections() {
|
||||||
const type = document.getElementById('type')?.value || 'ticket';
|
const type = document.getElementById('type')?.value || 'ticket';
|
||||||
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
|
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
|
||||||
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
|
window.caseCreateUI?.typeChanged(type);
|
||||||
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
|
|
||||||
const help = document.getElementById('caseTypeHelp');
|
const help = document.getElementById('caseTypeHelp');
|
||||||
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
|
if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.'
|
||||||
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
|
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
|
||||||
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
|
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
|
||||||
: 'Denne sagstype bruger kun de fælles sagsfelter.';
|
: 'Tilføj hardware, pipeline, tags og køb/salg efter behov.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOrderLinesEmptyState() {
|
function renderOrderLinesEmptyState() {
|
||||||
@ -1249,7 +1417,7 @@
|
|||||||
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
||||||
const profile = profileRes.ok ? await profileRes.json() : {};
|
const profile = profileRes.ok ? await profileRes.json() : {};
|
||||||
const configured = JSON.parse(setting.value || '[]');
|
const configured = JSON.parse(setting.value || '[]');
|
||||||
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
|
const types = Array.isArray(configured) && configured.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels);
|
||||||
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
||||||
if (!types.includes('abonnement')) types.push('abonnement');
|
if (!types.includes('abonnement')) types.push('abonnement');
|
||||||
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
|
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
|
||||||
@ -1279,18 +1447,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Initialization ---
|
// --- Initialization ---
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initializeSearch();
|
initializeSearch();
|
||||||
loadCaseTypesSelect();
|
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
|
||||||
loadPipelineStages();
|
|
||||||
selectCurrentUserAsResponsible();
|
|
||||||
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
|
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
|
||||||
applyTelefoniPrefill();
|
await applyTelefoniPrefill();
|
||||||
|
await window.caseCreateUI?.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Form Submission ---
|
// --- Form Submission ---
|
||||||
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
|
||||||
|
|
||||||
// UI Reset
|
// UI Reset
|
||||||
const errorDiv = document.getElementById('error');
|
const errorDiv = document.getElementById('error');
|
||||||
@ -1371,7 +1539,7 @@
|
|||||||
data.internet_connection_ids = [telefoniPrefill.internetConnectionId];
|
data.internet_connection_ids = [telefoniPrefill.internetConnectionId];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === 'pipeline') {
|
if (['pipeline_stage_id', 'pipeline_amount', 'pipeline_probability', 'pipeline_description'].some(id => document.getElementById(id).value !== '')) {
|
||||||
data.pipeline = {
|
data.pipeline = {
|
||||||
stage_id: document.getElementById('pipeline_stage_id').value || null,
|
stage_id: document.getElementById('pipeline_stage_id').value || null,
|
||||||
amount: document.getElementById('pipeline_amount').value || null,
|
amount: document.getElementById('pipeline_amount').value || null,
|
||||||
@ -1379,9 +1547,10 @@
|
|||||||
description: document.getElementById('pipeline_description').value || null
|
description: document.getElementById('pipeline_description').value || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (data.type === 'ordre') {
|
if (document.querySelector('#orderLines .order-line')) {
|
||||||
data.order_items = collectOrderItems();
|
data.order_items = collectOrderItems();
|
||||||
}
|
}
|
||||||
|
Object.assign(data, window.caseCreateUI?.relations() || {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/sag', {
|
const response = await fetch('/api/v1/sag', {
|
||||||
@ -1393,6 +1562,7 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
window.caseCreateUI?.created(result);
|
||||||
|
|
||||||
successDiv.classList.remove('d-none');
|
successDiv.classList.remove('d-none');
|
||||||
document.getElementById('success-text').textContent = "Sag oprettet";
|
document.getElementById('success-text').textContent = "Sag oprettet";
|
||||||
@ -1416,4 +1586,5 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/js/case-create.js?v=5"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
30
app/settings/frontend/case_templates.html
Normal file
30
app/settings/frontend/case_templates.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<div id="case-template-admin">
|
||||||
|
<h5>Sagsskabeloner</h5>
|
||||||
|
<p class="text-muted">Fælles hurtigskabeloner til Opret ny sag.</p>
|
||||||
|
<div id="cta-message" role="status"></div>
|
||||||
|
<div id="cta-list" class="list-group mb-3"></div>
|
||||||
|
<button type="button" id="cta-new" class="btn btn-outline-primary mb-3">Ny skabelon</button>
|
||||||
|
<form id="cta-form" class="card card-body">
|
||||||
|
<input id="cta-id" type="hidden">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6"><label for="cta-name" class="form-label">Navn</label><input id="cta-name" class="form-control" required maxlength="120"></div>
|
||||||
|
<div class="col-md-3"><label for="cta-icon" class="form-label">Ikon</label><select id="cta-icon" class="form-select"><option value="bi-lightning">Lyn</option><option value="bi-ticket">Ticket</option><option value="bi-router">Netværk</option><option value="bi-person-plus">Medarbejder</option><option value="bi-pc-display">Hardware</option><option value="bi-graph-up">Tilbud</option></select></div>
|
||||||
|
<div class="col-md-3"><label for="cta-sort" class="form-label">Rækkefølge</label><input id="cta-sort" type="number" step="1" class="form-control" value="0"></div>
|
||||||
|
<div class="col-md-4"><label for="cta-type" class="form-label">Sagstype</label><select id="cta-type" class="form-select"></select></div>
|
||||||
|
<div class="col-md-4"><label for="cta-status" class="form-label">Status</label><select id="cta-status" class="form-select"><option value="åben">Åben</option><option value="afventer">Afventer</option><option value="lukket">Lukket</option></select></div>
|
||||||
|
<div class="col-md-4"><label for="cta-group" class="form-label">Ansvarlig gruppe</label><select id="cta-group" class="form-select"><option value="">Ingen</option></select></div>
|
||||||
|
<div class="col-12"><label for="cta-title" class="form-label">Titel</label><input id="cta-title" class="form-control" maxlength="1000"></div>
|
||||||
|
<div class="col-12"><label for="cta-description" class="form-label">Beskrivelse</label><textarea id="cta-description" rows="5" class="form-control"></textarea></div>
|
||||||
|
<div class="col-12"><div id="cta-tags" class="mb-2"></div><button type="button" id="cta-add-tag" class="btn btn-sm btn-outline-primary">Tilføj tag</button></div>
|
||||||
|
<div class="col-12"><details><summary>Pipeline-standarder</summary><div class="row g-3 mt-1">
|
||||||
|
<div class="col-md-4"><label for="cta-stage" class="form-label">Stage</label><select id="cta-stage" class="form-select"><option value="">Ikke sat</option></select></div>
|
||||||
|
<div class="col-md-4"><label for="cta-amount" class="form-label">Beløb</label><input id="cta-amount" type="number" min="0" step="0.01" class="form-control"></div>
|
||||||
|
<div class="col-md-4"><label for="cta-probability" class="form-label">Sandsynlighed (%)</label><input id="cta-probability" type="number" min="0" max="100" step="1" class="form-control"></div>
|
||||||
|
<div class="col-12"><label for="cta-pipeline-description" class="form-label">Pipelinebeskrivelse</label><textarea id="cta-pipeline-description" class="form-control"></textarea></div>
|
||||||
|
</div></details></div>
|
||||||
|
<div class="col-12"><label><input id="cta-active" type="checkbox" checked> Aktiv</label></div>
|
||||||
|
<div class="col-12"><button type="submit" class="btn btn-primary">Gem skabelon</button></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/case-template-admin.js?v=1"></script>
|
||||||
@ -192,6 +192,7 @@
|
|||||||
<a class="nav-link" href="#task-templates" data-tab="task-templates">
|
<a class="nav-link" href="#task-templates" data-tab="task-templates">
|
||||||
<i class="bi bi-list-check me-2"></i>Opgave-templates
|
<i class="bi bi-list-check me-2"></i>Opgave-templates
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="#case-templates" data-tab="case-templates"><i class="bi bi-lightning me-2"></i>Sagsskabeloner</a>
|
||||||
<a class="nav-link" href="/admin/bmc-office-upload">
|
<a class="nav-link" href="/admin/bmc-office-upload">
|
||||||
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
|
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
|
||||||
</a>
|
</a>
|
||||||
@ -675,6 +676,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Templates -->
|
<!-- Task Templates -->
|
||||||
|
<div class="tab-pane fade" id="case-templates">
|
||||||
|
{% include "settings/frontend/case_templates.html" %}
|
||||||
|
</div>
|
||||||
<div class="tab-pane fade" id="task-templates">
|
<div class="tab-pane fade" id="task-templates">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1440,6 +1440,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
|
<link rel="stylesheet" href="/static/messages.css?v=1">
|
||||||
</head>
|
</head>
|
||||||
{% set _xff = request.headers.get('x-forwarded-for') if request and request.headers else '' %}
|
{% set _xff = request.headers.get('x-forwarded-for') if request and request.headers else '' %}
|
||||||
{% set _xff_first = _xff.split(',')[0].strip() if _xff else '' %}
|
{% set _xff_first = _xff.split(',')[0].strip() if _xff else '' %}
|
||||||
@ -1996,13 +1997,15 @@ if (bmcOriginalFetch) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/static/js/tag-picker.js?v=2.2"></script>
|
<script src="/static/js/tag-picker.js?v=2.3"></script>
|
||||||
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
||||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.5"></script>
|
<script src="/static/js/telefoni.js?v=2.5"></script>
|
||||||
<script src="/static/js/sms.js?v=1.1"></script>
|
<script src="/static/js/sms.js?v=1.1"></script>
|
||||||
<script src="/static/js/bug-report.js?v=1.4"></script>
|
<script src="/static/js/bug-report.js?v=1.4"></script>
|
||||||
<script src="/static/js/bottom-bar.js?v=2.69"></script>
|
{% include "shared/frontend/internal_message.html" %}
|
||||||
|
<script src="/static/js/message-ui.js?v=1"></script>
|
||||||
|
<script src="/static/js/bottom-bar.js?v=2.71"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
window.BMC_CAN_CLICK_TO_CALL = true;
|
window.BMC_CAN_CLICK_TO_CALL = true;
|
||||||
|
|||||||
20
app/shared/frontend/internal_message.html
Normal file
20
app/shared/frontend/internal_message.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<div class="modal fade" id="internalMessageModal" tabindex="-1" aria-labelledby="internalMessageTitle" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable"><div class="modal-content rounded-4 border-0 shadow">
|
||||||
|
<div class="modal-header border-0 px-4 pt-4"><div><span class="text-uppercase small text-muted">BMC · INTERNE BESKEDER</span><h4 id="internalMessageTitle" class="mb-0">Hvad skal din kollega vide?</h4></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div>
|
||||||
|
<div class="modal-body px-4"><form id="internalMessageForm">
|
||||||
|
<div class="d-flex gap-2 mb-4" role="group" aria-label="Beskedtype"><button type="button" class="btn btn-primary" data-internal-kind="message" aria-pressed="true"><i class="bi bi-chat-text me-2"></i>Kort besked</button><button type="button" class="btn btn-outline-primary" data-internal-kind="phone" aria-pressed="false"><i class="bi bi-telephone me-2"></i>Telefonbesked</button></div>
|
||||||
|
<div id="im-error" class="alert alert-danger d-none" role="alert"></div>
|
||||||
|
<label for="im-recipient" class="form-label">Modtager <span class="text-danger">*</span></label><select id="im-recipient" class="form-select mb-3" required><option value="">Vælg medarbejder…</option></select>
|
||||||
|
<label for="im-text" class="form-label">Besked <span class="text-danger">*</span></label><textarea id="im-text" rows="4" maxlength="2000" required class="form-control mb-3" placeholder="Skriv din besked her…"></textarea>
|
||||||
|
<details id="im-contact-panel" class="mb-3"><summary class="small text-muted mb-2"><i class="bi bi-person-plus me-1"></i>Tilknyt en kontaktperson <span class="small">· valgfrit</span></summary>
|
||||||
|
<label for="im-contact-search" class="form-label">Kontaktperson <small class="text-muted fw-normal">· valgfrit</small></label>
|
||||||
|
<div class="position-relative mb-3"><input id="im-contact-search" class="form-control" placeholder="Søg navn, telefon eller e-mail…" autocomplete="off"><div id="im-contact-results" class="list-group shadow-sm mt-1 d-none" style="max-height:200px;overflow:auto"></div><div id="im-contact" class="mt-2"></div></div>
|
||||||
|
</details>
|
||||||
|
<div id="im-phone-fields" class="row g-3 mb-3 d-none"><div class="col-sm-6"><label for="im-caller" class="form-label">Hvem ringede?</label><input id="im-caller" class="form-control" maxlength="200" placeholder="Navn, også uden kontaktkort"></div><div class="col-sm-6"><label for="im-phone" class="form-label">Telefonnummer til tilbageringning</label><input id="im-phone" type="tel" class="form-control" maxlength="80" placeholder="Telefonnummer"></div></div>
|
||||||
|
<label class="form-check mt-3"><input id="im-ack" type="checkbox" class="form-check-input"><span class="form-check-label small">Bed modtageren bekræfte, at beskeden er læst</span></label>
|
||||||
|
<p class="small text-muted mt-3 mb-0">Beskeden leveres i kollegaens interne beskeder i bundlinjen.</p>
|
||||||
|
</form></div>
|
||||||
|
<div class="modal-footer border-0 px-4 pb-4"><button type="button" class="btn btn-light border" data-bs-dismiss="modal">Luk</button><button type="submit" form="internalMessageForm" id="im-send" class="btn btn-primary px-4"><i class="bi bi-send me-2"></i>Send besked</button></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/internal-message.js?v=2"></script>
|
||||||
2
main.py
2
main.py
@ -502,6 +502,8 @@ app.include_router(bug_reports_api.router, prefix="/api/v1", tags=["Bug Reports"
|
|||||||
# Module Routers
|
# Module Routers
|
||||||
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
|
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
|
||||||
app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"])
|
app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"])
|
||||||
|
from app.modules.sag.backend.create_support import router as case_create_support_router
|
||||||
|
app.include_router(case_create_support_router, prefix="/api/v1", tags=["Cases"])
|
||||||
app.include_router(sag_reminders_api.router, tags=["Reminders"]) # No prefix - endpoints have full path
|
app.include_router(sag_reminders_api.router, tags=["Reminders"]) # No prefix - endpoints have full path
|
||||||
app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"])
|
app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"])
|
||||||
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
|
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
|
||||||
|
|||||||
10
migrations/238_case_create_templates.sql
Normal file
10
migrations/238_case_create_templates.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS case_create_templates (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
icon VARCHAR(80) NOT NULL DEFAULT 'bi-lightning',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
template_values JSONB NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(template_values) = 'object'),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
5
migrations/239_internal_phone_messages.sql
Normal file
5
migrations/239_internal_phone_messages.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE bottom_bar_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
|
||||||
|
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT '';
|
||||||
@ -47,6 +47,7 @@
|
|||||||
activeThreadKey: ''
|
activeThreadKey: ''
|
||||||
};
|
};
|
||||||
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
|
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
|
||||||
|
window.addEventListener('hub:internal-message-sent', function () { fetchBottomBarState(); });
|
||||||
let notesApiUnavailable = false;
|
let notesApiUnavailable = false;
|
||||||
let driftSummaryRefreshTimer = null;
|
let driftSummaryRefreshTimer = null;
|
||||||
let markMessagesReadPromise = null;
|
let markMessagesReadPromise = null;
|
||||||
@ -198,7 +199,7 @@
|
|||||||
|
|
||||||
messageItems.forEach(function (item) {
|
messageItems.forEach(function (item) {
|
||||||
const own = !!item.is_own;
|
const own = !!item.is_own;
|
||||||
const partnerId = own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0);
|
const partnerId = item.recipient_user_id == null ? 0 : (own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0));
|
||||||
const key = partnerId > 0 ? ('user:' + partnerId) : 'broadcast';
|
const key = partnerId > 0 ? ('user:' + partnerId) : 'broadcast';
|
||||||
const label = partnerId > 0
|
const label = partnerId > 0
|
||||||
? String(own ? (item.to || ('Bruger #' + partnerId)) : (item.from || ('Bruger #' + partnerId)))
|
? String(own ? (item.to || ('Bruger #' + partnerId)) : (item.from || ('Bruger #' + partnerId)))
|
||||||
@ -309,20 +310,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRenderableMessageThreads(activeThread) {
|
function getRenderableMessageThreads(activeThread) {
|
||||||
const seen = new Set();
|
const out = getMessageThreads();
|
||||||
const out = [];
|
if (activeThread?.key && !out.some(thread => thread.key === activeThread.key)) out.unshift(activeThread);
|
||||||
|
|
||||||
if (activeThread && activeThread.key) {
|
|
||||||
out.push(activeThread);
|
|
||||||
seen.add(activeThread.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
getMessageThreads().forEach(function (thread) {
|
|
||||||
if (seen.has(thread.key)) return;
|
|
||||||
seen.add(thread.key);
|
|
||||||
out.push(thread);
|
|
||||||
});
|
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -917,39 +906,8 @@
|
|||||||
const activeThread = ensureActiveMessageThread();
|
const activeThread = ensureActiveMessageThread();
|
||||||
syncChatRecipientToActiveThread(activeThread);
|
syncChatRecipientToActiveThread(activeThread);
|
||||||
const messageItems = activeThread && Array.isArray(activeThread.items) ? activeThread.items : [];
|
const messageItems = activeThread && Array.isArray(activeThread.items) ? activeThread.items : [];
|
||||||
if (messageItems.length > 0) {
|
if (messageItems.length > 0) return messageItems.map(window.BmcMessageUI.message);
|
||||||
return messageItems.map(function (m) {
|
return ['<div class="msg-empty"><span><i class="bi bi-chat-square-text"></i></span><strong>Her starter samtalen</strong><p>Send en kort besked eller giv en telefonbesked videre.</p></div>'];
|
||||||
const own = !!m.is_own;
|
|
||||||
const unreadBadge = m.is_unread ? ' <span class="badge text-bg-warning ms-1">Ny</span>' : '';
|
|
||||||
const importantBadge = m.requires_manual_ack ? ' <span class="badge text-bg-danger ms-1">Vigtig</span>' : '';
|
|
||||||
const ackBadge = own && m.requires_manual_ack
|
|
||||||
? (m.is_acknowledged
|
|
||||||
? ' <span class="badge text-bg-success ms-1">Bekræftet læst</span>'
|
|
||||||
: ' <span class="badge text-bg-secondary ms-1">Afventer læst-bekræftelse</span>')
|
|
||||||
: '';
|
|
||||||
const targetMeta = own && m.to ? '<div class="small text-muted mb-1">Til: ' + esc(m.to) + '</div>' : '';
|
|
||||||
const replyTargetId = own ? Number(m.recipient_user_id || 0) : Number(m.sender_user_id || 0);
|
|
||||||
const replyBtn = replyTargetId > 0
|
|
||||||
? '<div class="mt-2"><button type="button" class="btn btn-sm ' + (own ? 'btn-light' : 'btn-outline-secondary') + '" data-bb-reply-message="' + Number(m.id || 0) + '"><i class="bi bi-reply me-1"></i>Svar</button></div>'
|
|
||||||
: '';
|
|
||||||
const ackBtn = (!own && m.requires_manual_ack && m.is_unread)
|
|
||||||
? '<div class="mt-2"><button type="button" class="btn btn-sm btn-danger" data-bb-ack-message="' + Number(m.id || 0) + '"><i class="bi bi-check2-circle me-1"></i>Bekræft læst</button></div>'
|
|
||||||
: '';
|
|
||||||
return ''
|
|
||||||
+ '<div class="' + (own ? 'text-end' : '') + '">'
|
|
||||||
+ targetMeta
|
|
||||||
+ '<div class="d-inline-block ' + (own ? 'bg-primary text-white' : (m.requires_manual_ack ? 'bg-warning-subtle border border-warning-subtle' : 'bg-light')) + ' p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;">'
|
|
||||||
+ '<strong class="' + (own ? 'text-white' : 'text-accent') + '">' + esc(m.from) + ':</strong> '
|
|
||||||
+ esc(m.text)
|
|
||||||
+ unreadBadge
|
|
||||||
+ importantBadge
|
|
||||||
+ ackBadge
|
|
||||||
+ replyBtn
|
|
||||||
+ ackBtn
|
|
||||||
+ '</div></div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ['Ingen beskeder i denne tråd endnu.'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'tasks') {
|
if (key === 'tasks') {
|
||||||
@ -1229,6 +1187,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let messageFocusState = null;
|
let messageFocusState = null;
|
||||||
|
const previousMessages = innerContent.querySelector('.bb-messages-list');
|
||||||
|
const previousScroll = previousMessages ? {key:previousMessages.dataset.threadKey, top:previousMessages.scrollTop, bottom:previousMessages.scrollHeight-previousMessages.scrollTop-previousMessages.clientHeight < 40} : null;
|
||||||
if (activeKey === 'messages') {
|
if (activeKey === 'messages') {
|
||||||
const activeEl = document.activeElement;
|
const activeEl = document.activeElement;
|
||||||
const activeId = activeEl && activeEl.id ? activeEl.id : '';
|
const activeId = activeEl && activeEl.id ? activeEl.id : '';
|
||||||
@ -1329,17 +1289,18 @@
|
|||||||
const threadItems = getRenderableMessageThreads(activeThread);
|
const threadItems = getRenderableMessageThreads(activeThread);
|
||||||
syncChatRecipientToActiveThread(activeThread);
|
syncChatRecipientToActiveThread(activeThread);
|
||||||
|
|
||||||
if (threadItems.length > 0) {
|
{
|
||||||
const threadList = document.createElement('div');
|
const threadList = document.createElement('div');
|
||||||
threadList.className = 'bb-message-threads';
|
threadList.className = 'bb-message-threads';
|
||||||
|
threadList.innerHTML = '<div class="msg-inbox-heading"><span>Samtaler</span><button type="button" title="Ny besked" aria-label="Ny besked" onclick="window.openInternalMessage()"><i class="bi bi-pencil-square"></i></button></div>';
|
||||||
|
if (!threadItems.length) threadList.insertAdjacentHTML('beforeend', '<p class="small text-muted px-2">Dine samtaler vises her.</p>');
|
||||||
threadItems.forEach(function (thread) {
|
threadItems.forEach(function (thread) {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'bb-message-thread' + (activeThread && thread.key === activeThread.key ? ' is-active' : '');
|
button.className = 'bb-message-thread' + (activeThread && thread.key === activeThread.key ? ' is-active' : '');
|
||||||
button.setAttribute('data-bb-thread-key', thread.key);
|
button.setAttribute('data-bb-thread-key', thread.key);
|
||||||
button.innerHTML = ''
|
button.setAttribute('aria-current', activeThread && thread.key === activeThread.key ? 'true' : 'false');
|
||||||
+ '<span class="bb-message-thread-label">' + escapeHtml(thread.label || 'Samtale') + '</span>'
|
button.innerHTML = window.BmcMessageUI.thread(thread);
|
||||||
+ (thread.unread > 0 ? '<span class="bb-message-thread-count">' + Number(thread.unread || 0) + '</span>' : '');
|
|
||||||
threadList.appendChild(button);
|
threadList.appendChild(button);
|
||||||
});
|
});
|
||||||
chatContainer.appendChild(threadList);
|
chatContainer.appendChild(threadList);
|
||||||
@ -1347,6 +1308,9 @@
|
|||||||
|
|
||||||
const replyBox = document.createElement('div');
|
const replyBox = document.createElement('div');
|
||||||
replyBox.className = 'bb-messages-composer';
|
replyBox.className = 'bb-messages-composer';
|
||||||
|
const conversation = document.createElement('div');
|
||||||
|
conversation.className = 'msg-conversation';
|
||||||
|
conversation.innerHTML = '<header class="msg-conversation-header"><div><strong>' + escapeHtml(activeThread?.label || 'Vælg en samtale') + '</strong><small>' + (!activeThread ? 'Brug blyanten til at skrive til en kollega' : activeThread.partnerUserId ? 'Intern samtale' : 'Fælles beskeder til alle på vagt') + '</small></div><button type="button" class="msg-header-action" onclick="window.openInternalMessage({kind:\'phone\',recipient:' + Number(activeThread?.partnerUserId || 0) + '})"><i class="bi bi-telephone-plus"></i><span>Telefonbesked</span></button></header>';
|
||||||
const threadMeta = activeThread
|
const threadMeta = activeThread
|
||||||
? '<div class="small text-muted mb-2"><i class="bi bi-chat-square-text me-1"></i>'
|
? '<div class="small text-muted mb-2"><i class="bi bi-chat-square-text me-1"></i>'
|
||||||
+ (activeThread.partnerUserId ? ('Samtale med ' + escapeHtml(activeThread.label || 'Bruger')) : 'Besked til alle på vagt')
|
+ (activeThread.partnerUserId ? ('Samtale med ' + escapeHtml(activeThread.label || 'Bruger')) : 'Besked til alle på vagt')
|
||||||
@ -1359,27 +1323,23 @@
|
|||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
replyBox.innerHTML = `
|
replyBox.innerHTML = `
|
||||||
${threadMeta}
|
|
||||||
${replyBanner}
|
${replyBanner}
|
||||||
<div class="input-group input-group-sm mb-1">
|
<select id="chatRecipient" hidden aria-label="Modtager">
|
||||||
<span class="input-group-text bg-light text-muted border-0"><i class="bi bi-person"></i></span>
|
|
||||||
<select id="chatRecipient" class="form-select border-0 bg-light">
|
|
||||||
<option value="all">Alle på vagt</option>
|
<option value="all">Alle på vagt</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="msg-compose-field">
|
||||||
|
<textarea id="chatInputQuick" rows="2" maxlength="2000" aria-label="Besked" ${!activeThread?'disabled':''} placeholder="${activeThread?'Skriv til '+escapeHtml(activeThread.label)+'…':'Vælg en samtale eller opret en ny besked'}">${escapeHtml(chatComposerState.draft || '')}</textarea>
|
||||||
|
<button type="button" class="msg-send" id="btnSendMsg" aria-label="Send besked" ${!activeThread?'disabled':''}><i class="bi bi-arrow-up"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<label class="form-check form-switch small mb-2">
|
<div class="msg-compose-footer"><label><input type="checkbox" id="chatRequiresAck" ${chatComposerState.requiresManualAck ? 'checked' : ''}> Bed om læsebekræftelse</label><small>Ctrl / ⌘ + Enter for at sende</small></div>
|
||||||
<input class="form-check-input" type="checkbox" id="chatRequiresAck" ${chatComposerState.requiresManualAck ? 'checked' : ''}>
|
|
||||||
<span class="form-check-label">Kræv manuel læst-bekræftelse</span>
|
|
||||||
</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked..." value="${escapeHtml(chatComposerState.draft || '')}">
|
|
||||||
<button class="btn btn-outline-primary btn-sm" id="btnSendMsg"><i class="bi bi-send"></i></button>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
chatContainer.appendChild(ul);
|
conversation.appendChild(ul);
|
||||||
chatContainer.appendChild(replyBox);
|
conversation.appendChild(replyBox);
|
||||||
|
chatContainer.appendChild(conversation);
|
||||||
innerContent.appendChild(chatContainer);
|
innerContent.appendChild(chatContainer);
|
||||||
|
ul.dataset.threadKey = activeThread?.key || '';
|
||||||
|
window.requestAnimationFrame(function () { ul.scrollTop = previousScroll && previousScroll.key === ul.dataset.threadKey && !previousScroll.bottom ? previousScroll.top : ul.scrollHeight; });
|
||||||
|
|
||||||
const recipientSelect = document.getElementById('chatRecipient');
|
const recipientSelect = document.getElementById('chatRecipient');
|
||||||
const input = document.getElementById('chatInputQuick');
|
const input = document.getElementById('chatInputQuick');
|
||||||
@ -1396,6 +1356,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (input) {
|
if (input) {
|
||||||
|
input.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); event.stopPropagation(); document.getElementById('btnSendMsg')?.click(); }
|
||||||
|
});
|
||||||
input.addEventListener('input', function () {
|
input.addEventListener('input', function () {
|
||||||
chatComposerState.draft = input.value || '';
|
chatComposerState.draft = input.value || '';
|
||||||
});
|
});
|
||||||
@ -3513,7 +3476,7 @@
|
|||||||
if (activeKey === 'messages') {
|
if (activeKey === 'messages') {
|
||||||
renderTabPanel();
|
renderTabPanel();
|
||||||
}
|
}
|
||||||
const tabInner = document.getElementById('bbTabInnerContent');
|
const tabInner = document.querySelector('.bb-messages-list');
|
||||||
if (tabInner) {
|
if (tabInner) {
|
||||||
tabInner.scrollTop = tabInner.scrollHeight + 500;
|
tabInner.scrollTop = tabInner.scrollHeight + 500;
|
||||||
}
|
}
|
||||||
|
|||||||
256
static/js/case-create.js
Normal file
256
static/js/case-create.js
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
/* Progressive case creation. Existing searches and submit retain their API contracts. */
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const state = {tags: [], hardware: [], tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
|
||||||
|
let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = [];
|
||||||
|
async function api(url, options) {
|
||||||
|
const response = await fetch(url, {credentials: 'include', ...options});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(typeof body.detail === 'string' ? body.detail : 'Kunne ikke hente oplysninger');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
function panel(id, title, elements, open = false) {
|
||||||
|
const detail = document.createElement('details');
|
||||||
|
detail.className = 'cc-panel'; detail.id = `cc-${id}`; detail.open = open;
|
||||||
|
const icon = {relations:'bi-people',tags:'bi-tags',hardware:'bi-pc-display',pipeline:'bi-graph-up-arrow',order:'bi-bag',metadata:'bi-sliders'}[id];
|
||||||
|
detail.innerHTML = `<summary><span class="cc-panel-icon"><i class="bi ${icon}" aria-hidden="true"></i></span><span class="cc-panel-label">${esc(title)}<small class="cc-summary"></small></span></summary><div class="cc-panel-body"></div>`;
|
||||||
|
elements[0].before(detail);
|
||||||
|
elements.forEach(el => detail.lastElementChild.append(el));
|
||||||
|
state.panels[id] = detail;
|
||||||
|
detail.addEventListener('toggle', scheduleSave);
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
function summaries() {
|
||||||
|
const values = {
|
||||||
|
relations: `${selectedCustomer?.name || 'Vælg firma'} · ${Object.keys(selectedContacts).length} kontakter`,
|
||||||
|
tags: state.tags.map(t => t.name).join(', ') || 'Ingen tags',
|
||||||
|
hardware: `${state.hardware.length} enheder valgt`,
|
||||||
|
pipeline: [$('pipeline_stage_id').selectedOptions[0]?.text, $('pipeline_amount').value ? `${$('pipeline_amount').value} kr.` : ''].filter(Boolean).join(' · '),
|
||||||
|
order: `${document.querySelectorAll('#orderLines .order-line').length} linjer`,
|
||||||
|
metadata: [$('status').selectedOptions[0]?.text, $('ansvarlig_bruger_id').selectedOptions[0]?.text, $('deadline').value.replace('T', ' ')].filter(Boolean).join(' · ')
|
||||||
|
};
|
||||||
|
Object.entries(values).forEach(([key, value]) => { if (state.panels[key]) state.panels[key].querySelector('.cc-summary').textContent = value; });
|
||||||
|
}
|
||||||
|
function fieldSnapshot() {
|
||||||
|
return Object.fromEntries(Array.from($('createForm').querySelectorAll('input[id],select[id],textarea[id]')).filter(el => !['customerSearch','contactSearch'].includes(el.id)).map(el => [el.id, el.type === 'checkbox' ? el.checked : el.value]));
|
||||||
|
}
|
||||||
|
function snapshot() {
|
||||||
|
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
|
||||||
|
tags: state.tags, hardware: state.hardware, orders: collectOrderItems(), prefill: telefoniPrefill,
|
||||||
|
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
|
||||||
|
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
|
||||||
|
}
|
||||||
|
function save() {
|
||||||
|
if (!state.ready || state.pending || state.restoring || state.completed || !state.key) return;
|
||||||
|
try { localStorage.setItem(state.key, JSON.stringify(snapshot())); $('cc-save').textContent = 'Kladde gemt lokalt'; }
|
||||||
|
catch (error) { console.error('Kunne ikke gemme kladde', error); $('cc-save').textContent = 'Kladde kunne ikke gemmes i browseren'; }
|
||||||
|
}
|
||||||
|
function scheduleSave() {
|
||||||
|
summaries();
|
||||||
|
if (!state.ready || state.pending || state.restoring || state.completed) return;
|
||||||
|
$('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500);
|
||||||
|
}
|
||||||
|
function renderTags() {
|
||||||
|
$('cc-tag-chips').innerHTML = state.tags.map(t => `<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();
|
||||||
|
renderTagSuggestions();
|
||||||
|
}
|
||||||
|
function renderTagSuggestions() {
|
||||||
|
const box = $('cc-tag-suggestions');
|
||||||
|
if (!box) return;
|
||||||
|
const haystack = `${$('titel')?.value || ''} ${$('beskrivelse')?.value || ''}`.toLocaleLowerCase('da-DK');
|
||||||
|
if (haystack.trim().length < 3 || !state.tagCatalog.length) { box.innerHTML = ''; return; }
|
||||||
|
const selected = new Set(state.tags.map(tag => Number(tag.id)));
|
||||||
|
const suggestions = state.tagCatalog.filter(tag => {
|
||||||
|
if (selected.has(Number(tag.id)) || !['brand', 'type'].includes(tag.type)) return false;
|
||||||
|
const words = Array.isArray(tag.catch_words) ? tag.catch_words : [];
|
||||||
|
return words.some(word => String(word).trim() && haystack.includes(String(word).trim().toLocaleLowerCase('da-DK')));
|
||||||
|
}).slice(0, 8);
|
||||||
|
box.innerHTML = suggestions.length ? `<div class="cc-tag-suggestions__label">Foreslået ud fra teksten</div><div class="d-flex flex-wrap gap-1">${suggestions.map(tag => `<button type="button" class="btn btn-sm btn-outline-primary" data-suggested-tag="${Number(tag.id)}" title="Tilføj ${esc(tag.name)}">${tag.icon ? `<i class="bi ${esc(tag.icon)}"></i> ` : ''}${esc(tag.name)} <i class="bi bi-plus-lg"></i></button>`).join('')}</div>` : '';
|
||||||
|
}
|
||||||
|
function scheduleTagSuggestions() {
|
||||||
|
clearTimeout(tagSuggestionTimer);
|
||||||
|
tagSuggestionTimer = setTimeout(renderTagSuggestions, 350);
|
||||||
|
}
|
||||||
|
async function addTag(tag) {
|
||||||
|
if (!state.tags.some(t => t.id === tag.id)) {
|
||||||
|
const groups = await api('/api/v1/tags/groups').catch(() => []);
|
||||||
|
if (groups.some(g => g.id === tag.tag_group_id && ['single','toggle'].includes(g.behavior))) state.tags = state.tags.filter(t => t.tag_group_id !== tag.tag_group_id);
|
||||||
|
state.tags.push(tag); renderTags(); scheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderSelectedHardware() {
|
||||||
|
$('cc-hardware-chips').innerHTML = state.hardware.map(h => `<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();
|
||||||
|
}
|
||||||
|
async function duplicates() {
|
||||||
|
const token = ++duplicateToken, customerId = selectedCustomer?.id, title = $('titel').value.trim();
|
||||||
|
$('cc-duplicates').innerHTML = '';
|
||||||
|
if (!customerId || title.length < 5) return;
|
||||||
|
try {
|
||||||
|
const rows = await api(`/api/v1/case-create/duplicates?customer_id=${customerId}&title=${encodeURIComponent(title)}`);
|
||||||
|
if (token !== duplicateToken) return;
|
||||||
|
$('cc-duplicates').innerHTML = rows.length ? `<div class="alert alert-warning"><strong>Mulige eksisterende sager</strong>${rows.map(r => `<div><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small> · ${esc(r.status)} · ${esc(r.ansvarlig_navn || 'Ingen ansvarlig')}</small></div>`).join('')}</div>` : '';
|
||||||
|
} catch { if (token === duplicateToken) $('cc-duplicates').textContent = 'Duplikatkontrol er midlertidigt utilgængelig. Du kan stadig oprette sagen.'; }
|
||||||
|
}
|
||||||
|
function scheduleDuplicates() { ++duplicateToken; clearTimeout(duplicateTimer); duplicateTimer = setTimeout(duplicates, 500); }
|
||||||
|
async function contactOpenCases() {
|
||||||
|
const box = $('cc-contact-cases');
|
||||||
|
if (!box) return;
|
||||||
|
const contacts = Object.values(selectedContacts);
|
||||||
|
const token = ++contactCasesToken;
|
||||||
|
if (!contacts.length) { box.hidden = true; box.innerHTML = ''; return; }
|
||||||
|
box.hidden = false;
|
||||||
|
box.innerHTML = '<span class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Tjekker kontaktens åbne sager…</span>';
|
||||||
|
const query = contacts.map(contact => `contact_ids=${encodeURIComponent(contact.id)}`).join('&');
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/v1/case-create/contacts-open-cases?${query}`);
|
||||||
|
if (token !== contactCasesToken) return;
|
||||||
|
const groups = contacts.map(contact => ({contact, data: data[contact.id] || {total:0, items:[]}}));
|
||||||
|
const total = groups.reduce((sum, group) => sum + Number(group.data.total || 0), 0);
|
||||||
|
const title = total ? `${total} åben${total === 1 ? '' : 'ne'} sag${total === 1 ? '' : 'er'} på valgt kontakt` : 'Ingen åbne sager på valgt kontakt';
|
||||||
|
box.innerHTML = `<div class="cc-contact-cases__header"><span class="cc-contact-cases__header-icon"><i class="bi ${total ? 'bi-clipboard2-check' : 'bi-check2-circle'}"></i></span><div><span class="cc-contact-cases__eyebrow">Dublettjek</span><span class="cc-contact-cases__title">${title}</span></div></div>${total ? `<div class="cc-contact-cases__body">${groups.map(({contact,data}) => data.total ? `<div class="cc-contact-cases__group"><span class="cc-contact-cases__group-name">${esc(contact.name)}</span>${data.items.map(item => `<div class="cc-contact-cases__item"><a href="/sag/${Number(item.id)}/v3" target="_blank" rel="noopener">SAG-${Number(item.id)} · ${esc(item.titel)}</a><small>${esc(item.status || '')} · ${esc(item.ansvarlig_navn || 'Ingen ansvarlig')}${item.deadline ? ` · ${esc(new Date(item.deadline).toLocaleDateString('da-DK'))}` : ''}</small></div>`).join('')}${data.total > data.items.length ? `<span class="cc-contact-cases__more">Viser ${data.items.length} af ${data.total} åbne sager</span>` : ''}</div>` : '').join('')}</div>` : '<div class="cc-contact-cases__empty">Du kan oprette sagen uden risiko for en kendt, åben sag på kontakten.</div>'}`;
|
||||||
|
} catch {
|
||||||
|
if (token === contactCasesToken) box.innerHTML = '<span class="text-muted">Kunne ikke tjekke kontaktens åbne sager. Du kan stadig oprette sagen.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function workload() {
|
||||||
|
const token = ++workloadToken, id = $('ansvarlig_bruger_id').value;
|
||||||
|
$('cc-workload').textContent = id ? 'Henter åbne sager…' : 'Vælg en ansvarlig for at se åbne sager.';
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/v1/case-create/workload?user_id=${id}`);
|
||||||
|
if (token !== workloadToken) return;
|
||||||
|
$('cc-workload').innerHTML = `<details><summary>${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}</summary>${data.items.map(r => `<div class="cc-work-item"><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small>${esc(r.customer_name || '')} · ${esc(r.status)} <span class="${r.deadline && new Date(r.deadline) < new Date() ? 'text-danger' : ''}">${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}</span></small></div>`).join('') || '<p class="text-success">Ingen åbne sager.</p>'}${data.total > 10 ? `<a href="/sag?ansvarlig_bruger_id=${id}" target="_blank" rel="noopener">Se alle ${data.total} sager</a>` : ''}</details>`;
|
||||||
|
} catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; }
|
||||||
|
}
|
||||||
|
function typeChanged(type) {
|
||||||
|
document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); });
|
||||||
|
const key = {ticket:'hardware', pipeline:'pipeline', ordre:'order'}[type];
|
||||||
|
if (key && state.panels[key] && !state.restoring) state.panels[key].open = true;
|
||||||
|
if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`));
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
function buildTypes() {
|
||||||
|
const options = Array.from($('type').options), current = $('type').value;
|
||||||
|
const keys = [...new Set([current, 'ticket','pipeline','ordre','opgave', ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
|
||||||
|
const hints = {ticket:'Support og hjælp',pipeline:'Muligheder og tilbud',ordre:'Indkøb og salg',opgave:'Noget der skal løses',projekt:'Større forløb',service:'Service og vedligehold',abonnement:'Løbende aftaler'};
|
||||||
|
const button = key => `<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')); }
|
||||||
|
$('type').after(div);
|
||||||
|
div.addEventListener('click', e => { const message = e.target.closest('[data-case-message]'); if (message) { window.caseCreateUI?.openMessage(); return; } const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } });
|
||||||
|
}
|
||||||
|
async function applyTemplate(id) {
|
||||||
|
const template = templates.find(t => t.id === Number(id)); if (!template) return;
|
||||||
|
const values = template.values, fields = {type: values.type, titel: values.titel, beskrivelse: values.beskrivelse, status: values.status, assigned_group_id: values.assigned_group_id ?? ''};
|
||||||
|
Object.entries(values.pipeline || {}).forEach(([k,v]) => { fields[`pipeline_${k === 'stage_id' ? 'stage_id' : k}`] = v ?? ''; });
|
||||||
|
if (Object.entries(fields).some(([k,v]) => $(k)?.value && String(v) !== $(k).value) && !confirm('Skabelonen ændrer udfyldte felter. Vil du bruge den?')) return;
|
||||||
|
Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; });
|
||||||
|
const tags = await api('/api/v1/tags?is_active=true');
|
||||||
|
for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); }
|
||||||
|
updateCaseTypeSections(); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates();
|
||||||
|
}
|
||||||
|
async function restore(draft) {
|
||||||
|
state.restoring = true;
|
||||||
|
try {
|
||||||
|
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {};
|
||||||
|
telefoniPrefill = draft.prefill || telefoniPrefill;
|
||||||
|
Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } });
|
||||||
|
state.tags = draft.tags || []; state.hardware = draft.hardware || [];
|
||||||
|
$('orderLines').innerHTML = '';
|
||||||
|
(draft.orders || []).forEach(item => { addOrderLine(item.type); const row = $('orderLines').lastElementChild; Object.entries({description:'description',quantity:'quantity',unit:'unit',unit_price:'unit-price',amount:'amount',currency:'currency',status:'status',external_ref:'reference',purchase_purpose:'purpose'}).forEach(([k,cls]) => { const el = row.querySelector(`.order-${cls}`); if (el) el.value = item[k] ?? ''; }); });
|
||||||
|
renderSelections(); await loadHardwareForContacts(); renderTags(); renderSelectedHardware(); renderOrderLinesEmptyState();
|
||||||
|
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
|
||||||
|
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
|
||||||
|
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
|
||||||
|
updateCaseTypeSections(); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
|
||||||
|
$('charCount').textContent = `${$('beskrivelse').value.length} tegn`;
|
||||||
|
} finally { state.restoring = false; }
|
||||||
|
state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload();
|
||||||
|
}
|
||||||
|
function validate() {
|
||||||
|
document.querySelectorAll('.cc-error').forEach(el => el.remove());
|
||||||
|
document.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
|
||||||
|
const errors = [];
|
||||||
|
const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<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 (!selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma');
|
||||||
|
$('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); });
|
||||||
|
document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } });
|
||||||
|
if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async function init() {
|
||||||
|
const form = $('createForm');
|
||||||
|
try {
|
||||||
|
const setting = await api('/api/v1/settings/case_statuses');
|
||||||
|
const statuses = JSON.parse(setting.value || '[]');
|
||||||
|
if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); });
|
||||||
|
} catch { /* Keep the built-in statuses available. */ }
|
||||||
|
const headings = Array.from(form.querySelectorAll(':scope > h5'));
|
||||||
|
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true);
|
||||||
|
for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
|
||||||
|
const metadata = headings[1]; panel('metadata', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]);
|
||||||
|
const tags = document.createElement('section'); tags.innerHTML = '<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]);
|
||||||
|
$('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag);
|
||||||
|
window.setTagPickerContext?.('case', null, addTag);
|
||||||
|
$('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } };
|
||||||
|
$('cc-tag-suggestions').onclick = e => { const button = e.target.closest('[data-suggested-tag]'); const tag = state.tagCatalog.find(item => item.id === Number(button?.dataset.suggestedTag)); if (tag) addTag(tag).catch(error => showCreateError(error.message)); };
|
||||||
|
const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips);
|
||||||
|
chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } };
|
||||||
|
const originalRenderHardware = window.renderHardwareList;
|
||||||
|
window.renderHardwareList = items => { originalRenderHardware(items); Array.from($('hardwareList').children).forEach((row,i) => { if (!items[i]) return; const item = items[i]; const input = document.createElement('input'); input.type = 'checkbox'; input.className = 'form-check-input me-2'; input.dataset.hardwareChoice = item.id; input.setAttribute('aria-label', `Vælg ${item.model || item.brand || item.id}`); input.onchange = () => { state.hardware = state.hardware.filter(h => h.id !== item.id); if (input.checked) state.hardware.push({id:item.id,name:item.model || item.brand || `Hardware #${item.id}`}); renderSelectedHardware(); scheduleSave(); }; row.prepend(input); }); renderSelectedHardware(); };
|
||||||
|
window.quickCreateHardware = async () => {
|
||||||
|
const name = $('hardwareNameInput').value.trim(), customerId = selectedCustomer?.id || getSingleContactCompanyId();
|
||||||
|
if (!name || !customerId) { showCreateError('Udfyld hardwarenavn og vælg et firma'); return; }
|
||||||
|
const anydeskId = $('hardwareAnyDeskIdInput').value.trim();
|
||||||
|
const button = $('hardwareNameInput').closest('.card-body').querySelector('button'); button.disabled = true;
|
||||||
|
try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; }
|
||||||
|
};
|
||||||
|
const originalRender = window.renderSelections;
|
||||||
|
window.renderSelections = () => { originalRender(); if (state.ready) { scheduleSave(); scheduleDuplicates(); contactOpenCases(); } };
|
||||||
|
const contactCases = document.createElement('aside'); contactCases.id = 'cc-contact-cases'; contactCases.className = 'cc-contact-cases'; contactCases.hidden = true; $('selectedContacts').after(contactCases);
|
||||||
|
const workloadEl = document.createElement('div'); workloadEl.id = 'cc-workload'; workloadEl.className = 'cc-workload'; state.panels.metadata.after(workloadEl);
|
||||||
|
const duplicateEl = document.createElement('div'); duplicateEl.id = 'cc-duplicates'; $('titel').closest('.row').after(duplicateEl);
|
||||||
|
const toolbar = document.createElement('div'); toolbar.className = 'mb-3'; toolbar.innerHTML = '<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));
|
||||||
|
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');
|
||||||
|
buildTypes(); updateCaseTypeSections();
|
||||||
|
form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0));
|
||||||
|
$('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); });
|
||||||
|
$('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload);
|
||||||
|
document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } });
|
||||||
|
window.addEventListener('pagehide', save);
|
||||||
|
try {
|
||||||
|
const user = await api('/api/v1/auth/me');
|
||||||
|
if (!(user.user_id || user.id)) throw new Error('Mangler bruger');
|
||||||
|
const params = new URLSearchParams(location.search); params.sort();
|
||||||
|
state.key = `bmc:case-draft:v1:${user.user_id || user.id}:${params.toString() || 'new'}`;
|
||||||
|
const raw = localStorage.getItem(state.key); let draft;
|
||||||
|
try { draft = raw ? JSON.parse(raw) : null; } catch { localStorage.removeItem(state.key); }
|
||||||
|
if (draft && (draft.version !== 1 || !Number.isFinite(draft.savedAt) || !draft.fields || !Array.isArray(draft.orders) || Date.now()-draft.savedAt > 7*86400000)) { localStorage.removeItem(state.key); draft=null; }
|
||||||
|
if (draft) {
|
||||||
|
state.pending = true; const prompt = document.createElement('div'); prompt.id = 'cc-draft'; prompt.className = 'alert alert-info'; prompt.innerHTML = 'Der findes en lokal kladde. <button type="button" class="btn btn-sm btn-primary" id="cc-restore">Gendan kladde</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="cc-discard">Kassér</button>'; form.prepend(prompt);
|
||||||
|
$('cc-restore').onclick = () => restore(draft).catch(e => showCreateError(e.message));
|
||||||
|
$('cc-discard').onclick = () => { localStorage.removeItem(state.key); state.pending=false; prompt.remove(); scheduleSave(); };
|
||||||
|
}
|
||||||
|
$('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser';
|
||||||
|
} catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; }
|
||||||
|
state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []);
|
||||||
|
state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts();
|
||||||
|
}
|
||||||
|
window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => ({tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id)}),created: result => {
|
||||||
|
state.completed = true; clearTimeout(saveTimer);
|
||||||
|
try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ }
|
||||||
|
}};
|
||||||
|
})();
|
||||||
39
static/js/case-template-admin.js
Normal file
39
static/js/case-template-admin.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
(() => {
|
||||||
|
const $ = id => document.getElementById(`cta-${id}`);
|
||||||
|
const esc = v => String(v ?? '').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
let rows = [], tags = [], selected = [];
|
||||||
|
async function api(path, options) { const r = await fetch(`/api/v1/${path}`, {credentials:'include',...options}); const data = await r.json(); if (!r.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Kontrollér skabelonens felter'); return data; }
|
||||||
|
function message(text, error=false) { $('message').textContent=text; $('message').className=error?'alert alert-danger':'alert alert-success'; }
|
||||||
|
function renderTags() { $('tags').innerHTML=selected.map(id=>`<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-tag="${id}">${esc(tags.find(t=>t.id===id)?.name || `Tag #${id}`)} ×</button>`).join(''); }
|
||||||
|
function edit(row) {
|
||||||
|
$('form').reset(); const v=row?.values || {};
|
||||||
|
for (const [key,value] of Object.entries({id:row?.id || '',name:row?.name || '',icon:row?.icon || 'bi-lightning',sort:row?.sort_order || 0,type:v.type || 'ticket',status:v.status || 'åben',group:v.assigned_group_id || '',title:v.titel || '',description:v.beskrivelse || '',stage:v.pipeline?.stage_id || '',amount:v.pipeline?.amount ?? '',probability:v.pipeline?.probability ?? '', 'pipeline-description':v.pipeline?.description || ''})) $(key).value=value;
|
||||||
|
$('active').checked=row?.is_active ?? true; selected=[...(v.tag_ids || [])]; renderTags();
|
||||||
|
}
|
||||||
|
async function load() { rows=await api('case-create/templates/admin'); $('list').innerHTML=rows.map(r=>`<div class="list-group-item d-flex align-items-center gap-2"><button type="button" class="btn btn-link flex-grow-1 text-start" data-edit="${r.id}"><i class="bi ${esc(r.icon)} me-2"></i>${esc(r.name)} · ${r.is_active?'Aktiv':'Inaktiv'} · ${r.sort_order}</button><button type="button" class="btn btn-sm btn-outline-danger" data-delete="${r.id}">Slet</button></div>`).join('') || '<p class="text-muted">Ingen skabeloner endnu.</p>'; }
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const [types, groups, stages, tagRows] = await Promise.all([api('settings/case_types'),api('case-create/template-options'),api('pipeline/stages'),api('tags?is_active=true')]);
|
||||||
|
let typeKeys; try { typeKeys=JSON.parse(types.value || '[]'); } catch { typeKeys=[]; }
|
||||||
|
if (!Array.isArray(typeKeys) || !typeKeys.length) typeKeys=['ticket','pipeline','ordre','opgave','projekt','service','abonnement'];
|
||||||
|
$('type').innerHTML=[...new Set([...typeKeys,'pipeline','abonnement'])].map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join('');
|
||||||
|
$('group').insertAdjacentHTML('beforeend',groups.map(g=>`<option value="${g.id}">${esc(g.name)}</option>`).join(''));
|
||||||
|
$('stage').insertAdjacentHTML('beforeend',stages.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join(''));
|
||||||
|
tags=tagRows; await load(); edit(null);
|
||||||
|
const setting = await api('settings/case_statuses');
|
||||||
|
const statuses = JSON.parse(setting.value || '[]');
|
||||||
|
if (Array.isArray(statuses)) statuses.forEach(s => { const value=typeof s==='string'?s:s.value; if(value && !Array.from($('status').options).some(o=>o.value===value)) $('status').add(new Option(typeof s==='string'?s:(s.label || value),value)); });
|
||||||
|
} catch(e) { message(e.message,true); }
|
||||||
|
$('new').onclick=()=>edit(null);
|
||||||
|
$('tags').onclick=e=>{ const b=e.target.closest('[data-tag]'); if(b){selected=selected.filter(id=>id!==Number(b.dataset.tag));renderTags();} };
|
||||||
|
$('add-tag').onclick=()=>window.tagPicker.showSelection(t=>{if(!selected.includes(t.id))selected.push(t.id);renderTags();});
|
||||||
|
$('list').onclick=async e=>{try{const b=e.target.closest('[data-edit],[data-delete]');if(!b)return;if(b.dataset.edit)edit(rows.find(r=>r.id===Number(b.dataset.edit)));else if(confirm('Slet denne fælles skabelon?')){await api(`case-create/templates/${b.dataset.delete}`,{method:'DELETE'});await load();edit(null);}}catch(err){message(err.message,true);}};
|
||||||
|
$('form').onsubmit=async e=>{e.preventDefault();const button=$('form').querySelector('[type=submit]');button.disabled=true;try{
|
||||||
|
const number=k=>$(k).value===''?null:Number($(k).value);
|
||||||
|
const pipeline={stage_id:number('stage'),amount:number('amount'),probability:number('probability'),description:$('pipeline-description').value || null};
|
||||||
|
const body={name:$('name').value.trim(),icon:$('icon').value,sort_order:number('sort') || 0,is_active:$('active').checked,values:{type:$('type').value,titel:$('title').value,beskrivelse:$('description').value,status:$('status').value,assigned_group_id:number('group'),tag_ids:selected,pipeline:Object.values(pipeline).some(v=>v!==null)?pipeline:null}};
|
||||||
|
const id=$('id').value;const saved=await api(`case-create/templates${id?`/${id}`:''}`,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});await load();edit(saved);message('Skabelon gemt');
|
||||||
|
}catch(err){message(err.message,true);}finally{button.disabled=false;}};
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded',init);
|
||||||
|
})();
|
||||||
68
static/js/internal-message.js
Normal file
68
static/js/internal-message.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
let kind = 'message', contact = null, searchTimer, searchVersion = 0, sending = false;
|
||||||
|
async function api(url, options = {}) {
|
||||||
|
const response = await fetch(`/api/v1/${url}`, {credentials:'include',...options});
|
||||||
|
const data = await response.json().catch(()=>({}));
|
||||||
|
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Handlingen mislykkedes. Prøv igen.');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function showError(message) { $('im-error').textContent=message; $('im-error').classList.remove('d-none'); }
|
||||||
|
function setKind(value) {
|
||||||
|
kind=value;
|
||||||
|
$('im-phone-fields').classList.toggle('d-none',kind!=='phone');
|
||||||
|
document.querySelectorAll('[data-internal-kind]').forEach(b=>{const active=b.dataset.internalKind===kind;b.classList.toggle('btn-primary',active);b.classList.toggle('btn-outline-primary',!active);b.setAttribute('aria-pressed',String(active));});
|
||||||
|
$('im-text').placeholder=kind==='phone'?'Fx: Har ringet om tilbuddet og vil gerne ringes op i eftermiddag.':'Hvad skal din kollega vide?';
|
||||||
|
}
|
||||||
|
function selectContact(value) {
|
||||||
|
contact=value; ++searchVersion; clearTimeout(searchTimer);
|
||||||
|
$('im-contact').replaceChildren(); $('im-contact-search').value=''; $('im-contact-results').classList.add('d-none');
|
||||||
|
if (!contact) return;
|
||||||
|
$('im-contact-panel').open = true;
|
||||||
|
const chip=document.createElement('button');chip.type='button';chip.className='btn btn-sm btn-outline-primary';chip.textContent=`${contact.name} ×`;chip.setAttribute('aria-label',`Fjern ${contact.name}`);chip.onclick=()=>selectContact(null);$('im-contact').append(chip);
|
||||||
|
if (!$('im-caller').value) $('im-caller').value=contact.name;
|
||||||
|
if (!$('im-phone').value) $('im-phone').value=contact.phone || '';
|
||||||
|
}
|
||||||
|
window.openInternalMessage = async (context={}) => {
|
||||||
|
if (sending) return;
|
||||||
|
$('im-error').classList.add('d-none');
|
||||||
|
if (!$('im-text').value.trim() && context.kind) setKind(context.kind);
|
||||||
|
if (!$('im-text').value.trim() && context.contact) selectContact(context.contact);
|
||||||
|
bootstrap.Modal.getOrCreateInstance($('internalMessageModal')).show();
|
||||||
|
try {
|
||||||
|
const [payload, me]=await Promise.all([api('users?is_active=true'),api('auth/me')]);
|
||||||
|
const selected=!$('im-text').value.trim() && context.recipient ? String(context.recipient) : $('im-recipient').value;
|
||||||
|
const users=Array.isArray(payload)?payload:(payload.data || []);
|
||||||
|
$('im-recipient').replaceChildren(new Option('Vælg medarbejder…',''));
|
||||||
|
users.filter(u=>Number(u.id || u.user_id)!==Number(me.id || me.user_id)).forEach(u=>$('im-recipient').add(new Option(u.full_name || u.username || u.email, u.id || u.user_id)));
|
||||||
|
$('im-recipient').value=selected;
|
||||||
|
} catch(error) { showError(error.message); }
|
||||||
|
};
|
||||||
|
document.querySelectorAll('[data-internal-kind]').forEach(b=>b.onclick=()=>setKind(b.dataset.internalKind));
|
||||||
|
$('internalMessageModal').addEventListener('hidden.bs.modal',()=>{++searchVersion;clearTimeout(searchTimer);$('im-contact-results').classList.add('d-none');});
|
||||||
|
$('im-contact-search').addEventListener('input',()=>{
|
||||||
|
clearTimeout(searchTimer);const version=++searchVersion,query=$('im-contact-search').value.trim();
|
||||||
|
$('im-contact-results').classList.add('d-none');
|
||||||
|
if(query.length<2)return;
|
||||||
|
searchTimer=setTimeout(async()=>{try{
|
||||||
|
const rows=await api(`search/contacts?q=${encodeURIComponent(query)}`);
|
||||||
|
if(version!==searchVersion || document.activeElement!==$('im-contact-search'))return;
|
||||||
|
$('im-contact-results').replaceChildren();
|
||||||
|
if(!Array.isArray(rows) || !rows.length){$('im-contact-results').textContent='Ingen kontakter fundet. Du kan sende uden kontaktperson.';}
|
||||||
|
else rows.slice(0,10).forEach(r=>{const name=[r.first_name,r.last_name].filter(Boolean).join(' ') || r.name || `Kontakt #${r.id}`,phone=r.mobile || r.phone || '';const b=document.createElement('button');b.type='button';b.className='list-group-item list-group-item-action';b.textContent=`${name}${phone?' · '+phone:''}`;b.onclick=()=>selectContact({id:r.id,name,phone});$('im-contact-results').append(b);});
|
||||||
|
$('im-contact-results').classList.remove('d-none');
|
||||||
|
}catch(error){if(version===searchVersion)showError(error.message);}},300);
|
||||||
|
});
|
||||||
|
$('internalMessageForm').addEventListener('submit',async e=>{
|
||||||
|
e.preventDefault();if(sending)return;
|
||||||
|
if(!$('im-recipient').value || !$('im-text').value.trim()){showError('Vælg en modtager og skriv en besked.');return;}
|
||||||
|
sending=true;$('im-send').disabled=true;$('im-send').textContent='Sender…';$('im-error').classList.add('d-none');
|
||||||
|
try {
|
||||||
|
await api('bottom-bar/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:$('im-text').value.trim(),recipient_user_id:Number($('im-recipient').value),requires_manual_ack:$('im-ack').checked,message_kind:kind,contact_id:contact?.id || null,caller_name:kind==='phone'?$('im-caller').value.trim():'',callback_phone:kind==='phone'?$('im-phone').value.trim():''})});
|
||||||
|
$('internalMessageForm').reset();selectContact(null);$('im-contact-panel').open=false;setKind('message');bootstrap.Modal.getOrCreateInstance($('internalMessageModal')).hide();
|
||||||
|
window.dispatchEvent(new CustomEvent('hub:internal-message-sent'));
|
||||||
|
if(window.showNotification)window.showNotification('Besked sendt til din kollega','success');
|
||||||
|
}catch(error){showError(error.message);}finally{sending=false;$('im-send').disabled=false;$('im-send').innerHTML='<i class="bi bi-send me-2"></i>Send besked';}
|
||||||
|
});
|
||||||
|
})();
|
||||||
23
static/js/message-ui.js
Normal file
23
static/js/message-ui.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/* Presentational helpers shared by the internal inbox. No network or mutations. */
|
||||||
|
window.BmcMessageUI = (() => {
|
||||||
|
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const initials = name => String(name || '?').trim().split(/\s+/).slice(0,2).map(p=>p[0]).join('').toUpperCase();
|
||||||
|
const time = value => { const d=new Date(value); return value && !Number.isNaN(d.getTime()) ? d.toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}) : ''; };
|
||||||
|
function message(m) {
|
||||||
|
const own=!!m.is_own, phone=m.message_kind==='phone';
|
||||||
|
const number=String(m.callback_phone || '').replace(/[^+0-9*#,;]/g,'');
|
||||||
|
const person=m.caller_name || m.contact_name || 'Telefonbesked';
|
||||||
|
const contact=Number(m.contact_id)>0?`<a class="msg-contact" href="/contacts/${Number(m.contact_id)}"><i class="bi bi-person"></i> ${escape(m.contact_name || 'Åbn kontakt')}</a>`:'';
|
||||||
|
const replyId=own?Number(m.recipient_user_id):Number(m.sender_user_id);
|
||||||
|
const stamp=time(m.created_at);
|
||||||
|
const status=own?(m.is_acknowledged?'Bekræftet læst':m.is_read?'Læst':'Sendt'):'';
|
||||||
|
const phoneCard=phone?`<div class="msg-call"><span class="msg-call-icon"><i class="bi bi-telephone"></i></span><div><small>TELEFONBESKED</small><strong>${escape(person)}</strong>${m.callback_phone?`<span>${escape(m.callback_phone)}</span>`:''}</div>${number?`<a class="msg-call-action" href="tel:${escape(number)}" aria-label="Ring tilbage til ${escape(person)}"><i class="bi bi-telephone-outbound"></i> Ring tilbage</a>`:''}</div>`:'';
|
||||||
|
return `<article class="msg-message ${own?'is-own':''} ${phone?'is-phone':''}"><div class="msg-byline"><span>${own?'Dig':escape(m.from || 'Kollega')}</span>${stamp?`<time datetime="${escape(m.created_at)}" title="${escape(new Date(m.created_at).toLocaleString('da-DK'))}">${stamp}</time>`:''}${m.is_unread?'<span class="msg-new-dot" title="Ulæst" aria-label="Ulæst"></span>':''}</div><div class="msg-bubble">${phoneCard}<div class="msg-text">${escape(m.text)}</div>${contact}</div><div class="msg-message-actions">${status?`<span class="msg-delivery"><i class="bi bi-check2${m.is_read || m.is_acknowledged?'-all':''}"></i> ${status}</span>`:''}${m.requires_manual_ack && !m.is_acknowledged?'<span class="msg-ack-hint">Læsebekræftelse ønskes</span>':''}${!own && m.requires_manual_ack && !m.is_acknowledged?`<button type="button" class="msg-ack" data-bb-ack-message="${Number(m.id)}"><i class="bi bi-check2-circle"></i> Bekræft læst</button>`:''}${replyId>0?`<button type="button" class="msg-reply" data-bb-reply-message="${Number(m.id)}"><i class="bi bi-reply"></i> Svar</button>`:''}</div></article>`;
|
||||||
|
}
|
||||||
|
function thread(t, active) {
|
||||||
|
const last=t.items?.[t.items.length-1];
|
||||||
|
const preview=last?`${last.message_kind==='phone'?'Telefonbesked · ':''}${last.text || ''}`:'Start en samtale';
|
||||||
|
return `<span class="msg-avatar ${t.partnerUserId?'':'is-group'}">${t.partnerUserId?escape(initials(t.label)):'<i class="bi bi-people"></i>'}</span><span class="msg-thread-copy"><span class="msg-thread-top"><strong>${escape(t.label)}</strong><time>${time(t.lastCreatedAt)}</time></span><span class="msg-thread-preview">${escape(preview)}</span></span>${t.unread?`<span class="bb-message-thread-count">${Number(t.unread)}</span>`:''}`;
|
||||||
|
}
|
||||||
|
return {message,thread,escape,initials};
|
||||||
|
})();
|
||||||
@ -256,6 +256,11 @@ class TagPicker {
|
|||||||
|
|
||||||
async selectTag(tag) {
|
async selectTag(tag) {
|
||||||
if (!tag) return;
|
if (!tag) return;
|
||||||
|
if (this.selectionOnly) {
|
||||||
|
this.onSelectCallback?.(tag, null);
|
||||||
|
this.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🏷️ Selecting tag ${tag.name} for context: ${this.contextType} #${this.contextId}`);
|
console.log(`🏷️ Selecting tag ${tag.name} for context: ${this.contextType} #${this.contextId}`);
|
||||||
|
|
||||||
@ -356,6 +361,7 @@ class TagPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
show(contextType = null, contextId = null, onSelect = null) {
|
show(contextType = null, contextId = null, onSelect = null) {
|
||||||
|
this.selectionOnly = false;
|
||||||
// Use provided context OR fall back to page defaults
|
// Use provided context OR fall back to page defaults
|
||||||
// Note: arguments are undefined if not passed, so check for null/undefined
|
// Note: arguments are undefined if not passed, so check for null/undefined
|
||||||
this.contextType = (contextType !== null && contextType !== undefined) ? contextType : this.defaultContextType;
|
this.contextType = (contextType !== null && contextType !== undefined) ? contextType : this.defaultContextType;
|
||||||
@ -380,6 +386,13 @@ class TagPicker {
|
|||||||
this.defaultOnSelectCallback = onSelect;
|
this.defaultOnSelectCallback = onSelect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showSelection(onSelect) {
|
||||||
|
this.show(null, null, onSelect);
|
||||||
|
this.selectionOnly = true;
|
||||||
|
this.contextType = null;
|
||||||
|
this.contextId = null;
|
||||||
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
const modalInstance = bootstrap.Modal.getInstance(this.modal);
|
const modalInstance = bootstrap.Modal.getInstance(this.modal);
|
||||||
if (modalInstance) {
|
if (modalInstance) {
|
||||||
@ -413,6 +426,18 @@ class TagPicker {
|
|||||||
// Initialize global tag picker
|
// Initialize global tag picker
|
||||||
window.tagPicker = new TagPicker();
|
window.tagPicker = new TagPicker();
|
||||||
|
|
||||||
|
// Deliver creation-time workflow events on the saved case, where handlers exist.
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const match = location.pathname.match(/^\/sag\/(\d+)(?:\/v3)?\/?$/);
|
||||||
|
if (!match) return;
|
||||||
|
const key = `bmc:case-tag-actions:${match[1]}`;
|
||||||
|
try {
|
||||||
|
const actions = JSON.parse(sessionStorage.getItem(key) || '[]');
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
setTimeout(() => actions.forEach(detail => window.dispatchEvent(new CustomEvent('hub:tag-action', {detail}))), 0);
|
||||||
|
} catch { /* Invalid or unavailable session storage must not block the page. */ }
|
||||||
|
});
|
||||||
|
|
||||||
// Helper function to show tag picker with context
|
// Helper function to show tag picker with context
|
||||||
window.showTagPicker = function(entityType, entityId, onSelect = null) {
|
window.showTagPicker = function(entityType, entityId, onSelect = null) {
|
||||||
window.tagPicker.show(entityType, entityId, onSelect);
|
window.tagPicker.show(entityType, entityId, onSelect);
|
||||||
|
|||||||
82
static/messages.css
Normal file
82
static/messages.css
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/* Internal messages: a quiet inbox, a readable conversation, one composer. */
|
||||||
|
.global-bottom-bar .bb-messages-layout{display:grid;grid-template-columns:248px minmax(0,1fr);gap:0;border:1px solid var(--border-color,#dce4e9);border-radius:14px;background:var(--bg-card,#fff);height:100%;min-height:0;overflow:hidden}
|
||||||
|
.global-bottom-bar .bb-message-threads{display:flex;flex-direction:column;gap:3px;overflow:auto;border-right:1px solid var(--border-color,#e2e8ed);padding:0 8px 10px;background:color-mix(in srgb,var(--bg-card,#fff) 97%,#59798c);scrollbar-width:thin}
|
||||||
|
.msg-inbox-heading{display:flex;align-items:center;justify-content:space-between;padding:16px 8px 12px;font-size:.72rem;font-weight:650;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#718293)}
|
||||||
|
.msg-inbox-heading button,.msg-header-action{border:0;background:transparent;color:var(--text-secondary,#657b8b);border-radius:8px;padding:7px;cursor:pointer}
|
||||||
|
.msg-inbox-heading button:hover,.msg-header-action:hover{background:var(--accent-light,#eaf1f4)}
|
||||||
|
.global-bottom-bar .bb-message-thread{width:100%;border:0;border-radius:10px;padding:12px 9px;gap:10px;white-space:normal;box-shadow:none;text-align:left;background:transparent;flex-shrink:0;min-height:66px}
|
||||||
|
.global-bottom-bar .bb-message-thread:hover{background:var(--bg-hover,#edf2f5)}
|
||||||
|
.global-bottom-bar .bb-message-thread.is-active{background:color-mix(in srgb,var(--bg-card,#fff) 90%,#278e87);color:var(--text-primary,#233f50);box-shadow:none;border:0}
|
||||||
|
.msg-avatar{display:grid;place-items:center;width:34px;height:34px;border-radius:11px;flex-shrink:0;background:color-mix(in srgb,var(--bg-card,#fff) 84%,#607a8e);font-size:.7rem;letter-spacing:.03em;color:var(--text-primary,#3d5868)}
|
||||||
|
.msg-avatar.is-group{background:color-mix(in srgb,var(--bg-card,#fff) 88%,#319387);color:var(--text-primary,#347369)}
|
||||||
|
.msg-thread-copy{min-width:0;flex:1;display:block}
|
||||||
|
.msg-thread-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||||
|
.msg-thread-top strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:.79rem}
|
||||||
|
.msg-thread-top time{font-size:.62rem;flex-shrink:0;color:var(--text-secondary,#7d8d99);font-weight:400}
|
||||||
|
.msg-thread-preview{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#7d8d99);font-weight:400;font-size:.71rem;margin-top:4px}
|
||||||
|
.global-bottom-bar .bb-message-thread-count,.global-bottom-bar .bb-message-thread.is-active .bb-message-thread-count{background:#267f79;color:white;font-size:.63rem;min-width:17px;height:17px;padding:0 4px}
|
||||||
|
.msg-conversation{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden}
|
||||||
|
.msg-conversation-header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--border-color,#e6ebef)}
|
||||||
|
.msg-conversation-header strong{font-size:.87rem;font-weight:650;color:var(--text-primary,#233f50)}
|
||||||
|
.msg-conversation-header small{display:block;font-size:.68rem;color:var(--text-secondary,#7a8c99);margin-top:3px}
|
||||||
|
.msg-header-action{display:flex;gap:7px;align-items:center;font-size:.7rem;white-space:nowrap}
|
||||||
|
.global-bottom-bar .bb-messages-list{display:flex;flex-direction:column;gap:17px;flex:1;min-height:0;overflow-y:auto;padding:20px!important;margin:0;background:color-mix(in srgb,var(--bg-card,#fff) 98%,#678190)}
|
||||||
|
.global-bottom-bar .bb-messages-list>li{display:block!important;flex-shrink:0;margin:0;padding:0!important;border:0!important;box-shadow:none!important;background:transparent!important;list-style:none}
|
||||||
|
.msg-message{max-width:84%;width:fit-content;min-width:130px}
|
||||||
|
.msg-message.is-own{margin-left:auto}
|
||||||
|
.msg-byline{display:flex;align-items:center;gap:9px;padding:0 3px 5px;font-size:.64rem;color:var(--text-secondary,#7a8a97)}
|
||||||
|
.msg-byline>span:first-child{font-weight:600;color:var(--text-primary,#466072)}
|
||||||
|
.msg-message.is-own .msg-byline{justify-content:flex-end}
|
||||||
|
.msg-new-dot{width:5px;height:5px;border-radius:50%;background:#2c9589}
|
||||||
|
.msg-bubble{padding:12px 15px;border:1px solid var(--border-color,#e0e7ec);border-radius:3px 13px 13px;background:var(--bg-card,#fff);color:var(--text-primary,#304b5d)}
|
||||||
|
.msg-message.is-own .msg-bubble{background:color-mix(in srgb,var(--bg-card,#fff) 92%,#349b90);border-color:color-mix(in srgb,var(--border-color,#e0e7ec) 65%,#349b90);border-radius:13px 3px 13px 13px}
|
||||||
|
.msg-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.82rem;line-height:1.65}
|
||||||
|
.msg-message-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:5px;padding:0 3px;min-height:18px;color:var(--text-secondary,#81909c);font-size:.61rem}
|
||||||
|
.is-own .msg-message-actions{justify-content:flex-end}
|
||||||
|
.msg-message-actions button{font-size:.64rem;background:transparent;border:0;padding:1px 0;color:var(--text-secondary,#6b8291)}
|
||||||
|
.msg-message-actions .msg-ack{color:var(--text-primary,#266e65);font-weight:600}
|
||||||
|
.msg-reply:hover,.msg-ack:hover{text-decoration:underline}
|
||||||
|
.msg-contact{display:inline-flex;gap:5px;margin-top:9px;text-decoration:none;font-size:.68rem;color:var(--text-primary,#397480)}
|
||||||
|
.msg-call{display:flex;gap:10px;align-items:center;padding-bottom:12px;margin-bottom:10px;border-bottom:1px solid var(--border-color,#dce5e8)}
|
||||||
|
.msg-call-icon{width:33px;height:33px;border-radius:10px;display:grid;place-items:center;background:color-mix(in srgb,var(--bg-card,#fff) 83%,#cda65e);color:var(--text-primary,#80662f);flex-shrink:0}
|
||||||
|
.msg-call>div{min-width:0;flex:1}
|
||||||
|
.msg-call small{display:block;font-size:.54rem;letter-spacing:.08em;color:var(--text-secondary,#88919a);margin-bottom:3px}
|
||||||
|
.msg-call strong{display:block;font-weight:600;font-size:.8rem;overflow-wrap:anywhere}
|
||||||
|
.msg-call>div>span{display:block;font-size:.71rem;color:var(--text-secondary,#7a8c99);margin-top:3px}
|
||||||
|
.msg-call-action{display:flex;align-items:center;gap:5px;flex-shrink:0;text-decoration:none;color:var(--text-primary,#286e65);font-size:.66rem;padding:6px 8px;border:1px solid var(--border-color,#d4e0e4);border-radius:7px}
|
||||||
|
.global-bottom-bar .bb-messages-composer{margin:0;padding:12px 18px 10px;flex-shrink:0;border:0;border-top:1px solid var(--border-color,#e5ebee);background:var(--bg-card,#fff);border-radius:0}
|
||||||
|
.msg-compose-field{display:flex;align-items:flex-end;border:1px solid var(--border-color,#dbe4ea);border-radius:11px;background:var(--bg-card,#fff);padding:8px;gap:9px}
|
||||||
|
.msg-compose-field:focus-within{border-color:#639f99;box-shadow:0 0 0 2px #349b9012}
|
||||||
|
.msg-compose-field textarea{width:100%;min-width:0;resize:vertical;max-height:110px;min-height:43px;border:0;background:transparent;outline:none;padding:3px 5px;color:var(--text-primary,#304b5d);font-size:.82rem;line-height:1.6}
|
||||||
|
.msg-send{height:33px;width:33px;border:0;border-radius:9px;background:#267b74;color:white;flex-shrink:0}
|
||||||
|
.msg-send:disabled{opacity:.5}
|
||||||
|
.msg-compose-footer{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:7px;font-size:.63rem;color:var(--text-secondary,#84919c)}
|
||||||
|
.msg-compose-footer label{display:flex;gap:6px;align-items:center;cursor:pointer}
|
||||||
|
.msg-compose-footer input{accent-color:#267b74}
|
||||||
|
.msg-empty{display:grid;place-items:center;text-align:center;color:var(--text-secondary,#81919e);padding:24px 10px}
|
||||||
|
.msg-empty>span{width:46px;height:46px;border-radius:15px;background:var(--accent-light,#edf4f4);display:grid;place-items:center;margin-bottom:13px;font-size:1.25rem}
|
||||||
|
.msg-empty strong{font-size:.84rem;color:var(--text-primary,#3d5667)}
|
||||||
|
.msg-empty p{font-size:.73rem;max-width:260px;margin:7px 0}
|
||||||
|
/* Compose dialog shares the inbox palette and places the message first. */
|
||||||
|
#internalMessageModal .modal-dialog{max-width:640px}
|
||||||
|
#internalMessageModal .modal-content{border:1px solid var(--border-color,#dce4e9)!important;border-radius:18px!important;background:var(--bg-card,#fff);overflow:hidden}
|
||||||
|
#internalMessageModal .modal-header{padding:24px 28px 20px!important;align-items:flex-start}
|
||||||
|
#internalMessageModal .modal-header h4{font-size:1.3rem;letter-spacing:-.03em;font-weight:650;color:var(--text-primary,#233f50)}
|
||||||
|
#internalMessageModal .modal-header .text-uppercase{font-size:.6rem!important;letter-spacing:.12em;display:block;margin-bottom:7px}
|
||||||
|
#internalMessageModal .modal-body{padding:0 28px 8px!important}
|
||||||
|
#internalMessageModal .form-label{font-size:.76rem;font-weight:550;color:var(--text-primary,#445f71);margin-bottom:7px}
|
||||||
|
#internalMessageModal .form-control,#internalMessageModal .form-select{font-size:.83rem;border:1px solid var(--border-color,#dce4e9);border-radius:9px;background:var(--bg-card,#fff);color:var(--text-primary,#304b5d);padding:10px 12px}
|
||||||
|
#internalMessageModal .form-control:focus,#internalMessageModal .form-select:focus{border-color:#639f99;box-shadow:0 0 0 3px #349b9012}
|
||||||
|
#internalMessageModal #im-text{min-height:145px;line-height:1.7;resize:vertical}
|
||||||
|
#internalMessageModal [role=group]{display:flex;gap:4px!important;background:var(--bg-hover,#f1f5f6);padding:4px;border-radius:10px;margin-bottom:22px!important}
|
||||||
|
#internalMessageModal [data-internal-kind]{flex:1;border:0!important;background:transparent;color:var(--text-secondary,#7c8b96);border-radius:7px;font-size:.78rem;padding:10px}
|
||||||
|
#internalMessageModal [data-internal-kind][aria-pressed=true]{background:var(--bg-card,#fff);color:var(--text-primary,#2d635d);box-shadow:0 1px 5px #163b4912}
|
||||||
|
#internalMessageModal #im-phone-fields{padding:14px 4px;background:var(--bg-hover,#f5f7f8);border-radius:10px;margin:0 0 17px}
|
||||||
|
#internalMessageModal .modal-footer{padding:16px 28px 22px!important;background:var(--bg-hover,#f7f9fa);gap:8px}
|
||||||
|
#internalMessageModal .modal-footer .btn{border-radius:9px;font-size:.8rem;padding:10px 19px}
|
||||||
|
#internalMessageModal #im-send{background:#267b74;border-color:#267b74}
|
||||||
|
#internalMessageModal .form-check-label,#internalMessageModal p.small{font-size:.7rem!important;color:var(--text-secondary,#7c8c98)}
|
||||||
|
#internalMessageModal #im-contact .btn{font-size:.7rem;border-radius:7px;border-color:var(--border-color,#d6e4e5);color:var(--text-primary,#397480)}
|
||||||
|
#internalMessageModal .form-check-input{accent-color:#267b74}
|
||||||
|
@media(max-width:820px){.global-bottom-bar .bb-messages-layout{grid-template-columns:195px minmax(0,1fr)}.msg-avatar{display:none}.msg-conversation-header{padding:11px 13px}.global-bottom-bar .bb-messages-list{padding:14px!important}.msg-message{max-width:94%}.msg-compose-footer>small{display:none}.msg-call{flex-wrap:wrap}.msg-call-action{margin-left:43px}}
|
||||||
|
@media(max-width:600px){.global-bottom-bar .bb-messages-layout{display:flex;flex-direction:column}.global-bottom-bar .bb-message-threads{flex-direction:row;flex-shrink:0;overflow-x:auto;max-height:66px;border-right:0;border-bottom:1px solid var(--border-color,#e2e8ed);padding:5px;gap:4px}.msg-inbox-heading{padding:0 5px}.msg-inbox-heading>span{display:none}.global-bottom-bar .bb-message-thread{min-height:50px;width:160px;padding:7px 9px}.msg-thread-top time{display:none}.msg-conversation{flex:1}.msg-header-action span{display:none}.global-bottom-bar .bb-messages-composer{padding:8px 10px}.msg-compose-field textarea{min-height:32px}.msg-message-actions{gap:6px}.msg-ack-hint{display:none}#internalMessageModal .modal-body{padding:0 20px 8px!important}#internalMessageModal .modal-header{padding:22px 20px 18px!important}}
|
||||||
87
tests/test_case_create_dom.cjs
Normal file
87
tests/test_case_create_dom.cjs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// Run with NODE_PATH pointing to a jsdom installation (no application dependency).
|
||||||
|
const {JSDOM} = require('jsdom');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const source = fs.readFileSync('app/modules/sag/templates/create.html','utf8');
|
||||||
|
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||||
|
const html = source.split('{% block content %}')[1].split('<script>')[0]
|
||||||
|
.replace(/\{% for user[\s\S]*?\{% endfor %\}/, '<option value="7">Test bruger</option>')
|
||||||
|
.replace(/\{% for group[\s\S]*?\{% endfor %\}/, '<option value="2">Support</option>');
|
||||||
|
const extension = fs.readFileSync('static/js/case-create.js','utf8');
|
||||||
|
const delay = ms => new Promise(r=>setTimeout(r,ms));
|
||||||
|
async function page(storage, userId=7, search='') {
|
||||||
|
const dom = new JSDOM(html,{url:`http://localhost:8001/sag/new${search}`,runScripts:'outside-only'});
|
||||||
|
const w=dom.window;
|
||||||
|
await new Promise(r=>w.addEventListener('DOMContentLoaded',r));
|
||||||
|
w.CSS = {escape:s=>s}; w.confirm=()=>true; w.alert=()=>{};
|
||||||
|
w.setTagPickerContext=()=>{}; w.tagPicker={showSelection:cb=>cb({id:9,name:'Hardware',color:'#112233'})};
|
||||||
|
w.bootstrap={Modal:class {show(){} hide(){}}};
|
||||||
|
w.fetch=async url=>({ok:true,json:async()=>{
|
||||||
|
if(url.includes('/settings/case_types'))return {value:'["ticket","pipeline","ordre","opgave","service","abonnement"]'};
|
||||||
|
if(url.includes('/auth/me/profile'))return {default_case_type:'ticket'};
|
||||||
|
if(url.includes('/auth/me'))return {id:userId};
|
||||||
|
if(url.includes('/pipeline/stages'))return [{id:1,name:'Tilbud'}];
|
||||||
|
if(url.includes('/case-create/workload'))return {total:0,items:[]};
|
||||||
|
if(url.includes('/case-create/contacts-open-cases'))return {5:{total:2,items:[{id:44,titel:'Eksisterende routerfejl',status:'åben',ansvarlig_navn:'Test bruger',deadline:null}]}};
|
||||||
|
if(url.includes('/case-create/templates'))return [{id:1,name:'Test',values:{type:'pipeline',titel:'Tilbud',beskrivelse:'Tekst',status:'åben',tag_ids:[9]}}];
|
||||||
|
if(url.includes('/tags?'))return [{id:9,name:'Hardware'},{id:10,name:'Router',type:'brand',catch_words:['router'],color:'#112233'}];
|
||||||
|
return [];
|
||||||
|
}});
|
||||||
|
if(storage)for(const [k,v] of Object.entries(storage))w.localStorage.setItem(k,v);
|
||||||
|
w.eval(script + '\n' + extension + '\nwindow.testSetCustomer = () => { selectedCustomer={id:11,name:"Firma"}; selectedContacts={}; renderSelections(); }; window.testSetContact = () => { selectedContacts={5:{id:5,name:"Ada"}}; renderSelections(); window.caseCreateUI.contactsChanged(); };'); w.document.dispatchEvent(new w.Event('DOMContentLoaded'));
|
||||||
|
await delay(60);
|
||||||
|
return dom;
|
||||||
|
}
|
||||||
|
(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-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/);
|
||||||
|
assert.equal(w.caseCreateUI.validate(),false);
|
||||||
|
assert.equal(d.getElementById('cc-relations').open,true);
|
||||||
|
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');
|
||||||
|
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');
|
||||||
|
})().catch(e=>{console.error(e);process.exit(1);});
|
||||||
125
tests/test_case_create_support.py
Normal file
125
tests/test_case_create_support.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from app.modules.sag.backend import create_support as support
|
||||||
|
|
||||||
|
|
||||||
|
def test_similarity_handles_danish_case_and_punctuation():
|
||||||
|
assert support.title_similarity('NETVÆRK: fejl!', 'netværk fejl') == 1
|
||||||
|
assert support.title_similarity('', '') == 0
|
||||||
|
assert support.title_similarity('Router virker ikke', 'Router virker ikke hos kunde') > .6
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_rejects_forbidden_relationships_and_invalid_numbers():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
support.TemplateValues(customer_id=12)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
support.PipelineDefaults(probability=101)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
support.PipelineDefaults(amount=float('nan'))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', [None, {}, [True], [0], [-1], ['1']])
|
||||||
|
def test_relationship_ids_are_strict(value):
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
support.ids(value, 'ids')
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicates_rank_and_limit(monkeypatch):
|
||||||
|
monkeypatch.setattr(support, 'closed_statuses', lambda: ['lukket'])
|
||||||
|
def query(sql, params):
|
||||||
|
assert 'deleted_at IS NULL' in sql
|
||||||
|
assert params == (7, ['lukket'])
|
||||||
|
return [{'id': i, 'titel': 'Netværk fejl'} for i in range(8)] + [{'id': 9, 'titel': 'xyz'}]
|
||||||
|
monkeypatch.setattr(support, 'execute_query', query)
|
||||||
|
assert [r['id'] for r in support.duplicates(7, 'Netværk fejl')] == [7, 6, 5, 4, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_workload_empty_and_total(monkeypatch):
|
||||||
|
monkeypatch.setattr(support, 'closed_statuses', lambda: ['closed'])
|
||||||
|
monkeypatch.setattr(support, 'execute_query', lambda *args: [])
|
||||||
|
assert support.workload(7) == {'total': 0, 'items': []}
|
||||||
|
monkeypatch.setattr(support, 'execute_query', lambda *args: [{'id': 4, 'total': 25}])
|
||||||
|
assert support.workload(7)['total'] == 25
|
||||||
|
|
||||||
|
|
||||||
|
def test_contact_open_cases_returns_only_active_cases_and_first_five(monkeypatch):
|
||||||
|
monkeypatch.setattr(support, 'closed_statuses', lambda: ['lukket'])
|
||||||
|
def query(sql, params):
|
||||||
|
assert 'FROM sag_kontakter' in sql
|
||||||
|
assert 'position<=5' in sql
|
||||||
|
assert params == ([3], ['lukket'])
|
||||||
|
return [
|
||||||
|
{'contact_id': 3, 'id': index, 'titel': f'Sag {index}', 'status': 'åben',
|
||||||
|
'deadline': None, 'ansvarlig_navn': 'Ada', 'total': 7}
|
||||||
|
for index in range(1, 6)
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(support, 'execute_query', query)
|
||||||
|
result = support.contacts_open_cases([3])
|
||||||
|
assert result[3]['total'] == 7
|
||||||
|
assert [item['id'] for item in result[3]['items']] == [1, 2, 3, 4, 5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_associations_deduplicate_and_single_group_last_wins():
|
||||||
|
class Cursor:
|
||||||
|
def __init__(self): self.calls = []
|
||||||
|
def execute(self, sql, args): self.calls.append((sql, args))
|
||||||
|
def fetchall(self):
|
||||||
|
if 'hardware_assets' in self.calls[-1][0]: return [{'id': 4}]
|
||||||
|
return [{'id': i, 'name': str(i), 'tag_group_id': 5, 'behavior': 'single'} for i in [8, 9]]
|
||||||
|
def fetchone(self): return {'action_type': 'open_task_template_modal', 'action_config': {}}
|
||||||
|
cursor = Cursor()
|
||||||
|
actions = support.attach_create_relations(cursor, 20, {'hardware_ids': [4, 4], 'tag_ids': [8, 9]}, 7)
|
||||||
|
inserts = [args for sql, args in cursor.calls if 'INSERT INTO entity_tags' in sql]
|
||||||
|
assert inserts == [(20, 9, 7)]
|
||||||
|
assert len([sql for sql, _ in cursor.calls if 'INSERT INTO sag_hardware' in sql]) == 1
|
||||||
|
assert actions[0]['entity_id'] == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_hardware_fails_before_association_insert():
|
||||||
|
class Cursor:
|
||||||
|
def execute(self, sql, args): assert 'SELECT' in sql
|
||||||
|
def fetchall(self): return []
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
support.attach_create_relations(Cursor(), 20, {'hardware_ids': [999]}, 7)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('missing_hardware', [False, True])
|
||||||
|
def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, missing_hardware):
|
||||||
|
import asyncio
|
||||||
|
from app.modules.sag.backend import router as cases
|
||||||
|
class Cursor:
|
||||||
|
def __init__(self): self.calls = []
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *_): pass
|
||||||
|
def execute(self, sql, args): self.calls.append((sql, args))
|
||||||
|
def fetchone(self):
|
||||||
|
if 'INSERT INTO sag_sager' in self.calls[-1][0]: return {'id': 20}
|
||||||
|
return None
|
||||||
|
def fetchall(self):
|
||||||
|
if 'hardware_assets' in self.calls[-1][0]: return [] if missing_hardware else [{'id': 4}]
|
||||||
|
return [{'id': 9, 'name': 'Test', 'tag_group_id': None, 'behavior': None}]
|
||||||
|
class Connection:
|
||||||
|
def __init__(self): self.cur = Cursor(); self.committed = False; self.rolled_back = False
|
||||||
|
def cursor(self, **kwargs): return self.cur
|
||||||
|
def commit(self): self.committed = True
|
||||||
|
def rollback(self): self.rolled_back = True
|
||||||
|
conn = Connection()
|
||||||
|
monkeypatch.setattr(cases, 'get_db_connection', lambda: conn)
|
||||||
|
monkeypatch.setattr(cases, 'release_db_connection', lambda c: None)
|
||||||
|
monkeypatch.setattr(cases, '_normalize_case_status', lambda v: 'åben')
|
||||||
|
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda r: 7)
|
||||||
|
monkeypatch.setattr(cases, '_validate_user_id', lambda v: None)
|
||||||
|
monkeypatch.setattr(cases, '_validate_group_id', lambda v: None)
|
||||||
|
monkeypatch.setattr(cases, 'table_has_column', lambda *a: True)
|
||||||
|
data = {'titel': 'Test', 'customer_id': 3, 'type': 'ticket', 'pipeline': {'amount': 123}, 'hardware_ids': [4], 'tag_ids': [9], 'order_items': [{'description': 'Router', 'amount': 50}]}
|
||||||
|
if missing_hardware:
|
||||||
|
with pytest.raises(HTTPException): asyncio.run(cases.create_sag(None, data))
|
||||||
|
assert conn.rolled_back and not conn.committed
|
||||||
|
else:
|
||||||
|
result = asyncio.run(cases.create_sag(None, data))
|
||||||
|
assert result['id'] == 20 and conn.committed and not conn.rolled_back
|
||||||
|
sql = '\n'.join(query for query, _ in conn.cur.calls)
|
||||||
|
assert all(table in sql for table in ['sag_sager','sag_salgsvarer','sag_hardware','entity_tags'])
|
||||||
|
case_args = next(args for query, args in conn.cur.calls if 'INSERT INTO sag_sager' in query)
|
||||||
|
assert 123.0 in case_args
|
||||||
30
tests/test_internal_message_dom.cjs
Normal file
30
tests/test_internal_message_dom.cjs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
const {JSDOM}=require('jsdom');
|
||||||
|
const fs=require('node:fs');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
(async()=>{
|
||||||
|
const dom=new JSDOM(fs.readFileSync('app/shared/frontend/internal_message.html','utf8'),{url:'http://localhost:8001',runScripts:'outside-only'});
|
||||||
|
const w=dom.window,d=w.document;let sent=null,fail=false;
|
||||||
|
w.bootstrap={Modal:{getOrCreateInstance:()=>({show(){},hide(){}})}};
|
||||||
|
w.fetch=async(url,options={})=>{
|
||||||
|
if(options.method==='POST'){sent=JSON.parse(options.body);return {ok:!fail,json:async()=>fail?{detail:'Testfejl'}:{message:'Besked sendt'}};}
|
||||||
|
return {ok:true,json:async()=>url.includes('auth/me')?{id:1}:url.includes('users')?[{id:1,full_name:'Mig'},{id:2,full_name:'Kollega'}]:[]};
|
||||||
|
};
|
||||||
|
w.eval(fs.readFileSync('static/js/internal-message.js','utf8'));
|
||||||
|
await w.openInternalMessage();
|
||||||
|
assert.equal(d.getElementById('im-recipient').options.length,2);
|
||||||
|
assert.equal(sent,null,'Opening must not send anything');
|
||||||
|
d.getElementById('im-recipient').value='2';d.getElementById('im-text').value='Kort besked';
|
||||||
|
d.getElementById('internalMessageForm').dispatchEvent(new w.Event('submit',{cancelable:true}));await new Promise(r=>setTimeout(r,10));
|
||||||
|
assert.equal(sent.contact_id,null);assert.equal(sent.message_kind,'message');assert.equal(sent.recipient_user_id,2);
|
||||||
|
await w.openInternalMessage({contact:{id:4,name:'Kontakt'}});
|
||||||
|
d.querySelector('[data-internal-kind="phone"]').click();
|
||||||
|
assert.ok(!d.getElementById('im-phone-fields').classList.contains('d-none'));
|
||||||
|
d.getElementById('im-recipient').value='2';d.getElementById('im-phone').value='12345678';d.getElementById('im-text').value='Ring venligst tilbage';
|
||||||
|
fail=true;d.getElementById('internalMessageForm').dispatchEvent(new w.Event('submit',{cancelable:true}));await new Promise(r=>setTimeout(r,10));
|
||||||
|
assert.equal(sent.contact_id,4);assert.equal(sent.caller_name,'Kontakt');assert.equal(sent.callback_phone,'12345678');assert.equal(sent.message_kind,'phone');
|
||||||
|
assert.equal(d.getElementById('im-text').value,'Ring venligst tilbage','Failure must preserve draft');
|
||||||
|
d.getElementById('im-contact').querySelector('button').click();fail=false;
|
||||||
|
d.getElementById('internalMessageForm').dispatchEvent(new w.Event('submit',{cancelable:true}));await new Promise(r=>setTimeout(r,10));
|
||||||
|
assert.equal(sent.contact_id,null);assert.equal(sent.caller_name,'Kontakt');assert.equal(d.getElementById('im-text').value,'');
|
||||||
|
dom.window.close();console.log('PASS: explicit sending, recipient choice, normal/phone messages, optional/removable contact, failure preserves text');
|
||||||
|
})().catch(e=>{console.error(e);process.exit(1);});
|
||||||
68
tests/test_internal_messages.py
Normal file
68
tests/test_internal_messages.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"""Internal message tests use a fake database; no messages are delivered."""
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from app.modules.bottom_bar.backend import router as messages
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('contact_id', [None, 4])
|
||||||
|
def test_phone_message_stores_optional_contact(monkeypatch, contact_id):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(messages, 'ensure_bottom_bar_messages_schema', lambda: None)
|
||||||
|
monkeypatch.setattr(messages, '_ensure_user_exists', lambda uid: None)
|
||||||
|
def query(sql, args):
|
||||||
|
calls.append((sql, args))
|
||||||
|
if 'SELECT id FROM contacts' in sql:
|
||||||
|
return {'id': 4}
|
||||||
|
return {'id': 8}
|
||||||
|
monkeypatch.setattr(messages, 'execute_query_single', query)
|
||||||
|
payload = messages.BottomBarMessageCreatePayload(message=' Ring tilbage ', recipient_user_id=2, contact_id=contact_id, message_kind='phone', caller_name='Navn', callback_phone='12345678')
|
||||||
|
result = asyncio.run(messages.send_bottom_bar_message(payload, {'id': 1, 'full_name': 'Test'}))
|
||||||
|
args = next(args for sql, args in calls if 'INSERT' in sql)
|
||||||
|
assert args == (1, 2, 'Ring tilbage', False, 'phone', contact_id, 'Navn', '12345678')
|
||||||
|
assert result['item']['message_kind'] == 'phone'
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_contact_does_not_send(monkeypatch):
|
||||||
|
monkeypatch.setattr(messages, 'ensure_bottom_bar_messages_schema', lambda: None)
|
||||||
|
monkeypatch.setattr(messages, '_ensure_user_exists', lambda uid: None)
|
||||||
|
def query(sql, args):
|
||||||
|
assert 'INSERT' not in sql
|
||||||
|
return None
|
||||||
|
monkeypatch.setattr(messages, 'execute_query_single', query)
|
||||||
|
payload = messages.BottomBarMessageCreatePayload(message='Test', recipient_user_id=2, contact_id=999)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(messages.send_bottom_bar_message(payload, {'id': 1}))
|
||||||
|
assert error.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_message_defaults_are_preserved():
|
||||||
|
payload = messages.BottomBarMessageCreatePayload(message='Almindelig besked')
|
||||||
|
assert payload.message_kind == 'message'
|
||||||
|
assert payload.contact_id is None
|
||||||
|
assert payload.recipient_user_id is None
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
messages.BottomBarMessageCreatePayload(message='Test', message_kind='unknown')
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_message_is_rejected(monkeypatch):
|
||||||
|
monkeypatch.setattr(messages, 'ensure_bottom_bar_messages_schema', lambda: None)
|
||||||
|
monkeypatch.setattr(messages, '_ensure_user_exists', lambda uid: None)
|
||||||
|
payload = messages.BottomBarMessageCreatePayload(message='Test', recipient_user_id=1)
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
asyncio.run(messages.send_bottom_bar_message(payload, {'id': 1}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_outgoing_status_uses_recipient_receipt(monkeypatch):
|
||||||
|
from datetime import datetime
|
||||||
|
from app.modules.bottom_bar.backend import service
|
||||||
|
monkeypatch.setattr(service, 'ensure_bottom_bar_messages_schema', lambda: None)
|
||||||
|
def query(sql, args):
|
||||||
|
assert 'THEN m.recipient_user_id ELSE %s END' in sql
|
||||||
|
assert len(args) == 6
|
||||||
|
return [{'id': 8, 'sender_user_id': 1, 'recipient_user_id': 2, 'message_text': 'Hej', 'created_at': datetime(2026,9,8), 'read_at': datetime(2026,9,8), 'acknowledged_at': None}]
|
||||||
|
monkeypatch.setattr(service, 'execute_query', query)
|
||||||
|
monkeypatch.setattr(service, 'execute_query_single', lambda *a: {'count': 0})
|
||||||
|
item = service.get_user_messages_summary(1)['list'][0]
|
||||||
|
assert item['is_own'] and item['is_read'] and not item['is_unread']
|
||||||
29
tests/test_message_inbox_dom.cjs
Normal file
29
tests/test_message_inbox_dom.cjs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
const {JSDOM}=require('jsdom');
|
||||||
|
const fs=require('node:fs');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
(async()=>{
|
||||||
|
const dom=new JSDOM('<div class="global-bottom-bar"><div id="bbTabTitle"><span class="bb-tab-title-text"></span></div><div id="bbTabDescription"></div><div id="bbTabInnerContent"></div></div>',{url:'http://localhost:8001',runScripts:'outside-only',pretendToBeVisual:true});
|
||||||
|
const w=dom.window,d=w.document;
|
||||||
|
await new Promise(r=>w.addEventListener('DOMContentLoaded',r));
|
||||||
|
w.fetch=async()=>({ok:true,json:async()=>[]});
|
||||||
|
w.eval(fs.readFileSync('static/js/message-ui.js','utf8'));
|
||||||
|
const src=fs.readFileSync('static/js/bottom-bar.js','utf8').replace(/\}\)\(\);\s*$/, 'window.inboxTest={render(items,key=""){latestSections={messages:{list:items,count:0}};activeKey="messages";chatComposerState.activeThreadKey=key;chatComposerState.loaded=true;renderTabPanel();},threads:getMessageThreads};})();');
|
||||||
|
w.eval(src);
|
||||||
|
w.inboxTest.render([]);
|
||||||
|
assert.ok(d.querySelector('.bb-message-threads'));
|
||||||
|
assert.ok(d.querySelector('.msg-conversation'));
|
||||||
|
assert.ok(d.getElementById('btnSendMsg').disabled,'Empty inbox must not silently broadcast');
|
||||||
|
const items=[{id:1,sender_user_id:2,recipient_user_id:null,from:'Kollega',text:'Fælles besked',created_at:'2026-09-08T10:00:00'}, {id:2,sender_user_id:2,recipient_user_id:1,from:'Kollega',text:'Ring tilbage <script>alert(1)</script>',message_kind:'phone',caller_name:'Anne',callback_phone:'+45 12345678',contact_id:4,created_at:'2026-09-08T11:00:00'}, {id:3,sender_user_id:1,recipient_user_id:2,to:'Kollega',from:'Dig',text:'Jeg ringer',is_own:true,is_read:true,created_at:'2026-09-08T11:01:00'}];
|
||||||
|
w.inboxTest.render(items,'user:2');
|
||||||
|
assert.equal(w.inboxTest.threads().length,2,'Broadcast is separate from sender conversation');
|
||||||
|
assert.equal(d.querySelectorAll('.msg-message').length,2);
|
||||||
|
assert.equal(d.querySelectorAll('.msg-call').length,1);
|
||||||
|
assert.equal(d.querySelector('.msg-call-action').getAttribute('href'),'tel:+4512345678');
|
||||||
|
assert.equal(d.querySelector('.msg-bubble script'),null,'Messages must be escaped');
|
||||||
|
assert.match(d.querySelector('.is-own .msg-delivery').textContent,/Læst/);
|
||||||
|
assert.equal(d.getElementById('chatInputQuick').tagName,'TEXTAREA');
|
||||||
|
let sends=0;d.getElementById('btnSendMsg').onclick=()=>sends++;
|
||||||
|
d.getElementById('chatInputQuick').dispatchEvent(new w.KeyboardEvent('keydown',{key:'Enter',bubbles:true}));assert.equal(sends,0);
|
||||||
|
d.getElementById('chatInputQuick').dispatchEvent(new w.KeyboardEvent('keydown',{key:'Enter',ctrlKey:true,bubbles:true}));assert.equal(sends,1);
|
||||||
|
dom.window.close();console.log('PASS: inbox layout, empty-state safety, separate broadcasts, phone cards, escaping, read status, multiline and shortcut');
|
||||||
|
})().catch(e=>{console.error(e);process.exit(1);});
|
||||||
@ -159,7 +159,7 @@ def test_case_create_lists_contacts_for_selected_customer():
|
|||||||
def test_case_create_defaults_responsible_to_current_user():
|
def test_case_create_defaults_responsible_to_current_user():
|
||||||
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
|
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")
|
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
|
||||||
assert "selectCurrentUserAsResponsible();" in template
|
assert "selectCurrentUserAsResponsible()" in template
|
||||||
assert "raw_responsible = data.get" in router
|
assert "raw_responsible = data.get" in router
|
||||||
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
|
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user