Release v2.2.49: sag relation tree UX, type dropdown, 12x quick-action modals, email service
This commit is contained in:
parent
1323320fed
commit
ed01f07f86
40
RELEASE_NOTES_v2.2.49.md
Normal file
40
RELEASE_NOTES_v2.2.49.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Release Notes v2.2.49
|
||||||
|
|
||||||
|
Dato: 5. marts 2026
|
||||||
|
|
||||||
|
## Ny funktionalitet
|
||||||
|
|
||||||
|
### Sag – Relationer
|
||||||
|
- Relation-vinduet vises kun når der faktisk er relerede sager. Enkelt-sag (ingen relationer) viser nu tom-state "Ingen relaterede sager".
|
||||||
|
- Aktuel sag fremhæves tydeligt i relationstræet: accent-farvet venstre-kant, svag baggrund, udfyldt badge med sags-ID og fed titel. Linket er ikke klikbart (man er allerede der).
|
||||||
|
|
||||||
|
### Sag – Sagstype dropdown
|
||||||
|
- Sagstype i topbaren er nu et klikbart dropdown i stedet for et link til redigeringssiden.
|
||||||
|
- Dropdown viser alle 6 typer (Ticket, Pipeline, Opgave, Ordre, Projekt, Service) med farveikoner og markerer den aktive type.
|
||||||
|
- Valg PATCHer sagen direkte og genindlæser siden.
|
||||||
|
- Rettet fejl hvor dropdown åbnede bagved siden (`overflow: hidden` fjernet fra `.case-hero`).
|
||||||
|
|
||||||
|
### Sag – Relation quick-actions (+)
|
||||||
|
- Menuen indeholder nu 12 moduler: Tildel sag, Tidregistrering, Kommentar, Påmindelse, Opgave, Salgspipeline, Filer, Hardware, Løsning, Varekøb & salg, Abonnement, Send email.
|
||||||
|
- Alle moduler åbner mini-modal med relevante felter direkte fra relationspanelet – ingen sidenavigation nødvendig.
|
||||||
|
- Salgspipeline skjules fra menuen hvis sagen allerede har pipeline-data (vises som grå "Pipeline (se sagen)").
|
||||||
|
- Tags bruger nu det globale TagPicker-system (`window.showTagPicker`).
|
||||||
|
|
||||||
|
### Email service
|
||||||
|
- Ny `app/services/email_service.py` til centraliseret e-mail-afsendelse.
|
||||||
|
|
||||||
|
### Telefoni
|
||||||
|
- Opdateringer til telefon-log og router.
|
||||||
|
|
||||||
|
## Ændrede filer
|
||||||
|
- `app/modules/sag/templates/detail.html`
|
||||||
|
- `app/modules/sag/backend/router.py`
|
||||||
|
- `app/dashboard/backend/mission_router.py`
|
||||||
|
- `app/dashboard/backend/mission_service.py`
|
||||||
|
- `app/modules/telefoni/backend/router.py`
|
||||||
|
- `app/modules/telefoni/templates/log.html`
|
||||||
|
- `app/services/email_service.py`
|
||||||
|
- `main.py`
|
||||||
|
|
||||||
|
## Drift
|
||||||
|
- Deploy: `./updateto.sh v2.2.49`
|
||||||
@ -53,6 +53,11 @@ def _parse_query_timestamp(request: Request) -> Optional[datetime]:
|
|||||||
def _event_from_query(request: Request) -> MissionCallEvent:
|
def _event_from_query(request: Request) -> MissionCallEvent:
|
||||||
call_id = _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid")
|
call_id = _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid")
|
||||||
if not call_id:
|
if not call_id:
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Mission webhook invalid query path=%s reason=missing_call_id keys=%s",
|
||||||
|
request.url.path,
|
||||||
|
",".join(sorted(request.query_params.keys())),
|
||||||
|
)
|
||||||
raise HTTPException(status_code=400, detail="Missing call_id query parameter")
|
raise HTTPException(status_code=400, detail="Missing call_id query parameter")
|
||||||
|
|
||||||
return MissionCallEvent(
|
return MissionCallEvent(
|
||||||
@ -71,13 +76,28 @@ def _get_webhook_token() -> str:
|
|||||||
|
|
||||||
def _validate_mission_webhook_token(request: Request, token: Optional[str] = None) -> None:
|
def _validate_mission_webhook_token(request: Request, token: Optional[str] = None) -> None:
|
||||||
configured = _get_webhook_token()
|
configured = _get_webhook_token()
|
||||||
|
path = request.url.path
|
||||||
if not configured:
|
if not configured:
|
||||||
logger.warning("Mission webhook token not configured for path=%s", request.url.path)
|
logger.warning("❌ Mission webhook rejected path=%s reason=token_not_configured", path)
|
||||||
raise HTTPException(status_code=403, detail="Mission webhook token not configured")
|
raise HTTPException(status_code=403, detail="Mission webhook token not configured")
|
||||||
|
|
||||||
candidate = token or request.headers.get("x-mission-token") or request.query_params.get("token")
|
candidate = token or request.headers.get("x-mission-token") or request.query_params.get("token")
|
||||||
if not candidate or candidate.strip() != configured:
|
if not candidate or candidate.strip() != configured:
|
||||||
logger.warning("Mission webhook forbidden for path=%s", request.url.path)
|
source = "query_or_arg"
|
||||||
|
if not token and request.headers.get("x-mission-token"):
|
||||||
|
source = "header"
|
||||||
|
|
||||||
|
masked = "<empty>"
|
||||||
|
if candidate:
|
||||||
|
c = candidate.strip()
|
||||||
|
masked = "***" if len(c) <= 8 else f"{c[:4]}...{c[-4:]}"
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"❌ Mission webhook forbidden path=%s reason=token_mismatch source=%s token=%s",
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
masked,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,14 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class MissionService:
|
class MissionService:
|
||||||
|
@staticmethod
|
||||||
|
def _safe(label: str, func, default):
|
||||||
|
try:
|
||||||
|
return func()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("❌ Mission state component failed: %s (%s)", label, exc)
|
||||||
|
return default
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _table_exists(table_name: str) -> bool:
|
def _table_exists(table_name: str) -> bool:
|
||||||
row = execute_query_single("SELECT to_regclass(%s) AS table_name", (f"public.{table_name}",))
|
row = execute_query_single("SELECT to_regclass(%s) AS table_name", (f"public.{table_name}",))
|
||||||
@ -234,21 +242,49 @@ class MissionService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_state() -> Dict[str, Any]:
|
def get_state() -> Dict[str, Any]:
|
||||||
|
kpis_default = {
|
||||||
|
"open_cases": 0,
|
||||||
|
"new_cases": 0,
|
||||||
|
"unassigned_cases": 0,
|
||||||
|
"deadlines_today": 0,
|
||||||
|
"overdue_deadlines": 0,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"kpis": MissionService.get_kpis(),
|
"kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default),
|
||||||
"active_calls": MissionService.get_active_calls(),
|
"active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, []),
|
||||||
"employee_deadlines": MissionService.get_employee_deadlines(),
|
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
|
||||||
"active_alerts": MissionService.get_active_alerts(),
|
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
|
||||||
"live_feed": MissionService.get_live_feed(20),
|
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
|
||||||
"config": {
|
"config": {
|
||||||
"display_queues": MissionService.parse_json_setting("mission_display_queues", []),
|
"display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []),
|
||||||
"sound_enabled": str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true",
|
"sound_enabled": MissionService._safe(
|
||||||
"sound_volume": int(MissionService.get_setting_value("mission_sound_volume", "70") or 70),
|
"config.sound_enabled",
|
||||||
"sound_events": MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]),
|
lambda: str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true",
|
||||||
"kpi_visible": MissionService.parse_json_setting(
|
True,
|
||||||
"mission_kpi_visible",
|
),
|
||||||
|
"sound_volume": MissionService._safe(
|
||||||
|
"config.sound_volume",
|
||||||
|
lambda: int(MissionService.get_setting_value("mission_sound_volume", "70") or 70),
|
||||||
|
70,
|
||||||
|
),
|
||||||
|
"sound_events": MissionService._safe(
|
||||||
|
"config.sound_events",
|
||||||
|
lambda: MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]),
|
||||||
|
["incoming_call", "uptime_down", "critical_event"],
|
||||||
|
),
|
||||||
|
"kpi_visible": MissionService._safe(
|
||||||
|
"config.kpi_visible",
|
||||||
|
lambda: MissionService.parse_json_setting(
|
||||||
|
"mission_kpi_visible",
|
||||||
|
["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"],
|
||||||
|
),
|
||||||
["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"],
|
["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"],
|
||||||
),
|
),
|
||||||
"customer_filter": MissionService.get_setting_value("mission_customer_filter", "") or "",
|
"customer_filter": MissionService._safe(
|
||||||
|
"config.customer_filter",
|
||||||
|
lambda: MissionService.get_setting_value("mission_customer_filter", "") or "",
|
||||||
|
"",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -114,6 +114,28 @@ class QuickCreateRequest(BaseModel):
|
|||||||
user_id: int
|
user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class SagSendEmailRequest(BaseModel):
|
||||||
|
to: List[str]
|
||||||
|
subject: str = Field(..., min_length=1, max_length=998)
|
||||||
|
body_text: str = Field(..., min_length=1)
|
||||||
|
cc: List[str] = Field(default_factory=list)
|
||||||
|
bcc: List[str] = Field(default_factory=list)
|
||||||
|
body_html: Optional[str] = None
|
||||||
|
attachment_file_ids: List[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
|
||||||
|
cleaned: List[str] = []
|
||||||
|
for value in values or []:
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
if "@" not in candidate or "." not in candidate.split("@")[-1]:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid email in {field_name}: {candidate}")
|
||||||
|
cleaned.append(candidate)
|
||||||
|
return list(dict.fromkeys(cleaned))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis)
|
@router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis)
|
||||||
async def analyze_quick_create(request: QuickCreateRequest):
|
async def analyze_quick_create(request: QuickCreateRequest):
|
||||||
"""
|
"""
|
||||||
@ -577,6 +599,86 @@ async def update_sag(sag_id: int, updates: dict):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to update case")
|
raise HTTPException(status_code=500, detail="Failed to update case")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Beskrivelse inline editing with history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BeskrivelsePatch(BaseModel):
|
||||||
|
beskrivelse: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sag/{sag_id}/beskrivelse")
|
||||||
|
async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: Request):
|
||||||
|
"""Update case description and store a change history entry."""
|
||||||
|
try:
|
||||||
|
row = execute_query_single(
|
||||||
|
"SELECT id, beskrivelse FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(sag_id,)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
|
old_beskrivelse = row.get("beskrivelse")
|
||||||
|
new_beskrivelse = body.beskrivelse
|
||||||
|
|
||||||
|
# Resolve acting user (may be None for anonymous)
|
||||||
|
user_id = _get_user_id_from_request(request)
|
||||||
|
changed_by_name = None
|
||||||
|
if user_id:
|
||||||
|
u = execute_query_single(
|
||||||
|
"SELECT COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS name FROM users WHERE user_id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
if u:
|
||||||
|
changed_by_name = u["name"]
|
||||||
|
|
||||||
|
# Write history entry
|
||||||
|
execute_query(
|
||||||
|
"""INSERT INTO sag_beskrivelse_history
|
||||||
|
(sag_id, beskrivelse_before, beskrivelse_after, changed_by_user_id, changed_by_name)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)""",
|
||||||
|
(sag_id, old_beskrivelse, new_beskrivelse, user_id, changed_by_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the case
|
||||||
|
execute_query(
|
||||||
|
"UPDATE sag_sager SET beskrivelse = %s, updated_at = NOW() WHERE id = %s",
|
||||||
|
(new_beskrivelse, sag_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ Beskrivelse updated for sag %s by user %s", sag_id, user_id)
|
||||||
|
return {"ok": True, "beskrivelse": new_beskrivelse}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error updating beskrivelse for sag %s: %s", sag_id, e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update description")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sag/{sag_id}/beskrivelse/history")
|
||||||
|
async def get_sag_beskrivelse_history(sag_id: int):
|
||||||
|
"""Return the change history for a case's description, newest first."""
|
||||||
|
exists = execute_query_single(
|
||||||
|
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(sag_id,)
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""SELECT id, beskrivelse_before, beskrivelse_after,
|
||||||
|
changed_by_name, changed_at
|
||||||
|
FROM sag_beskrivelse_history
|
||||||
|
WHERE sag_id = %s
|
||||||
|
ORDER BY changed_at DESC
|
||||||
|
LIMIT 50""",
|
||||||
|
(sag_id,)
|
||||||
|
) or []
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
class PipelineUpdate(BaseModel):
|
class PipelineUpdate(BaseModel):
|
||||||
amount: Optional[float] = None
|
amount: Optional[float] = None
|
||||||
probability: Optional[int] = Field(default=None, ge=0, le=100)
|
probability: Optional[int] = Field(default=None, ge=0, le=100)
|
||||||
@ -757,6 +859,15 @@ async def delete_relation(sag_id: int, relation_id: int):
|
|||||||
# TAGS - Case Tags
|
# TAGS - Case Tags
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/sag/tags/all")
|
||||||
|
async def get_all_tags():
|
||||||
|
"""Return all distinct tag names across all cases (for autocomplete)."""
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn ASC LIMIT 200"
|
||||||
|
) or []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}/tags")
|
@router.get("/sag/{sag_id}/tags")
|
||||||
async def get_tags(sag_id: int):
|
async def get_tags(sag_id: int):
|
||||||
"""Get all tags for a case."""
|
"""Get all tags for a case."""
|
||||||
@ -2038,6 +2149,154 @@ async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
|
|||||||
await add_sag_email_link(sag_id, {"email_id": email_id})
|
await add_sag_email_link(sag_id, {"email_id": email_id})
|
||||||
return {"status": "imported", "email_id": email_id}
|
return {"status": "imported", "email_id": email_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sag/{sag_id}/emails/send")
|
||||||
|
async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||||
|
"""Send outbound email directly from case email tab and link it to case."""
|
||||||
|
case_exists = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
||||||
|
if not case_exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
|
to_addresses = _normalize_email_list(payload.to, "to")
|
||||||
|
cc_addresses = _normalize_email_list(payload.cc, "cc")
|
||||||
|
bcc_addresses = _normalize_email_list(payload.bcc, "bcc")
|
||||||
|
|
||||||
|
if not to_addresses:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one recipient in 'to' is required")
|
||||||
|
|
||||||
|
subject = (payload.subject or "").strip()
|
||||||
|
body_text = (payload.body_text or "").strip()
|
||||||
|
if not subject:
|
||||||
|
raise HTTPException(status_code=400, detail="subject is required")
|
||||||
|
if not body_text:
|
||||||
|
raise HTTPException(status_code=400, detail="body_text is required")
|
||||||
|
|
||||||
|
attachment_rows = []
|
||||||
|
attachment_ids = list(dict.fromkeys(payload.attachment_file_ids or []))
|
||||||
|
if attachment_ids:
|
||||||
|
placeholders = ",".join(["%s"] * len(attachment_ids))
|
||||||
|
attachment_query = f"""
|
||||||
|
SELECT id, filename, content_type, size_bytes, stored_name
|
||||||
|
FROM sag_files
|
||||||
|
WHERE sag_id = %s AND id IN ({placeholders})
|
||||||
|
"""
|
||||||
|
attachment_rows = execute_query(attachment_query, (sag_id, *attachment_ids))
|
||||||
|
|
||||||
|
if len(attachment_rows) != len(attachment_ids):
|
||||||
|
raise HTTPException(status_code=400, detail="One or more selected attachments were not found on this case")
|
||||||
|
|
||||||
|
smtp_attachments = []
|
||||||
|
for row in attachment_rows:
|
||||||
|
path = _resolve_attachment_path(row["stored_name"])
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Attachment file is missing on server: {row['filename']}")
|
||||||
|
|
||||||
|
smtp_attachments.append({
|
||||||
|
"filename": row["filename"],
|
||||||
|
"content_type": row.get("content_type") or "application/octet-stream",
|
||||||
|
"content": path.read_bytes(),
|
||||||
|
"size": row.get("size_bytes") or 0,
|
||||||
|
"file_path": str(path),
|
||||||
|
})
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
|
success, send_message, generated_message_id = await email_service.send_email_with_attachments(
|
||||||
|
to_addresses=to_addresses,
|
||||||
|
subject=subject,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=payload.body_html,
|
||||||
|
cc=cc_addresses,
|
||||||
|
bcc=bcc_addresses,
|
||||||
|
attachments=smtp_attachments,
|
||||||
|
respect_dry_run=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.error("❌ Failed to send case email for case %s: %s", sag_id, send_message)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to send email")
|
||||||
|
|
||||||
|
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
|
||||||
|
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
|
||||||
|
|
||||||
|
insert_email_query = """
|
||||||
|
INSERT INTO email_messages (
|
||||||
|
message_id, subject, sender_email, sender_name,
|
||||||
|
recipient_email, cc, body_text, body_html,
|
||||||
|
received_date, folder, has_attachments, attachment_count,
|
||||||
|
status, import_method, linked_case_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
insert_result = execute_query(
|
||||||
|
insert_email_query,
|
||||||
|
(
|
||||||
|
generated_message_id,
|
||||||
|
subject,
|
||||||
|
sender_email,
|
||||||
|
sender_name,
|
||||||
|
", ".join(to_addresses),
|
||||||
|
", ".join(cc_addresses),
|
||||||
|
body_text,
|
||||||
|
payload.body_html,
|
||||||
|
datetime.now(),
|
||||||
|
"Sent",
|
||||||
|
bool(smtp_attachments),
|
||||||
|
len(smtp_attachments),
|
||||||
|
"sent",
|
||||||
|
"sag_outbound",
|
||||||
|
sag_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not insert_result:
|
||||||
|
logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id)
|
||||||
|
raise HTTPException(status_code=500, detail="Email sent but logging failed")
|
||||||
|
|
||||||
|
email_id = insert_result[0]["id"]
|
||||||
|
|
||||||
|
if smtp_attachments:
|
||||||
|
from psycopg2 import Binary
|
||||||
|
|
||||||
|
for attachment in smtp_attachments:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO email_attachments (
|
||||||
|
email_id, filename, content_type, size_bytes, file_path, content_data
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
email_id,
|
||||||
|
attachment["filename"],
|
||||||
|
attachment["content_type"],
|
||||||
|
attachment.get("size") or len(attachment["content"]),
|
||||||
|
attachment.get("file_path"),
|
||||||
|
Binary(attachment["content"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_emails (sag_id, email_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
(sag_id, email_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"✅ Outbound case email sent and linked (case=%s, email_id=%s, recipients=%s)",
|
||||||
|
sag_id,
|
||||||
|
email_id,
|
||||||
|
", ".join(to_addresses),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "sent",
|
||||||
|
"email_id": email_id,
|
||||||
|
"message": send_message,
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SOLUTIONS
|
# SOLUTIONS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
7009
app/modules/sag/templates/detail.html.bak
Normal file
7009
app/modules/sag/templates/detail.html.bak
Normal file
File diff suppressed because it is too large
Load Diff
@ -112,19 +112,54 @@ def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
|
|||||||
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
|
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
|
||||||
accepted_tokens = {s for s in (env_secret, db_secret) if s}
|
accepted_tokens = {s for s in (env_secret, db_secret) if s}
|
||||||
whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip()
|
whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip()
|
||||||
|
client_ip = _get_client_ip(request)
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
def _mask(value: Optional[str]) -> str:
|
||||||
|
if not value:
|
||||||
|
return "<empty>"
|
||||||
|
stripped = value.strip()
|
||||||
|
if len(stripped) <= 8:
|
||||||
|
return "***"
|
||||||
|
return f"{stripped[:4]}...{stripped[-4:]}"
|
||||||
|
|
||||||
if not accepted_tokens and not whitelist:
|
if not accepted_tokens and not whitelist:
|
||||||
logger.error("❌ Telefoni callbacks are not secured (no TELEFONI_SHARED_SECRET or TELEFONI_IP_WHITELIST set)")
|
logger.error(
|
||||||
|
"❌ Telefoni callback rejected path=%s reason=no_security_config ip=%s",
|
||||||
|
path,
|
||||||
|
client_ip,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=403, detail="Telefoni callbacks not configured")
|
raise HTTPException(status_code=403, detail="Telefoni callbacks not configured")
|
||||||
|
|
||||||
if token and token.strip() in accepted_tokens:
|
if token and token.strip() in accepted_tokens:
|
||||||
|
logger.debug("✅ Telefoni callback accepted path=%s auth=token ip=%s", path, client_ip)
|
||||||
return
|
return
|
||||||
|
|
||||||
if whitelist:
|
if token and accepted_tokens:
|
||||||
client_ip = _get_client_ip(request)
|
logger.warning(
|
||||||
if ip_in_whitelist(client_ip, whitelist):
|
"⚠️ Telefoni callback token mismatch path=%s ip=%s provided=%s accepted_sources=%s",
|
||||||
return
|
path,
|
||||||
|
client_ip,
|
||||||
|
_mask(token),
|
||||||
|
"+".join([name for name, value in (("env", env_secret), ("db", db_secret)) if value]) or "none",
|
||||||
|
)
|
||||||
|
elif not token:
|
||||||
|
logger.info("ℹ️ Telefoni callback without token path=%s ip=%s", path, client_ip)
|
||||||
|
|
||||||
|
if whitelist:
|
||||||
|
if ip_in_whitelist(client_ip, whitelist):
|
||||||
|
logger.debug("✅ Telefoni callback accepted path=%s auth=ip_whitelist ip=%s", path, client_ip)
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Telefoni callback IP not in whitelist path=%s ip=%s whitelist=%s",
|
||||||
|
path,
|
||||||
|
client_ip,
|
||||||
|
whitelist,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("ℹ️ Telefoni callback whitelist not configured path=%s ip=%s", path, client_ip)
|
||||||
|
|
||||||
|
logger.warning("❌ Telefoni callback forbidden path=%s ip=%s", path, client_ip)
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -358,6 +358,7 @@ async function loadUsers() {
|
|||||||
opt.textContent = `${u.full_name || u.username || ('#' + u.user_id)}${u.telefoni_extension ? ' (' + u.telefoni_extension + ')' : ''}`;
|
opt.textContent = `${u.full_name || u.username || ('#' + u.user_id)}${u.telefoni_extension ? ' (' + u.telefoni_extension + ')' : ''}`;
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
sel.value = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed loading telefoni users', e);
|
console.error('Failed loading telefoni users', e);
|
||||||
}
|
}
|
||||||
@ -500,6 +501,16 @@ async function unlinkCase(callId) {
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initLinkSagModalEvents();
|
initLinkSagModalEvents();
|
||||||
|
const userFilter = document.getElementById('filterUser');
|
||||||
|
const fromFilter = document.getElementById('filterFrom');
|
||||||
|
const toFilter = document.getElementById('filterTo');
|
||||||
|
const withoutCaseFilter = document.getElementById('filterWithoutCase');
|
||||||
|
|
||||||
|
if (userFilter) userFilter.value = '';
|
||||||
|
if (fromFilter) fromFilter.value = '';
|
||||||
|
if (toFilter) toFilter.value = '';
|
||||||
|
if (withoutCaseFilter) withoutCaseFilter.checked = false;
|
||||||
|
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
document.getElementById('btnRefresh').addEventListener('click', loadCalls);
|
document.getElementById('btnRefresh').addEventListener('click', loadCalls);
|
||||||
document.getElementById('filterUser').addEventListener('change', loadCalls);
|
document.getElementById('filterUser').addEventListener('change', loadCalls);
|
||||||
|
|||||||
@ -11,11 +11,14 @@ import email
|
|||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.base import MIMEBase
|
||||||
|
from email import encoders
|
||||||
from typing import List, Dict, Optional, Tuple
|
from typing import List, Dict, Optional, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
# Try to import aiosmtplib, but don't fail if not available
|
# Try to import aiosmtplib, but don't fail if not available
|
||||||
try:
|
try:
|
||||||
@ -1013,3 +1016,101 @@ class EmailService:
|
|||||||
error_msg = f"❌ Failed to send email: {str(e)}"
|
error_msg = f"❌ Failed to send email: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
|
async def send_email_with_attachments(
|
||||||
|
self,
|
||||||
|
to_addresses: List[str],
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
body_html: Optional[str] = None,
|
||||||
|
cc: Optional[List[str]] = None,
|
||||||
|
bcc: Optional[List[str]] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
attachments: Optional[List[Dict]] = None,
|
||||||
|
respect_dry_run: bool = True,
|
||||||
|
) -> Tuple[bool, str, str]:
|
||||||
|
"""Send email via SMTP with optional attachments and return generated Message-ID."""
|
||||||
|
|
||||||
|
generated_message_id = f"<{uuid4().hex}@bmchub.local>"
|
||||||
|
|
||||||
|
if respect_dry_run and settings.REMINDERS_DRY_RUN:
|
||||||
|
logger.warning(
|
||||||
|
"🔒 DRY RUN MODE: Would send email to %s with subject '%s'",
|
||||||
|
to_addresses,
|
||||||
|
subject,
|
||||||
|
)
|
||||||
|
return True, "Dry run mode - email not actually sent", generated_message_id
|
||||||
|
|
||||||
|
if not HAS_AIOSMTPLIB:
|
||||||
|
logger.error("❌ aiosmtplib not installed - cannot send email. Install with: pip install aiosmtplib")
|
||||||
|
return False, "aiosmtplib not installed", generated_message_id
|
||||||
|
|
||||||
|
if not all([settings.EMAIL_SMTP_HOST, settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD]):
|
||||||
|
logger.error("❌ SMTP not configured - cannot send email")
|
||||||
|
return False, "SMTP not configured", generated_message_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart('mixed')
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = f"{settings.EMAIL_SMTP_FROM_NAME} <{settings.EMAIL_SMTP_FROM_ADDRESS}>"
|
||||||
|
msg['To'] = ', '.join(to_addresses)
|
||||||
|
msg['Message-ID'] = generated_message_id
|
||||||
|
|
||||||
|
if cc:
|
||||||
|
msg['Cc'] = ', '.join(cc)
|
||||||
|
if reply_to:
|
||||||
|
msg['Reply-To'] = reply_to
|
||||||
|
|
||||||
|
content_part = MIMEMultipart('alternative')
|
||||||
|
content_part.attach(MIMEText(body_text, 'plain'))
|
||||||
|
if body_html:
|
||||||
|
content_part.attach(MIMEText(body_html, 'html'))
|
||||||
|
msg.attach(content_part)
|
||||||
|
|
||||||
|
for attachment in (attachments or []):
|
||||||
|
content = attachment.get("content")
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filename = attachment.get("filename") or "attachment.bin"
|
||||||
|
content_type = attachment.get("content_type") or "application/octet-stream"
|
||||||
|
maintype, _, subtype = content_type.partition("/")
|
||||||
|
if not maintype or not subtype:
|
||||||
|
maintype, subtype = "application", "octet-stream"
|
||||||
|
|
||||||
|
mime_attachment = MIMEBase(maintype, subtype)
|
||||||
|
mime_attachment.set_payload(content)
|
||||||
|
encoders.encode_base64(mime_attachment)
|
||||||
|
mime_attachment.add_header('Content-Disposition', f'attachment; filename="{filename}"')
|
||||||
|
msg.attach(mime_attachment)
|
||||||
|
|
||||||
|
async with aiosmtplib.SMTP(
|
||||||
|
hostname=settings.EMAIL_SMTP_HOST,
|
||||||
|
port=settings.EMAIL_SMTP_PORT,
|
||||||
|
use_tls=settings.EMAIL_SMTP_USE_TLS
|
||||||
|
) as smtp:
|
||||||
|
await smtp.login(settings.EMAIL_SMTP_USER, settings.EMAIL_SMTP_PASSWORD)
|
||||||
|
|
||||||
|
all_recipients = to_addresses.copy()
|
||||||
|
if cc:
|
||||||
|
all_recipients.extend(cc)
|
||||||
|
if bcc:
|
||||||
|
all_recipients.extend(bcc)
|
||||||
|
|
||||||
|
await smtp.sendmail(
|
||||||
|
settings.EMAIL_SMTP_FROM_ADDRESS,
|
||||||
|
all_recipients,
|
||||||
|
msg.as_string()
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"✅ Email with attachments sent successfully to %s recipient(s): %s",
|
||||||
|
len(to_addresses),
|
||||||
|
subject,
|
||||||
|
)
|
||||||
|
return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"❌ Failed to send email with attachments: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return False, error_msg, generated_message_id
|
||||||
|
|||||||
12
apply_layout.py
Normal file
12
apply_layout.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# 1. Fjern max-width
|
||||||
|
content = content.replace('<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;">', '<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem;">')
|
||||||
|
|
||||||
|
# Find de dele vi vil genbruge (dette kræver præcis regex eller dom parsing)
|
||||||
|
# For denne opgave benytter vi en mere generel struktur opdatering ved at finde specifikke markører.
|
||||||
|
# Her antager jeg scriptet er et template udkast
|
||||||
|
print("Script executed.")
|
||||||
299
docs/sagsvisning_mockups.html
Normal file
299
docs/sagsvisning_mockups.html
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mockups - Sagsvisning</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #0f4c75; /* Nordic Top Deep Blue */
|
||||||
|
--bg-body: #f4f6f8;
|
||||||
|
--card-border: #e2e8f0;
|
||||||
|
}
|
||||||
|
body { background-color: var(--bg-body); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
|
||||||
|
.top-nav { background-color: #fff; padding: 15px; border-bottom: 2px solid var(--primary-color); margin-bottom: 20px; }
|
||||||
|
.mockup-container { display: none; }
|
||||||
|
.mockup-container.active { display: block; }
|
||||||
|
.card { border: 1px solid var(--card-border); box-shadow: 0 1px 3px rgba(0,0,0,0.05); margin-bottom: 1rem; border-radius: 8px; }
|
||||||
|
.card-header { background-color: #fff; border-bottom: 1px solid var(--card-border); font-weight: 600; color: var(--primary-color); }
|
||||||
|
.section-title { font-size: 0.85rem; text-transform: uppercase; color: #6c757d; font-weight: 700; margin-bottom: 10px; letter-spacing: 0.5px; }
|
||||||
|
.btn-primary { background-color: var(--primary-color); border-color: var(--primary-color); }
|
||||||
|
.badge-status { background-color: var(--primary-color); color: white; }
|
||||||
|
.timeline-item { border-left: 2px solid var(--card-border); padding-left: 15px; margin-bottom: 15px; position: relative; }
|
||||||
|
.timeline-item::before { content: ''; position: absolute; left: -6px; top: 0; width: 10px; height: 10px; border-radius: 50%; background: var(--primary-color); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="top-nav text-center">
|
||||||
|
<h4 class="mb-3" style="color: var(--primary-color);">Vælg Layout Mockup</h4>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-primary active" onclick="showMockup('mockup1', this)">Forslag 1: Arbejdsstationen (3 Kolonner)</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary" onclick="showMockup('mockup2', this)">Forslag 2: Tidslinjen (Inbox Flow)</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary" onclick="showMockup('mockup3', this)">Forslag 3: Det Fokuserede Workspace (Store Faner)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
|
||||||
|
<!-- FORSLAG 1: TRE KOLONNER -->
|
||||||
|
<div id="mockup1" class="mockup-container active">
|
||||||
|
<h5 class="text-muted"><i class="fas fa-columns"></i> Forslag 1: Arbejdsstationen (Kontekst -> Arbejde -> Styring)</h5>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Header status -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body py-2 d-flex justify-content-between align-items-center flex-wrap">
|
||||||
|
<div><strong>ID: 1</strong> <span class="badge badge-status">åben</span> | <strong>Kunde:</strong> Blåhund Import (TEST) | <strong>Kontakt:</strong> Janne Vinter</div>
|
||||||
|
<div><strong>Datoer:</strong> Opr: 01/03-26 | <strong>Deadline:</strong> <span class="text-danger border border-danger p-1 rounded"><i class="far fa-clock"></i> 03/03-26</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Kol 1: Kontekst (Venstre) -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="section-title">Kontekst & Stamdata</div>
|
||||||
|
<div class="card"><div class="card-header"><i class="fas fa-building"></i> Kunder</div><div class="card-body py-2"><small>Blåhund Import (TEST)</small></div></div>
|
||||||
|
<div class="card"><div class="card-header"><i class="fas fa-users"></i> Kontakter</div><div class="card-body py-2"><small>Janne Vinter</small></div></div>
|
||||||
|
<div class="card"><div class="card-header"><i class="fas fa-laptop"></i> Hardware</div><div class="card-body py-2"><span class="text-muted small">Ingen valgt</span></div></div>
|
||||||
|
<div class="card"><div class="card-header"><i class="fas fa-map-marker-alt"></i> Lokationer</div><div class="card-body py-2"><span class="text-muted small">Ingen valgt</span></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kol 2: Arbejde (Midten) -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="section-title">Arbejdsflade</div>
|
||||||
|
|
||||||
|
<!-- Beskrivelse altid synlig -->
|
||||||
|
<div class="card border-primary mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">dette er en test sag</h5>
|
||||||
|
<p class="card-text text-muted mb-0">Ingen beskrivelse tilføjet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Faner tager sig af resten -->
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item"><a class="nav-link active" href="#">Sagsdetaljer</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><i class="fas fa-wrench"></i> Løsning</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><i class="fas fa-envelope"></i> E-mail</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#"><i class="fas fa-shopping-basket"></i> Varekøb & Salg</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6><i class="fas fa-link"></i> Relationer</h6>
|
||||||
|
<div class="border rounded p-2 mb-3 bg-light"><small>#2 Undersag 1 -> Afledt af #1 dette er en test sag</small></div>
|
||||||
|
|
||||||
|
<h6><i class="fas fa-phone"></i> Opkaldshistorik</h6>
|
||||||
|
<div class="border rounded p-2 mb-3 text-center text-muted"><small>Ingen opkald registreret</small></div>
|
||||||
|
|
||||||
|
<h6><i class="fas fa-paperclip"></i> Filer & Dokumenter</h6>
|
||||||
|
<div class="border rounded p-3 text-center bg-light border-dashed"><small><i class="fas fa-cloud-upload-alt fs-4 d-block mb-1"></i> Træk filer hertil for at uploade</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kol 3: Styring (Højre) -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="section-title">Sagstyring</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Ansvar & Tildeling</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<label class="form-label small">Ansvarlig medarbejder</label>
|
||||||
|
<select class="form-select form-select-sm mb-2"><option>Ingen</option></select>
|
||||||
|
<label class="form-label small">Ansvarlig gruppe</label>
|
||||||
|
<select class="form-select form-select-sm mb-3"><option>Technicians</option></select>
|
||||||
|
<button class="btn btn-primary btn-sm w-100">Gem Tildeling</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="fas fa-check-square text-success"></i> Todo-opgaver</div>
|
||||||
|
<div class="card-body text-center py-4 text-muted"><small>Ingen opgaver endnu</small><br><button class="btn btn-outline-secondary btn-sm mt-2"><i class="fas fa-plus"></i> Opret</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- FORSLAG 2: TIDSLINJEN -->
|
||||||
|
<div id="mockup2" class="mockup-container">
|
||||||
|
<h5 class="text-muted"><i class="fas fa-stream"></i> Forslag 2: Tidslinjen (Fokus på flow og kommunikation)</h5>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Sticky Kompakt Header -->
|
||||||
|
<div class="card shadow-sm border-0 mb-4 sticky-top" style="z-index: 1000; top: 0;">
|
||||||
|
<div class="card-body py-2 d-flex justify-content-between align-items-center fs-6 bg-white">
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-status me-2">ID: 1</span>
|
||||||
|
<strong>Blåhund Import</strong> <span class="text-muted">/ Janne Vinter</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<select class="form-select form-select-sm" style="width: auto;"><option>Ingen (Technicians)</option></select>
|
||||||
|
<span class="badge bg-danger">Frist: 03/03-26</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Hoved feed (Venstre) -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
|
||||||
|
<!-- Beskrivelse - Hero boks -->
|
||||||
|
<div class="p-4 rounded mb-4" style="background-color: #e3f2fd; border-left: 4px solid var(--primary-color);">
|
||||||
|
<h4 class="mb-1">dette er en test sag</h4>
|
||||||
|
<p class="mb-0 text-muted">Ingen beskrivelse angivet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handlingsmoduler - Inline tabs for inputs -->
|
||||||
|
<div class="card mb-4 bg-light">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<button class="btn btn-sm btn-outline-primary"><i class="fas fa-comment"></i> Nyt Svar/Notat</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"><i class="fas fa-wrench"></i> Registrer Løsning/Tid</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"><i class="fas fa-shopping-basket"></i> Tilføj Vare</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"><i class="fas fa-paperclip"></i> Vedhæft fil</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tidslinjen / Log -->
|
||||||
|
<h6 class="text-muted"><i class="fas fa-history"></i> Aktivitet & Historik</h6>
|
||||||
|
<div class="bg-white p-3 rounded border">
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="small fw-bold">System <span class="text-muted fw-normal float-end">01/03/2026 14:00</span></div>
|
||||||
|
<div>Sagen blev oprettet.</div>
|
||||||
|
<div class="mt-2 p-2 bg-light border rounded"><small>Relation: #2 Undersag 1 tilknyttet.</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center text-muted small mt-4"><i class="fas fa-check"></i> Slut på historik</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar (Højre) -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Sagsfakta & Stamdata</div>
|
||||||
|
<div class="accordion accordion-flush" id="accordionFakta">
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header"><button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#fakta1"><i class="fas fa-building me-2"></i> Kunde & Kontakt</button></h2>
|
||||||
|
<div id="fakta1" class="accordion-collapse collapse"><div class="accordion-body small">Blåhund Import (TEST)<br>Janne Vinter</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header"><button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#fakta2"><i class="fas fa-laptop me-2"></i> Hardware & Lokation</button></h2>
|
||||||
|
<div id="fakta2" class="accordion-collapse collapse"><div class="accordion-body small text-muted">Intet valgt</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="fas fa-check-square"></i> Todo-opgaver & Wiki</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<input type="text" class="form-control form-control-sm mb-2" placeholder="Søg i Wiki...">
|
||||||
|
<hr>
|
||||||
|
<div class="text-center text-muted small"><small>Ingen Todo-opgaver</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- FORSLAG 3: DET FOKUSEREDE WORKSPACE -->
|
||||||
|
<div id="mockup3" class="mockup-container">
|
||||||
|
<h5 class="text-muted"><i class="fas fa-window-maximize"></i> Forslag 3: Fokuseret Workspace (Store kategoriserede faner)</h5>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Sidebar venstre (Lille) -->
|
||||||
|
<div class="col-md-2 border-end" style="min-height: 70vh;">
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="small fw-bold text-muted mb-2">Sags Info</div>
|
||||||
|
<div class="fs-5 text-primary fw-bold">#1 åben</div>
|
||||||
|
<div class="small mt-1 text-danger"><i class="far fa-clock"></i> 03/03-26</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="small fw-bold text-muted mb-2">Tildeling</div>
|
||||||
|
<select class="form-select form-select-sm mb-1"><option>Ingen</option></select>
|
||||||
|
<select class="form-select form-select-sm"><option>Technicians</option></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="small fw-bold text-muted mb-2">Hurtige links</div>
|
||||||
|
<ul class="nav flex-column small">
|
||||||
|
<li class="nav-item"><a class="nav-link px-0 text-dark" href="#"><i class="fas fa-link me-1"></i> Relationer (1)</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link px-0 text-dark" href="#"><i class="fas fa-check-square me-1 text-success"></i> Todo (0)</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link px-0 text-dark" href="#"><i class="fas fa-book me-1"></i> Wiki søgning</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hovedarbejdsflade -->
|
||||||
|
<div class="col-md-10">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 class="m-0">dette er en test sag</h3>
|
||||||
|
<button class="btn btn-outline-primary btn-sm"><i class="fas fa-edit"></i> Rediger</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STORE arbejdsfaner -->
|
||||||
|
<ul class="nav nav-pills nav-fill mb-4 border rounded bg-white shadow-sm p-1">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active fw-bold" href="#"><i class="fas fa-eye"></i> 1. Overblik & Stamdata</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-dark fw-bold" href="#"><i class="fas fa-wrench"></i> 2. Løsning & Salg</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-dark fw-bold" href="#"><i class="fas fa-comments"></i> 3. Kommunikation (Mail/Log)</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Indhold for aktiv fane (Overblik) -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="text-primary border-bottom pb-2 mb-3">Beskrivelse</h5>
|
||||||
|
<p class="text-muted">Ingen beskrivelse tilføjet for denne sag. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||||
|
|
||||||
|
<div class="row mt-5">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold">Personer & Steder</h6>
|
||||||
|
<table class="table table-sm table-borderless">
|
||||||
|
<tr><td class="text-muted w-25">Kunde</td><td><strong>Blåhund Import (TEST)</strong></td></tr>
|
||||||
|
<tr><td class="text-muted">Kontakt</td><td>Janne Vinter</td></tr>
|
||||||
|
<tr><td class="text-muted">Lokation</td><td>-</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold">Udstyr</h6>
|
||||||
|
<table class="table table-sm table-borderless">
|
||||||
|
<tr><td class="text-muted w-25">Hardware</td><td>-</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showMockup(id, btnClicked) {
|
||||||
|
// Skjul alle
|
||||||
|
document.querySelectorAll('.mockup-container').forEach(el => el.classList.remove('active'));
|
||||||
|
// Fjern active state fra knapper
|
||||||
|
document.querySelectorAll('.btn-group .btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
|
||||||
|
// Vis valgte
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
btnClicked.classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
141
fix_cols.py
Normal file
141
fix_cols.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
def fix_columns():
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Udskift selve start containeren!
|
||||||
|
# Målet er at omdanne:
|
||||||
|
# <div class="col-lg-8" id="case-left-column"> ... (Hero card, etc.)
|
||||||
|
# <div class="col-lg-4" id="case-right-column"> ... (Tidsreg, etc.)
|
||||||
|
|
||||||
|
# Fordi det er komplekst at udtrække hver enkelt data-module fra en stor fil uden at tabe layout,
|
||||||
|
# griber vi det an ved at ændre CSS klasserne på container niveauet HVIS vi kun ville ha flex,
|
||||||
|
# men for rigtige 3 kolonner flytter vi `case-left-column`s grid definitioner.
|
||||||
|
|
||||||
|
# Vi kan bygge 3 kolonner inde "case-left-column" + "case-right-column" er den 3. kolonne.
|
||||||
|
# Så left -> 2 kolonner, right -> 1 kolonne. Total 3.
|
||||||
|
# Nu er left = col-lg-8. Vi gør den til col-xl-9 col-lg-8.
|
||||||
|
# Right = col-lg-4. Bliver til col-xl-3 col-lg-4.
|
||||||
|
|
||||||
|
# INDE i left:
|
||||||
|
# Put et grid: <div class="row"><div class="col-xl-4"> (Venstre) </div> <div class="col-xl-8"> (Midten med Opgavebeskivelse) </div></div>
|
||||||
|
|
||||||
|
# Step 1: Let's find "id="case-left-column""
|
||||||
|
html = html.replace('<div class="col-lg-8" id="case-left-column">', '<div class="col-xl-9 col-lg-8" id="case-left-column">\n<div class="row g-4">\n<!-- TREDELT-1: Relations, History, etc. -->\n<div class="col-xl-4 order-2 order-xl-1" id="inner-left-col">\n</div>\n<!-- TREDELT-2: Hero, Info -->\n<div class="col-xl-8 order-1 order-xl-2" id="inner-center-col">\n')
|
||||||
|
|
||||||
|
html = html.replace('<div class="col-lg-4" id="case-right-column">', '</div></div><!-- slut inner cols -->\n</div>\n<div class="col-xl-3 col-lg-4" id="case-right-column">')
|
||||||
|
|
||||||
|
# Now we need to MOVE widgets from "inner-center-col" (where everything currently is) to "inner-left-col".
|
||||||
|
# The widgets we want to move are:
|
||||||
|
# 'relations'
|
||||||
|
# 'call-history'
|
||||||
|
# 'pipeline'
|
||||||
|
|
||||||
|
def move_widget(widget_name, dest_id, current_html):
|
||||||
|
pattern = f'data-module="{widget_name}"'
|
||||||
|
match = current_html.find(pattern)
|
||||||
|
if match == -1:
|
||||||
|
return current_html
|
||||||
|
|
||||||
|
div_start = current_html.rfind('<div class="row mb-3"', max(0, match - 200), match)
|
||||||
|
if div_start == -1:
|
||||||
|
div_start = current_html.rfind('<div class="card', max(0, match - 200), match)
|
||||||
|
|
||||||
|
if div_start == -1:
|
||||||
|
return current_html
|
||||||
|
|
||||||
|
# Find balanced end
|
||||||
|
count = 0
|
||||||
|
i = div_start
|
||||||
|
end_idx = -1
|
||||||
|
while i < len(current_html):
|
||||||
|
if current_html.startswith('<div', i):
|
||||||
|
count += 1
|
||||||
|
i += 4
|
||||||
|
elif current_html.startswith('</div', i):
|
||||||
|
count -= 1
|
||||||
|
if count <= 0:
|
||||||
|
i = current_html.find('>', i) + 1
|
||||||
|
end_idx = i
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
i += 5
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if end_idx != -1:
|
||||||
|
widget = current_html[div_start:end_idx]
|
||||||
|
# Fjern fra oprendelig plads
|
||||||
|
current_html = current_html[:div_start] + current_html[end_idx:]
|
||||||
|
|
||||||
|
# Sæt ind i ny plads (lige efter dest_id div'en)
|
||||||
|
dest_pattern = f'id="{dest_id}">\n'
|
||||||
|
dest_pos = current_html.find(dest_pattern)
|
||||||
|
if dest_pos != -1:
|
||||||
|
insert_pos = dest_pos + len(dest_pattern)
|
||||||
|
current_html = current_html[:insert_pos] + widget + "\n" + current_html[insert_pos:]
|
||||||
|
|
||||||
|
return current_html
|
||||||
|
|
||||||
|
html = move_widget('relations', 'inner-left-col', html)
|
||||||
|
html = move_widget('call-history', 'inner-left-col', html)
|
||||||
|
html = move_widget('pipeline', 'inner-left-col', html)
|
||||||
|
|
||||||
|
# Nogle widgets ligger i right-col, som vi gerne vil have i left col nu?
|
||||||
|
# Contacts, Customers, Locations
|
||||||
|
# De ligger ikke i en <div class="row mb-3">, de er bare direkte `<div class="card h-100...`
|
||||||
|
# Let's extract them correctly
|
||||||
|
def move_card(widget_name, dest_id, current_html):
|
||||||
|
pattern = f'data-module="{widget_name}"'
|
||||||
|
match = current_html.find(pattern)
|
||||||
|
if match == -1:
|
||||||
|
return current_html
|
||||||
|
|
||||||
|
div_start = current_html.rfind('<div class="card', max(0, match - 200), match)
|
||||||
|
if div_start == -1:
|
||||||
|
return current_html
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
i = div_start
|
||||||
|
end_idx = -1
|
||||||
|
while i < len(current_html):
|
||||||
|
if current_html.startswith('<div', i):
|
||||||
|
count += 1
|
||||||
|
i += 4
|
||||||
|
elif current_html.startswith('</div', i):
|
||||||
|
count -= 1
|
||||||
|
if count <= 0:
|
||||||
|
i = current_html.find('>', i) + 1
|
||||||
|
end_idx = i
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
i += 5
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if end_idx != -1:
|
||||||
|
widget = current_html[div_start:end_idx]
|
||||||
|
# De er ofte svøbt i en class mb-3 i col-right. Hvis ikke, læg vi en mb-3 kappe
|
||||||
|
widget = f'<div class="mb-3">{widget}</div>'
|
||||||
|
current_html = current_html[:div_start] + current_html[end_idx:]
|
||||||
|
|
||||||
|
dest_pattern = f'id="{dest_id}">\n'
|
||||||
|
dest_pos = current_html.find(dest_pattern)
|
||||||
|
if dest_pos != -1:
|
||||||
|
insert_pos = dest_pos + len(dest_pattern)
|
||||||
|
current_html = current_html[:insert_pos] + widget + "\n" + current_html[insert_pos:]
|
||||||
|
|
||||||
|
return current_html
|
||||||
|
|
||||||
|
html = move_card('contacts', 'inner-left-col', html)
|
||||||
|
html = move_card('customers', 'inner-left-col', html)
|
||||||
|
html = move_card('locations', 'inner-left-col', html)
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
print("Drejede kolonnerne på plads!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
fix_columns()
|
||||||
63
fix_desc2.py
Normal file
63
fix_desc2.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
def replace_desc():
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
start_str = "<!-- ROW 1: Main Info -->"
|
||||||
|
end_str = "<!-- ROW 1B: Pipeline -->"
|
||||||
|
|
||||||
|
start_idx = html.find(start_str)
|
||||||
|
end_idx = html.find(end_str)
|
||||||
|
|
||||||
|
if start_idx == -1 or end_idx == -1:
|
||||||
|
print("COULD NOT FIND ROWS")
|
||||||
|
return
|
||||||
|
|
||||||
|
new_desc = """<!-- ROW 1: Main Info -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<!-- MAIN HERO CARD: Titel & Beskrivelse -->
|
||||||
|
<div class="col-12 mb-4 mt-2">
|
||||||
|
<div class="card shadow-sm border-0 border-start border-4 border-primary" style="background-color: var(--bg-card); border-radius: 8px;">
|
||||||
|
<div class="card-body p-4 pt-4 pb-5 position-relative">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||||
|
<div class="w-100 pe-3">
|
||||||
|
<h2 class="mb-2 fw-bolder" style="color: var(--accent); font-size: 1.8rem; letter-spacing: -0.5px;">
|
||||||
|
{{ case.titel }}
|
||||||
|
</h2>
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1 mt-2">
|
||||||
|
<span class="badge {{ 'bg-success' if case.status == 'åben' else 'bg-secondary' }} px-2 py-1 shadow-sm">{{ case.status }}</span>
|
||||||
|
<span class="badge bg-light text-dark border px-2 py-1">{{ case.template_key or case.type or 'ticket' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-shrink-0 mt-1">
|
||||||
|
<a href="/sag/{{ case.id }}/edit" class="btn btn-outline-primary shadow-sm" style="border-radius: 6px;">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Rediger sag
|
||||||
|
</a>
|
||||||
|
<button onclick="confirmDeleteCase()" class="btn btn-outline-danger shadow-sm" style="border-radius: 6px;" title="Slet sag">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 pt-3 border-top border-light">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-card-text fs-5 text-muted me-2"></i>
|
||||||
|
<h6 class="text-muted text-uppercase small mb-0 fw-bold" style="letter-spacing: 0.05em;">Opgavebeskrivelse</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="description-section rounded bg-white p-4 shadow-sm border" style="min-height: 120px;">
|
||||||
|
<div class="prose text-dark" style="font-size: 1.05rem; line-height: 1.7; white-space: pre-wrap;">{{ case.beskrivelse or '<div class="text-center p-3"><p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p></div>' | safe }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
html = html[:start_idx] + new_desc + "\n " + html[end_idx:]
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
print("Done description")
|
||||||
|
|
||||||
|
replace_desc()
|
||||||
82
fix_hero_desc.py
Normal file
82
fix_hero_desc.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_block = """ <!-- Main Case Info -->
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="card h-100 d-flex flex-column case-summary-card">
|
||||||
|
<div class="card-header case-summary-header">
|
||||||
|
<div>
|
||||||
|
<div class="case-summary-title">{{ case.titel }}</div>
|
||||||
|
<div class="case-summary-meta">
|
||||||
|
<span class="case-pill">#{{ case.id }}</span>
|
||||||
|
<span class="case-pill">{{ case.status }}</span>
|
||||||
|
<span class="case-pill case-pill-muted">{{ case.template_key or case.type or 'ticket' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<a href="/sag/{{ case.id }}/edit" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil">
|
||||||
|
with op content = f.read()
|
||||||
|
|
||||||
|
old_block = """ <!-- Main Cas="
|
||||||
|
old_block = """ e-d <div class="col-12 mb-3">
|
||||||
|
<div class="card h-1bi <div class="card-header case-summary-header">
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class c <div class="case-summary-meta">
|
||||||
|
<span class="case-pill">#{er <span class="case-pill">{{ case.status }}</sel <span class="case-pill case-pill-muted">{{ case s </div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2"</ </div>
|
||||||
|
|
||||||
|
</d <a href="/sag/{{ case.id }}/edit" class=ri <i class="bi bi-pencil">
|
||||||
|
with op content = f.read()
|
||||||
|
|
||||||
|
ol-0 borderwith op content = f.read()
|
||||||
|
|
||||||
|
old_block = """ ol
|
||||||
|
old_block = """ <!-- Maus:old_block = """ e-d <div car <div class="card h-1bi >
|
||||||
|
<div>
|
||||||
|
<div class c "w ">
|
||||||
|
<span class="case-pill">#{er <span cla </div>
|
||||||
|
<div class="d-flex align-items-center gap-2"</ </div>
|
||||||
|
|
||||||
|
</d <a href="/sag/{{ case.id }}/edit" class=ri <i class="bi b } <div m"
|
||||||
|
</d <a href="/sag/{{ case.id }}/edit" clda </d 1"with op content = f.read()
|
||||||
|
|
||||||
|
ol-0 borderwith op content = f.read()
|
||||||
|
|
||||||
|
old_block = """ ol
|
||||||
|
old_block = """ <!-- M
|
||||||
|
ol-0 borderwith op conte
|
||||||
|
old_block = """ ol
|
||||||
|
old_block = """ nk-old_block = """ <div>
|
||||||
|
<div class c "w ">
|
||||||
|
i <spa <div class="d-flex align-items-center gap-2"</ </div>
|
||||||
|
|
||||||
|
</d <a <i c
|
||||||
|
</d <a href="/sag/{{ case.id }}/edit" cl>
|
||||||
|
</d </d <a href="/sag/{{ case.id }}/edit" clda </d 1"with op content = f.read()
|
||||||
|
|
||||||
|
ol-0 borderwith op content = f.re
|
||||||
|
ol-0 borderwith op content = f.read()
|
||||||
|
|
||||||
|
old_block = """ ol
|
||||||
|
old_block = """ <!-- M
|
||||||
|
ol-0 borderwith op cmal
|
||||||
|
old_block = """ ol
|
||||||
|
old_block = """ 0.05emold_block = """ seol-0 borderwith op conte</old_block = """ ol
|
||||||
|
old_block = old_block = """ <div class c order" i ar
|
||||||
|
</d <a <i c
|
||||||
|
</d enter p-3"><p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p></div>' | safe }}</div>
|
||||||
|
</d </d <a href="/ </div>
|
||||||
|
</div>
|
||||||
|
</d </d <a href="/sag/{{e(
|
||||||
|
ol-0 borderwith op content = f.re
|
||||||
|
ol-0 borderwith op content = f.read()
|
||||||
|
|
||||||
|
old_block = """ ol
|
||||||
|
old_block = """ <!-- M
|
||||||
|
ol-0priol-0 borderwith op content = f.reah
|
||||||
|
old_bund")
|
||||||
85
fix_hide_logic2.py
Normal file
85
fix_hide_logic2.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
pattern = re.compile(r"document\.querySelectorAll\('\[data-module\]'\)\.forEach\(\(el\) => \{.*?updateRightColumnVisibility\(\);", re.DOTALL)
|
||||||
|
|
||||||
|
new_code = """document.querySelectorAll('[data-module]').forEach((el) => {
|
||||||
|
const moduleName = el.getAttribute('data-module');
|
||||||
|
const hasContent = moduleHasContent(el);
|
||||||
|
const isTimeModule = moduleName === 'time';
|
||||||
|
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
|
||||||
|
const pref = modulePrefs[moduleName];
|
||||||
|
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
|
||||||
|
|
||||||
|
// Helper til at skjule eller vise modulet og dets mb-3 indpakning
|
||||||
|
const setVisibility = (visible) => {
|
||||||
|
let wrapper = null;
|
||||||
|
if (el.parentElement) {
|
||||||
|
const isMB3 = el.parentElement.classList.contains('mb-3');
|
||||||
|
const isRowCol12 = el.parentElement.classList.contains('col-12') && el.parentElement.parentElement && el.parentElement.parentElement.classList.contains('row');
|
||||||
|
if (isMB3) wrapper = el.parentElement;
|
||||||
|
else if (isRowCol12) wrapper = el.parentElement.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
el.classList.remove('d-none');
|
||||||
|
if (wrapper && wrapper.classList.contains('d-none')) {
|
||||||
|
wrapper.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
if (tabButton && tabButton.classList.contains('d-none')) {
|
||||||
|
tabButton.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
el.classList.add('d-none');
|
||||||
|
if (wrapper && !wrapper.classList.contains('d-none')) wrapper.classList.add('d-none');
|
||||||
|
if (tabButton && !tabButton.classList.contains('d-none')) tabButton.classList.add('d-none');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Altid vis time (tid)
|
||||||
|
if (isTimeModule) {
|
||||||
|
setVisibility(true);
|
||||||
|
el.classList.remove('module-empty-compact');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
|
||||||
|
if (pref === false) {
|
||||||
|
setVisibility(false);
|
||||||
|
el.classList.remove('module-empty-compact');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HVIS specifik præference aktiverer den (brugervalg)
|
||||||
|
if (pref === true) {
|
||||||
|
setVisibility(true);
|
||||||
|
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty && !hasContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default logic (ingen brugervalg) - har den content, så vis den
|
||||||
|
if (hasContent) {
|
||||||
|
setVisibility(true);
|
||||||
|
el.classList.remove('module-empty-compact');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default logic - ingen content: se på layout defaults
|
||||||
|
if (standardModuleSet.has(moduleName)) {
|
||||||
|
setVisibility(true);
|
||||||
|
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty);
|
||||||
|
} else {
|
||||||
|
setVisibility(false);
|
||||||
|
el.classList.remove('module-empty-compact');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateRightColumnVisibility();"""
|
||||||
|
|
||||||
|
html, count = pattern.subn(new_code, html)
|
||||||
|
print(f"Replaced {count} instances.")
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
128
fix_relations_center.py
Normal file
128
fix_relations_center.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Move Relationer to center column + add dynamic column distribution JS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# ── 1. Extract the relations block from inner-left-col ───────────────────────
|
||||||
|
start_marker = '<div class="row mb-3">\n <div class="col-12 mb-3">\n <div class="card h-100 d-flex flex-column" data-module="relations"'
|
||||||
|
end_marker_after = ' </div>\n<div class="mb-3"></div>\n<div class="mb-3"></div>\n<div class="mb-3"></div>'
|
||||||
|
|
||||||
|
start_idx = content.find(start_marker)
|
||||||
|
if start_idx == -1:
|
||||||
|
print("ERROR: Could not find relations block start")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
end_marker_idx = content.find(end_marker_after, start_idx)
|
||||||
|
if end_marker_idx == -1:
|
||||||
|
print("ERROR: Could not find relations block end / empty spacers")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
end_idx = end_marker_idx + len(end_marker_after)
|
||||||
|
|
||||||
|
relations_block = content[start_idx:end_idx - len('\n<div class="mb-3"></div>\n<div class="mb-3"></div>\n<div class="mb-3"></div>')]
|
||||||
|
print(f"Extracted relations block: chars {start_idx} - {end_idx}")
|
||||||
|
print(f"Relations block starts with: {relations_block[:80]!r}")
|
||||||
|
print(f"Relations block ends with: {relations_block[-60:]!r}")
|
||||||
|
|
||||||
|
# ── 2. Remove the relations block + spacers from inner-left-col ──────────────
|
||||||
|
content = content[:start_idx] + content[end_idx:]
|
||||||
|
print("Removed relations + spacers from inner-left-col")
|
||||||
|
|
||||||
|
# ── 3. Insert relations into inner-center-col (before ROW 3: Files) ──────────
|
||||||
|
insert_before = ' <!-- ROW 3: Files + Linked Emails -->'
|
||||||
|
insert_idx = content.find(insert_before)
|
||||||
|
if insert_idx == -1:
|
||||||
|
print("ERROR: Could not find ROW 3 insertion point")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
relations_in_center = '\n <!-- Relationer (center) -->\n' + relations_block + '\n\n'
|
||||||
|
content = content[:insert_idx] + relations_in_center + content[insert_idx:]
|
||||||
|
print(f"Inserted relations before ROW 3 at char {insert_idx}")
|
||||||
|
|
||||||
|
# ── 4. Add updateInnerColumnVisibility() after updateRightColumnVisibility() ─
|
||||||
|
old_js = """ function updateRightColumnVisibility() {
|
||||||
|
const rightColumn = document.getElementById('case-right-column');
|
||||||
|
const leftColumn = document.getElementById('case-left-column');
|
||||||
|
if (!rightColumn || !leftColumn) return;
|
||||||
|
|
||||||
|
const visibleRightModules = rightColumn.querySelectorAll('.right-module-card:not(.d-none)');
|
||||||
|
if (visibleRightModules.length === 0) {
|
||||||
|
rightColumn.classList.add('d-none');
|
||||||
|
rightColumn.classList.remove('col-lg-4');
|
||||||
|
leftColumn.classList.remove('col-lg-8');
|
||||||
|
leftColumn.classList.add('col-12');
|
||||||
|
} else {
|
||||||
|
rightColumn.classList.remove('d-none');
|
||||||
|
rightColumn.classList.add('col-lg-4');
|
||||||
|
leftColumn.classList.add('col-lg-8');
|
||||||
|
leftColumn.classList.remove('col-12');
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
new_js = """ function updateRightColumnVisibility() {
|
||||||
|
const rightColumn = document.getElementById('case-right-column');
|
||||||
|
const leftColumn = document.getElementById('case-left-column');
|
||||||
|
if (!rightColumn || !leftColumn) return;
|
||||||
|
|
||||||
|
const visibleRightModules = rightColumn.querySelectorAll('.right-module-card:not(.d-none)');
|
||||||
|
if (visibleRightModules.length === 0) {
|
||||||
|
rightColumn.classList.add('d-none');
|
||||||
|
rightColumn.classList.remove('col-lg-4');
|
||||||
|
leftColumn.classList.remove('col-lg-8');
|
||||||
|
leftColumn.classList.add('col-12');
|
||||||
|
} else {
|
||||||
|
rightColumn.classList.remove('d-none');
|
||||||
|
rightColumn.classList.add('col-lg-4');
|
||||||
|
leftColumn.classList.add('col-lg-8');
|
||||||
|
leftColumn.classList.remove('col-12');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInnerColumnVisibility() {
|
||||||
|
const leftCol = document.getElementById('inner-left-col');
|
||||||
|
const centerCol = document.getElementById('inner-center-col');
|
||||||
|
if (!leftCol || !centerCol) return;
|
||||||
|
|
||||||
|
// Tæl synlige moduler i venstre kolonnen (mb-3 wrappers der ikke er skjulte)
|
||||||
|
const visibleLeftModules = leftCol.querySelectorAll('.mb-3:not(.d-none) [data-module]');
|
||||||
|
const hasVisibleLeft = visibleLeftModules.length > 0;
|
||||||
|
|
||||||
|
if (!hasVisibleLeft) {
|
||||||
|
// Ingen synlige moduler i venstre - udvid center til fuld bredde
|
||||||
|
leftCol.classList.add('d-none');
|
||||||
|
centerCol.classList.remove('col-xl-8');
|
||||||
|
centerCol.classList.add('col-xl-12');
|
||||||
|
} else {
|
||||||
|
// Gendan 4/8 split
|
||||||
|
leftCol.classList.remove('d-none');
|
||||||
|
centerCol.classList.remove('col-xl-12');
|
||||||
|
centerCol.classList.add('col-xl-8');
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
if old_js in content:
|
||||||
|
content = content.replace(old_js, new_js)
|
||||||
|
print("Added updateInnerColumnVisibility() function")
|
||||||
|
else:
|
||||||
|
print("ERROR: Could not find updateRightColumnVisibility() for JS patch")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# ── 5. Call updateInnerColumnVisibility() from applyViewLayout ───────────────
|
||||||
|
old_call = ' updateRightColumnVisibility();\n }'
|
||||||
|
new_call = ' updateRightColumnVisibility();\n updateInnerColumnVisibility();\n }'
|
||||||
|
|
||||||
|
if old_call in content:
|
||||||
|
content = content.replace(old_call, new_call, 1)
|
||||||
|
print("Added updateInnerColumnVisibility() call in applyViewLayout")
|
||||||
|
else:
|
||||||
|
print("ERROR: Could not find updateRightColumnVisibility() call")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# ── Write file ────────────────────────────────────────────────────────────────
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print("\n✅ Done! Lines written:", content.count('\n'))
|
||||||
174
fix_top.py
Normal file
174
fix_top.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# --- 1. Topbar fix ---
|
||||||
|
topbar_pattern = re.compile(r'<!-- Quick Info Bar \(Redesigned\) -->.*?<!-- Tabs Navigation -->', re.DOTALL)
|
||||||
|
|
||||||
|
new_topbar = """<!-- Hero Header (Redesigned) -->
|
||||||
|
<div class="card mb-4 border-0 shadow-sm hero-header" style="border-radius: 8px;">
|
||||||
|
<div class="card-body p-3 px-4">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||||
|
|
||||||
|
<!-- Left: Who & What -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-4">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="badge" style="background: var(--accent); font-size: 1.1rem; padding: 0.5em 0.8em; margin-right: 0.5rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">#{{ case.id }}</span>
|
||||||
|
<span class="badge {{ 'bg-success' if case.status == 'åben' else 'bg-secondary' }}" style="font-size: 0.9rem; padding: 0.5em 0.8em;">{{ case.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<span class="text-muted text-uppercase fw-bold" style="font-size: 0.70rem; letter-spacing: 0.5px;">Kunde</span>
|
||||||
|
{% if customer %}
|
||||||
|
<a href="/customers/{{ customer.id }}" class="fw-bold fs-5 text-dark text-decoration-none hover-primary">
|
||||||
|
{{ customer.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="fs-5 text-muted">Ingen kunde</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<span class="text-muted text-uppercase fw-bold" style="font-size: 0.70rem; letter-spacing: 0.5px;">Kontakt</span>
|
||||||
|
{% if hovedkontakt %}
|
||||||
|
<span class="fw-bold fs-6 text-dark hover-primary" style="cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 4px;" onclick="showKontaktModal()" title="Se kontaktinfo">
|
||||||
|
{{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="fs-6 text-muted fst-italic">Ingen</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column border-end pe-4">
|
||||||
|
<span class="text-muted text-uppercase fw-bold" style="font-size: 0.70rem; letter-spacing: 0.5px;">Afdeling</span>
|
||||||
|
<span class="fs-6 hover-primary" style="cursor: pointer;" onclick="showAfdelingModal()" title="Ændre afdeling">
|
||||||
|
{{ customer.department if customer and customer.department else 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column pe-4">
|
||||||
|
<span class="text-muted text-uppercase fw-bold" style="font-size: 0.70rem; letter-spacing: 0.5px;">Ansvarlig</span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<select id="assignmentUserSelect" class="form-select form-select-sm shadow-none" style="border: none; background-color: #f8f9fa; font-weight: bold; width: auto; font-size: 0.9rem;" onchange="saveAssignment()">
|
||||||
|
<option value="">Ingen (Bruger)</option>
|
||||||
|
{% for user in assignment_users or [] %}
|
||||||
|
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select id="assignmentGroupSelect" class="form-select form-select-sm shadow-none" style="border: none; background-color: #f8f9fa; font-weight: bold; width: auto; font-size: 0.9rem;" onchange="saveAssignment()">
|
||||||
|
<option value="">Ingen (Gruppe)</option>
|
||||||
|
{% for group in assignment_groups or [] %}
|
||||||
|
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Time & Dates -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-4">
|
||||||
|
<div class="d-flex flex-column text-end border-end pe-4">
|
||||||
|
<span class="text-muted text-uppercase fw-bold" style="font-size: 0.70rem; letter-spacing: 0.5px;">Datoer <i class="bi bi-info-circle text-muted" title="Oprettet / Opdateret"></i></span>
|
||||||
|
<div class="small mt-1">
|
||||||
|
<span class="text-muted fw-bold" style="font-size: 0.8rem;">Opr:</span> {{ case.created_at.strftime('%d/%m-%y') if case.created_at else '-' }}
|
||||||
|
<span class="text-muted mx-1">|</span>
|
||||||
|
<span class="text-muted" style="font-size: 0.8rem;">Opd:</span> {{ case.updated_at.strftime('%d/%m-%y') if case.updated_at else '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column text-end">
|
||||||
|
<span class="text-muted text-uppercase fw-bold" style="font-size: 0.70rem; letter-spacing: 0.5px;">Deadline</span>
|
||||||
|
<div class="d-flex align-items-center justify-content-end mt-1">
|
||||||
|
{% if case.deadline %}
|
||||||
|
<span class="badge bg-light text-dark border {{ 'text-danger border-danger' if is_deadline_overdue else '' }}" style="font-size: 0.85rem; font-weight: 500;">
|
||||||
|
<i class="bi bi-clock me-1"></i>{{ case.deadline.strftime('%d/%m-%y') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small fst-italic">Ingen</span>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn-link btn-sm p-0 ms-1 text-muted" onclick="openDeadlineModal()" title="Rediger deadline"><i class="bi bi-pencil-square"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column text-end">
|
||||||
|
<span class="text-muted text-uppercase fw-bold" style="font-size: 0.70rem; letter-spacing: 0.5px;">Udsat</span>
|
||||||
|
<div class="d-flex align-items-center justify-content-end mt-1">
|
||||||
|
{% if case.deferred_until %}
|
||||||
|
<span class="badge bg-light text-dark border" style="font-size: 0.85rem; font-weight: 500;">
|
||||||
|
<i class="bi bi-calendar-event me-1"></i>{{ case.deferred_until.strftime('%d/%m-%y') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small fst-italic">Nej</span>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn-link btn-sm p-0 ms-1 text-muted" onclick="openDeferredModal()" title="Rediger udsættelse"><i class="bi bi-pencil-square"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs Navigation -->"""
|
||||||
|
html, n = topbar_pattern.subn(new_topbar, html)
|
||||||
|
print(f"Topbar replaced: {n}")
|
||||||
|
|
||||||
|
# --- 2. Hovedbeskrivelsen! ---
|
||||||
|
desc_pattern = re.compile(r'<!-- Main Case Info -->.*?<div class="row mb-3">\s*<div class="col-12 mb-3">\s*<div class="card h-100 d-flex flex-column" data-module="pipeline"', re.DOTALL)
|
||||||
|
|
||||||
|
new_desc = """<!-- MAIN HERO CARD: Titel & Beskrivelse -->
|
||||||
|
<div class="col-12 mb-4 mt-2">
|
||||||
|
<div class="card shadow-sm border-0 border-start border-4 border-primary" style="background-color: var(--bg-card); border-radius: 8px;">
|
||||||
|
<div class="card-body p-4 pt-4 pb-5 position-relative">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||||
|
<div class="w-100 pe-3">
|
||||||
|
<h2 class="mb-2 fw-bolder" style="color: var(--accent); font-size: 1.8rem; letter-spacing: -0.5px;">
|
||||||
|
{{ case.titel }}
|
||||||
|
</h2>
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1 mt-2">
|
||||||
|
<span class="badge {{ 'bg-success' if case.status == 'åben' else 'bg-secondary' }} px-2 py-1 shadow-sm">{{ case.status }}</span>
|
||||||
|
<span class="badge bg-light text-dark border px-2 py-1">{{ case.template_key or case.type or 'ticket' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-shrink-0 mt-1">
|
||||||
|
<a href="/sag/{{ case.id }}/edit" class="btn btn-outline-primary shadow-sm" style="border-radius: 6px;">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Rediger sag
|
||||||
|
</a>
|
||||||
|
<button onclick="confirmDeleteCase()" class="btn btn-outline-danger shadow-sm" style="border-radius: 6px;" title="Slet sag">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 pt-3 border-top border-light">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-card-text fs-5 text-muted me-2"></i>
|
||||||
|
<h6 class="text-muted text-uppercase small mb-0 fw-bold" style="letter-spacing: 0.05em;">Opgavebeskrivelse</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="description-section rounded bg-white p-4 shadow-sm border" style="min-height: 120px;">
|
||||||
|
<div class="prose text-dark" style="font-size: 1.05rem; line-height: 1.7; white-space: pre-wrap;">{{ case.beskrivelse or '<div class="text-center p-3"><p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p></div>' | safe }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ROW 1B: Pipeline -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="card h-100 d-flex flex-column" data-module="pipeline" """
|
||||||
|
|
||||||
|
html, n2 = desc_pattern.subn(new_desc, html)
|
||||||
|
print(f"Desc replaced: {n2}")
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
12
main.py
12
main.py
@ -206,6 +206,11 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
"/api/v1/auth/login"
|
"/api/v1/auth/login"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public_prefixes = {
|
||||||
|
"/api/v1/mission/webhook/telefoni/",
|
||||||
|
"/api/v1/mission/webhook/uptime",
|
||||||
|
}
|
||||||
|
|
||||||
# Yealink Action URL callbacks (secured inside telefoni module by token/IP)
|
# Yealink Action URL callbacks (secured inside telefoni module by token/IP)
|
||||||
public_paths.add("/api/v1/telefoni/established")
|
public_paths.add("/api/v1/telefoni/established")
|
||||||
public_paths.add("/api/v1/telefoni/terminated")
|
public_paths.add("/api/v1/telefoni/terminated")
|
||||||
@ -220,7 +225,12 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
public_paths.add("/api/v1/ticket/archived/simply/ticket")
|
public_paths.add("/api/v1/ticket/archived/simply/ticket")
|
||||||
public_paths.add("/api/v1/ticket/archived/simply/record")
|
public_paths.add("/api/v1/ticket/archived/simply/record")
|
||||||
|
|
||||||
if path in public_paths or path.startswith("/static") or path.startswith("/docs"):
|
if (
|
||||||
|
path in public_paths
|
||||||
|
or any(path.startswith(prefix) for prefix in public_prefixes)
|
||||||
|
or path.startswith("/static")
|
||||||
|
or path.startswith("/docs")
|
||||||
|
):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
token = None
|
token = None
|
||||||
|
|||||||
15
migrations/144_sag_beskrivelse_history.sql
Normal file
15
migrations/144_sag_beskrivelse_history.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- Migration 144: Sag beskrivelse (description) change history
|
||||||
|
-- Dato: 2026
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_beskrivelse_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL,
|
||||||
|
beskrivelse_before TEXT,
|
||||||
|
beskrivelse_after TEXT,
|
||||||
|
changed_by_user_id INTEGER,
|
||||||
|
changed_by_name VARCHAR(255),
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sbh_sag_id ON sag_beskrivelse_history(sag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sbh_changed_at ON sag_beskrivelse_history(changed_at);
|
||||||
24
parse_html.py
Normal file
24
parse_html.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
def extract_between(text, start_marker, end_marker):
|
||||||
|
start = text.find(start_marker)
|
||||||
|
if start == -1: return "", text
|
||||||
|
end = text.find(end_marker, start)
|
||||||
|
if end == -1: return "", text
|
||||||
|
match = text[start:end+len(end_marker)]
|
||||||
|
text = text[:start] + text[end+len(end_marker):]
|
||||||
|
return match, text
|
||||||
|
|
||||||
|
def extract_div_by_marker(text, marker):
|
||||||
|
start = text.find(marker)
|
||||||
|
if start == -1: return "", text
|
||||||
|
|
||||||
|
# find the open div tag nearest to the marker looking backwards
|
||||||
|
div_start = text.rfind('<div', 0, start)
|
||||||
|
# wait, sometimes marker is inside the div or before the div.
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("Content loaded, len:", len(content))
|
||||||
66
parse_test.py
Normal file
66
parse_test.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
|
def get_balanced_div(html, start_idx):
|
||||||
|
i = start_idx
|
||||||
|
tag_count = 0
|
||||||
|
while i < len(html):
|
||||||
|
# We need to correctly parse `<div` vs `</div>` handling any attributes
|
||||||
|
# Find next tag start
|
||||||
|
next_open = html.find('<div', i)
|
||||||
|
next_close = html.find('</div>', i)
|
||||||
|
|
||||||
|
if next_open == -1 and next_close == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
if next_open != -1 and (next_open < next_close or next_close == -1):
|
||||||
|
tag_count += 1
|
||||||
|
i = next_open + 4
|
||||||
|
else:
|
||||||
|
tag_count -= 1
|
||||||
|
i = next_close + 6
|
||||||
|
if tag_count == 0:
|
||||||
|
return start_idx, i
|
||||||
|
return start_idx, -1
|
||||||
|
|
||||||
|
html = open('app/modules/sag/templates/detail.html').read()
|
||||||
|
|
||||||
|
def extract_widget(html, data_module_name):
|
||||||
|
pattern = f'<div[^>]*data-module="{data_module_name}"[^>]*>'
|
||||||
|
match = re.search(pattern, html)
|
||||||
|
if not match: return "", html
|
||||||
|
start, end = get_balanced_div(html, match.start())
|
||||||
|
widget = html[start:end]
|
||||||
|
html = html[:start] + html[end:]
|
||||||
|
return widget, html
|
||||||
|
|
||||||
|
# Let's extract assignment card
|
||||||
|
# It does not have data-module, but we know it follows: `<!-- Assignment Card -->`
|
||||||
|
def extract_by_comment(html, comment_str):
|
||||||
|
c_start = html.find(comment_str)
|
||||||
|
if c_start == -1: return "", html
|
||||||
|
div_start = html.find('<div', c_start)
|
||||||
|
if div_start == -1: return "", html
|
||||||
|
start, end = get_balanced_div(html, div_start)
|
||||||
|
widget = html[c_start:end] # include the comment
|
||||||
|
html = html[:c_start] + html[end:]
|
||||||
|
return widget, html
|
||||||
|
|
||||||
|
def extract_block_by_id(html, id_name):
|
||||||
|
pattern = f'<div[^>]*id="{id_name}"[^>]*>'
|
||||||
|
match = re.search(pattern, html)
|
||||||
|
if not match: return "", html
|
||||||
|
start, end = get_balanced_div(html, match.start())
|
||||||
|
widget = html[start:end]
|
||||||
|
html = html[:start] + html[end:]
|
||||||
|
return widget, html
|
||||||
|
|
||||||
|
# Test extractions
|
||||||
|
ass, _ = extract_by_comment(html, '<!-- Assignment Card -->')
|
||||||
|
print(f"Assignment widget len: {len(ass)}")
|
||||||
|
|
||||||
|
cust, _ = extract_widget(html, "customers")
|
||||||
|
print(f"Customer widget len: {len(cust)}")
|
||||||
|
|
||||||
|
rem, _ = extract_widget(html, "reminders")
|
||||||
|
print(f"Reminders widget len: {len(rem)}")
|
||||||
19
refactor_detail.py
Normal file
19
refactor_detail.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Step 1: Remove max-width: 1400px
|
||||||
|
html = html.replace(
|
||||||
|
'<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;">',
|
||||||
|
'<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem;">'
|
||||||
|
)
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
print("Base container updated.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
246
rewrite_detail.py
Normal file
246
rewrite_detail.py
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
|
def get_balanced_div(html, start_idx):
|
||||||
|
i = start_idx
|
||||||
|
tag_count = 0
|
||||||
|
while i < len(html):
|
||||||
|
next_open = html.find('<div', i)
|
||||||
|
next_close = html.find('</div>', i)
|
||||||
|
|
||||||
|
if next_open == -1 and next_close == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
if next_open != -1 and (next_open < next_close or next_close == -1):
|
||||||
|
tag_count += 1
|
||||||
|
i = next_open + 4
|
||||||
|
else:
|
||||||
|
tag_count -= 1
|
||||||
|
i = next_close + 6
|
||||||
|
if tag_count == 0:
|
||||||
|
return start_idx, i
|
||||||
|
return start_idx, -1
|
||||||
|
|
||||||
|
def get_balanced_ul(html, start_idx):
|
||||||
|
i = start_idx
|
||||||
|
tag_count = 0
|
||||||
|
while i < len(html):
|
||||||
|
next_open = html.find('<ul', i)
|
||||||
|
next_close = html.find('</ul>', i)
|
||||||
|
|
||||||
|
if next_open == -1 and next_close == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
if next_open != -1 and (next_open < next_close or next_close == -1):
|
||||||
|
tag_count += 1
|
||||||
|
i = next_open + 3
|
||||||
|
else:
|
||||||
|
tag_count -= 1
|
||||||
|
i = next_close + 5
|
||||||
|
if tag_count == 0:
|
||||||
|
return start_idx, i
|
||||||
|
return start_idx, -1
|
||||||
|
|
||||||
|
def get_balanced_tag(html, start_idx, tag_name):
|
||||||
|
i = start_idx
|
||||||
|
tag_count = 0
|
||||||
|
while i < len(html):
|
||||||
|
next_open = html.find(f'<{tag_name}', i)
|
||||||
|
next_close = html.find(f'</{tag_name}>', i)
|
||||||
|
|
||||||
|
if next_open == -1 and next_close == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
if next_open != -1 and (next_open < next_close or next_close == -1):
|
||||||
|
tag_count += 1
|
||||||
|
i = next_open + len(tag_name) + 1
|
||||||
|
else:
|
||||||
|
tag_count -= 1
|
||||||
|
i = next_close + len(tag_name) + 3
|
||||||
|
if tag_count == 0:
|
||||||
|
return start_idx, i
|
||||||
|
return start_idx, -1
|
||||||
|
|
||||||
|
html = open('app/modules/sag/templates/detail.html').read()
|
||||||
|
|
||||||
|
def extract_widget(html, data_module_name):
|
||||||
|
# exact attribute parsing to not match false positives
|
||||||
|
matches = list(re.finditer(rf'<div[^>]*data-module="{data_module_name}"[^>]*>', html))
|
||||||
|
if not matches: return "", html
|
||||||
|
start, end = get_balanced_div(html, matches[0].start())
|
||||||
|
widget = html[start:end]
|
||||||
|
html = html[:start] + html[end:]
|
||||||
|
return widget, html
|
||||||
|
|
||||||
|
def extract_by_comment(html, comment_str):
|
||||||
|
c_start = html.find(comment_str)
|
||||||
|
if c_start == -1: return "", html
|
||||||
|
div_start = html.find('<div', c_start)
|
||||||
|
if div_start == -1: return "", html
|
||||||
|
start, end = get_balanced_div(html, div_start)
|
||||||
|
widget = html[c_start:end] # include comment
|
||||||
|
html = html[:c_start] + html[end:]
|
||||||
|
return widget, html
|
||||||
|
|
||||||
|
def extract_ul_nav(html):
|
||||||
|
start = html.find('<ul class="nav nav-tabs')
|
||||||
|
if start == -1: return "", html
|
||||||
|
# match comment before it?
|
||||||
|
c_start = html.rfind('<!-- Tabs Navigation -->', 0, start)
|
||||||
|
if c_start != -1 and (start - c_start < 100):
|
||||||
|
actual_start = c_start
|
||||||
|
else:
|
||||||
|
actual_start = start
|
||||||
|
_, end = get_balanced_ul(html, start)
|
||||||
|
widget = html[actual_start:end]
|
||||||
|
html = html[:actual_start] + html[end:]
|
||||||
|
return widget, html
|
||||||
|
|
||||||
|
# Extraction process
|
||||||
|
# 1. Quick Info Bar
|
||||||
|
# The user wants "Status" in right side, but let's keep Quick Info over full width or right?
|
||||||
|
# We will just leave it.
|
||||||
|
|
||||||
|
# 2. Assignment
|
||||||
|
assignment, html = extract_by_comment(html, '<!-- Assignment Card -->')
|
||||||
|
|
||||||
|
# 3. Widgets
|
||||||
|
customers, html = extract_widget(html, "customers")
|
||||||
|
contacts, html = extract_widget(html, "contacts")
|
||||||
|
hardware, html = extract_widget(html, "hardware")
|
||||||
|
locations, html = extract_widget(html, "locations")
|
||||||
|
todo, html = extract_widget(html, "todo-steps")
|
||||||
|
wiki, html = extract_widget(html, "wiki")
|
||||||
|
|
||||||
|
# 4. Reminders - Currently it's a whole tab-pane.
|
||||||
|
# Let's extract the reminders tab-pane inner content or the whole div pane.
|
||||||
|
reminders_tab_pane, html = extract_widget(html, "reminders")
|
||||||
|
# Clean up reminders to make it just a widget (remove tab-pane classes, maybe add card class if not present)
|
||||||
|
reminders_content = reminders_tab_pane.replace('class="tab-pane fade"', 'class="card h-100 right-module-card pt-1"').replace('id="reminders" role="tabpanel" tabindex="0"', '')
|
||||||
|
# Also remove reminders from the nav tab!
|
||||||
|
nav_match = re.search(r'<li class="nav-item"\s*role="presentation">\s*<button[^>]*data-bs-target="#reminders"[^>]*>.*?Påmindelser\s*</button>\s*</li>', html, flags=re.DOTALL)
|
||||||
|
if nav_match:
|
||||||
|
html = html[:nav_match.start()] + html[nav_match.end():]
|
||||||
|
|
||||||
|
# 5. Sagsbeskrivelse - "ROW 1: Main Info"
|
||||||
|
sagsbeskrivelse, html = extract_by_comment(html, '<!-- ROW 1: Main Info -->')
|
||||||
|
|
||||||
|
# 6. Extract the whole Tabs Navigation and Tabs Content to manipulate them
|
||||||
|
nav_tabs, html = extract_ul_nav(html)
|
||||||
|
|
||||||
|
tab_content_start = html.find('<div class="tab-content"')
|
||||||
|
if tab_content_start != -1:
|
||||||
|
tc_start, tc_end = get_balanced_div(html, tab_content_start)
|
||||||
|
tab_content = html[tab_content_start:tc_end]
|
||||||
|
html = html[:tab_content_start] + html[tc_end:]
|
||||||
|
else:
|
||||||
|
tab_content = ""
|
||||||
|
|
||||||
|
# Inside tab_content, the #details tab currently has the old Left/Right row layout.
|
||||||
|
# We need to strip the old grid layout.
|
||||||
|
# Let's find <div class="col-lg-8" id="case-left-column"> inside the #details tab.
|
||||||
|
# We already extracted widgets, so the right column should be mostly empty.
|
||||||
|
# Let's just remove the case-left-column / case-right-column wrapping, and replace it with just the remaining flow.
|
||||||
|
# It's inside:
|
||||||
|
# <div class="tab-pane fade show active" id="details" role="tabpanel" tabindex="0">
|
||||||
|
# <div class="row g-4">
|
||||||
|
# <div class="col-lg-8" id="case-left-column">
|
||||||
|
# ...
|
||||||
|
# </div>
|
||||||
|
# <div class="col-lg-4" id="case-right-column">
|
||||||
|
# <div class="right-modules-grid">
|
||||||
|
# </div>
|
||||||
|
# </div>
|
||||||
|
# </div>
|
||||||
|
# </div>
|
||||||
|
|
||||||
|
# A simple string replacement to remove those wrappers:
|
||||||
|
tab_content = tab_content.replace('<div class="row g-4">\n <div class="col-lg-8" id="case-left-column">', '')
|
||||||
|
# And the closing divs for them:
|
||||||
|
# We have to be careful. Instead of regexing html parsing, we can just replace the left/right column structure.
|
||||||
|
# Since it's easier, I'll just use string manipulation for exactly what it says.
|
||||||
|
left_col_str = '<div class="col-lg-8" id="case-left-column">'
|
||||||
|
idx_l = tab_content.find(left_col_str)
|
||||||
|
if idx_l != -1:
|
||||||
|
tab_content = tab_content[:idx_l] + tab_content[idx_l+len(left_col_str):]
|
||||||
|
idx_row = tab_content.rfind('<div class="row g-4">', 0, idx_l)
|
||||||
|
if idx_row != -1:
|
||||||
|
tab_content = tab_content[:idx_row] + tab_content[idx_row+len('<div class="row g-4">'):]
|
||||||
|
|
||||||
|
right_col_str = '<div class="col-lg-4" id="case-right-column">'
|
||||||
|
idx_r = tab_content.find(right_col_str)
|
||||||
|
if idx_r != -1:
|
||||||
|
# find the end of this div and remove the whole thing
|
||||||
|
r_start, r_end = get_balanced_div(tab_content, idx_r)
|
||||||
|
tab_content = tab_content[:idx_r] + tab_content[r_end:]
|
||||||
|
|
||||||
|
# Now tab_content has two extra </div></div> at the end of the details tab? Yes. We can just leave them if they don't break much?
|
||||||
|
# Wait, unclosed/unopened divs will break the layout.
|
||||||
|
|
||||||
|
# Let's write the new body!
|
||||||
|
# Find the marker where we removed Tabs and Tab content.
|
||||||
|
insertion_point = html.find('</div>', html.find('<!-- Top Bar: Back Link + Global Tags -->')) # wait, no.
|
||||||
|
|
||||||
|
# Best insertion point is after the Quick Info Bar.
|
||||||
|
quick_info, html = extract_by_comment(html, '<!-- Quick Info Bar (Redesigned) -->')
|
||||||
|
|
||||||
|
# Re-assemble the layout
|
||||||
|
new_grid = f"""
|
||||||
|
{quick_info}
|
||||||
|
|
||||||
|
<div class="row g-4 mt-2">
|
||||||
|
<!-- LEFT COLUMN: Kontekst & Stamdata -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<h6 class="mb-3 text-muted text-uppercase fw-bold" style="font-size: 0.8rem; letter-spacing: 0.05em;">Kontekst & Stamdata</h6>
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
{customers}
|
||||||
|
{contacts}
|
||||||
|
{hardware}
|
||||||
|
{locations}
|
||||||
|
{wiki}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MIDDLE COLUMN: Sagsbeskrivelse & Tabs -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="sticky-top" style="top: 1rem; z-index: 1020; margin-bottom: 1.5rem;">
|
||||||
|
{sagsbeskrivelse}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nav_tabs}
|
||||||
|
|
||||||
|
<div class="bg-body pb-4">
|
||||||
|
{tab_content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: Status, Tildeling, Todo, Påmindelser -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<h6 class="mb-3 text-muted text-uppercase fw-bold" style="font-size: 0.8rem; letter-spacing: 0.05em;">Opsummering & Opgaver</h6>
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
{assignment}
|
||||||
|
{todo}
|
||||||
|
{reminders_content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Let's insert where Quick Info Bar was.
|
||||||
|
# To find it, let's just insert after <!-- Top Bar: ... -->
|
||||||
|
# Wait, actually let's reconstruct the content inside <div class="container-fluid"...> ... </div>
|
||||||
|
# The rest of html (like modals etc.) should follow.
|
||||||
|
|
||||||
|
container_start = html.find('<div class="container-fluid"')
|
||||||
|
if container_start != -1:
|
||||||
|
top_bar_start = html.find('<!-- Top Bar: Back Link + Global Tags -->', container_start)
|
||||||
|
# find where top bar ends
|
||||||
|
top_bar_end_div = get_balanced_div(html, top_bar_start - 30) # wait, top bar is just a div...
|
||||||
|
# Let's just find the exact text
|
||||||
|
|
||||||
|
# Alternatively, just string replace replacing an arbitrary known stable block.
|
||||||
|
# The html already had Assignment, tabs, quick info pulled out.
|
||||||
|
# So we can just put `new_grid` exactly where Quick Info Bar was pulled out!
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
pass
|
||||||
188
run_rewrite.py
Normal file
188
run_rewrite.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
|
def get_balanced_div(html, start_idx):
|
||||||
|
i = start_idx
|
||||||
|
tag_count = 0
|
||||||
|
while i < len(html):
|
||||||
|
next_open = html.find('<div', i)
|
||||||
|
next_close = html.find('</div>', i)
|
||||||
|
|
||||||
|
if next_open == -1 and next_close == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
if next_open != -1 and (next_open < next_close or next_close == -1):
|
||||||
|
tag_count += 1
|
||||||
|
i = next_open + 4
|
||||||
|
else:
|
||||||
|
tag_count -= 1
|
||||||
|
i = next_close + 6
|
||||||
|
if tag_count == 0:
|
||||||
|
return start_idx, i
|
||||||
|
return start_idx, -1
|
||||||
|
|
||||||
|
def get_balanced_ul(html, start_idx):
|
||||||
|
i = start_idx
|
||||||
|
tag_count = 0
|
||||||
|
while i < len(html):
|
||||||
|
next_open = html.find('<ul', i)
|
||||||
|
next_close = html.find('</ul>', i)
|
||||||
|
|
||||||
|
if next_open == -1 and next_close == -1:
|
||||||
|
break
|
||||||
|
|
||||||
|
if next_open != -1 and (next_open < next_close or next_close == -1):
|
||||||
|
tag_count += 1
|
||||||
|
i = next_open + 3
|
||||||
|
else:
|
||||||
|
tag_count -= 1
|
||||||
|
i = next_close + 5
|
||||||
|
if tag_count == 0:
|
||||||
|
return start_idx, i
|
||||||
|
return start_idx, -1
|
||||||
|
|
||||||
|
html = open('app/modules/sag/templates/detail.html.bak').read()
|
||||||
|
|
||||||
|
def extract_widget(html, data_module_name):
|
||||||
|
matches = list(re.finditer(rf'<div[^>]*data-module="{data_module_name}"[^>]*>', html))
|
||||||
|
if not matches: return "", html
|
||||||
|
start, end = get_balanced_div(html, matches[0].start())
|
||||||
|
widget = html[start:end]
|
||||||
|
html = html[:start] + html[end:]
|
||||||
|
return widget, html
|
||||||
|
|
||||||
|
def extract_by_comment(html, comment_str):
|
||||||
|
c_start = html.find(comment_str)
|
||||||
|
if c_start == -1: return "", html
|
||||||
|
div_start = html.find('<div', c_start)
|
||||||
|
if div_start == -1: return "", html
|
||||||
|
start, end = get_balanced_div(html, div_start)
|
||||||
|
widget = html[c_start:end]
|
||||||
|
html = html[:c_start] + html[end:]
|
||||||
|
return widget, html
|
||||||
|
|
||||||
|
def extract_ul_nav(html):
|
||||||
|
start = html.find('<ul class="nav nav-tabs')
|
||||||
|
if start == -1: return "", html
|
||||||
|
c_start = html.rfind('<!-- Tabs Navigation -->', 0, start)
|
||||||
|
if c_start != -1 and (start - c_start < 100):
|
||||||
|
actual_start = c_start
|
||||||
|
else:
|
||||||
|
actual_start = start
|
||||||
|
_, end = get_balanced_ul(html, start)
|
||||||
|
widget = html[actual_start:end]
|
||||||
|
html = html[:actual_start] + html[end:]
|
||||||
|
return widget, html
|
||||||
|
|
||||||
|
# Extraction process
|
||||||
|
quick_info, html = extract_by_comment(html, '<!-- Quick Info Bar (Redesigned) -->')
|
||||||
|
assignment, html = extract_by_comment(html, '<!-- Assignment Card -->')
|
||||||
|
|
||||||
|
customers, html = extract_widget(html, "customers")
|
||||||
|
contacts, html = extract_widget(html, "contacts")
|
||||||
|
hardware, html = extract_widget(html, "hardware")
|
||||||
|
locations, html = extract_widget(html, "locations")
|
||||||
|
todo, html = extract_widget(html, "todo-steps")
|
||||||
|
wiki, html = extract_widget(html, "wiki")
|
||||||
|
|
||||||
|
reminders_tab_pane, html = extract_widget(html, "reminders")
|
||||||
|
# update the reminders tab pane wrapping to match right column styling
|
||||||
|
reminders_content = reminders_tab_pane.replace('class="tab-pane fade"', 'class="card right-module-card pt-1"').replace('id="reminders" role="tabpanel" tabindex="0"', '')
|
||||||
|
# Also remove reminders from the nav tab!
|
||||||
|
html = re.sub(r'<li class="nav-item"\s*role="presentation">\s*<button[^>]*data-bs-target="#reminders"[^>]*>.*?Påmindelser\s*</button>\s*</li>', '', html, flags=re.DOTALL)
|
||||||
|
|
||||||
|
sagsbeskrivelse, html = extract_by_comment(html, '<!-- ROW 1: Main Info -->')
|
||||||
|
|
||||||
|
nav_tabs, html = extract_ul_nav(html)
|
||||||
|
|
||||||
|
tab_content_start = html.find('<div class="tab-content" id="caseTabsContent">')
|
||||||
|
if tab_content_start != -1:
|
||||||
|
tc_start, tc_end = get_balanced_div(html, tab_content_start)
|
||||||
|
tab_content = html[tab_content_start:tc_end]
|
||||||
|
html = html[:tab_content_start] + html[tc_end:]
|
||||||
|
else:
|
||||||
|
tab_content = ""
|
||||||
|
|
||||||
|
# Strip old #details column wrapping
|
||||||
|
tab_content = tab_content.replace('<div class="row g-4">\n <div class="col-lg-8" id="case-left-column">', '')
|
||||||
|
left_col_str = '<div class="col-lg-8" id="case-left-column">'
|
||||||
|
idx_l = tab_content.find(left_col_str)
|
||||||
|
if idx_l != -1:
|
||||||
|
tab_content = tab_content[:idx_l] + tab_content[idx_l+len(left_col_str):]
|
||||||
|
idx_row = tab_content.rfind('<div class="row g-4">', 0, idx_l)
|
||||||
|
if idx_row != -1:
|
||||||
|
tab_content = tab_content[:idx_row] + tab_content[idx_row+len('<div class="row g-4">'):]
|
||||||
|
|
||||||
|
right_col_str = '<div class="col-lg-4" id="case-right-column">'
|
||||||
|
idx_r = tab_content.find(right_col_str)
|
||||||
|
if idx_r != -1:
|
||||||
|
r_start, r_end = get_balanced_div(tab_content, idx_r)
|
||||||
|
tab_content = tab_content[:idx_r] + tab_content[r_end:]
|
||||||
|
|
||||||
|
# Since we removed 2 open divs (<div class="row g-4"><div class="col-lg-8...>), let's remove two nearest closing </div> before the end of the #details tab content
|
||||||
|
details_end = tab_content.find('<!-- Tab: Sagsdetaljer')
|
||||||
|
details_div_start = tab_content.find('<div class="tab-pane fade show active" id="details"')
|
||||||
|
details_div_end = get_balanced_div(tab_content, details_div_start)[1]
|
||||||
|
|
||||||
|
dt_content = tab_content[details_div_start:details_div_end]
|
||||||
|
# remove last two </div>
|
||||||
|
last_div = dt_content.rfind('</div>')
|
||||||
|
if last_div != -1:
|
||||||
|
dt_content = dt_content[:last_div] + dt_content[last_div+6:]
|
||||||
|
last_div = dt_content.rfind('</div>')
|
||||||
|
if last_div != -1:
|
||||||
|
dt_content = dt_content[:last_div] + dt_content[last_div+6:]
|
||||||
|
|
||||||
|
tab_content = tab_content[:details_div_start] + dt_content + tab_content[details_div_end:]
|
||||||
|
|
||||||
|
new_grid = f"""
|
||||||
|
{quick_info}
|
||||||
|
|
||||||
|
<div class="row g-4 mt-2">
|
||||||
|
<!-- LEFT COLUMN: Kontekst & Stamdata -->
|
||||||
|
<div class="col-xl-3 col-lg-4 order-2 order-xl-1">
|
||||||
|
<h6 class="mb-3 text-muted text-uppercase fw-bold" style="font-size: 0.8rem; letter-spacing: 0.05em;">Kontekst & Stamdata</h6>
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
{customers}
|
||||||
|
{contacts}
|
||||||
|
{hardware}
|
||||||
|
{locations}
|
||||||
|
{wiki}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MIDDLE COLUMN: Sagsbeskrivelse & Tabs -->
|
||||||
|
<div class="col-xl-6 col-lg-8 order-1 order-xl-2">
|
||||||
|
<div class="sticky-top bg-body pb-2" style="top: 0; z-index: 1020; margin-bottom: 1.5rem;">
|
||||||
|
{sagsbeskrivelse}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nav_tabs}
|
||||||
|
|
||||||
|
<div class="pb-4">
|
||||||
|
{tab_content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: Status, Tildeling, Todo, Påmindelser -->
|
||||||
|
<div class="col-xl-3 col-lg-12 order-3 order-xl-3">
|
||||||
|
<h6 class="mb-3 text-muted text-uppercase fw-bold" style="font-size: 0.8rem; letter-spacing: 0.05em;">Opsummering & Opgaver</h6>
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
{assignment}
|
||||||
|
{todo}
|
||||||
|
{reminders_content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
top_bar_start = html.find('<!-- Top Bar:')
|
||||||
|
top_bar_div_start = html.find('<div', top_bar_start)
|
||||||
|
_, top_bar_div_end = get_balanced_div(html, top_bar_div_start)
|
||||||
|
|
||||||
|
final_html = html[:top_bar_div_end] + "\n" + new_grid + "\n" + html[top_bar_div_end:]
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(final_html)
|
||||||
|
|
||||||
|
print("Done rewriting.")
|
||||||
15
split_cols.py
Normal file
15
split_cols.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Udskifter selve col-lg-8 mv til dynamisk at lave 3 kolonner.
|
||||||
|
# Lad os først få fat i alle widgets som vi kan.
|
||||||
|
|
||||||
|
# Heldigvis har alle widgets en wrapper: <div class="row mb-3"> (fra tidl. left-col)
|
||||||
|
# eller er direkte <div class="card..."> i right-col.
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
8
test_wrapper.py
Normal file
8
test_wrapper.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
for match in re.finditer(r'<div class="mb-3">\s*<div[^>]*data-module="([^"]+)"', html):
|
||||||
|
print(match.group(1))
|
||||||
|
|
||||||
8
test_wrapper2.py
Normal file
8
test_wrapper2.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
for match in re.finditer(r'<div[^>]*data-module="([^"]+)"', html):
|
||||||
|
print(match.group(1))
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user