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:
|
||||
call_id = _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid")
|
||||
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")
|
||||
|
||||
return MissionCallEvent(
|
||||
@ -71,13 +76,28 @@ def _get_webhook_token() -> str:
|
||||
|
||||
def _validate_mission_webhook_token(request: Request, token: Optional[str] = None) -> None:
|
||||
configured = _get_webhook_token()
|
||||
path = request.url.path
|
||||
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")
|
||||
|
||||
candidate = token or request.headers.get("x-mission-token") or request.query_params.get("token")
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@ -9,6 +9,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
row = execute_query_single("SELECT to_regclass(%s) AS table_name", (f"public.{table_name}",))
|
||||
@ -234,21 +242,49 @@ class MissionService:
|
||||
|
||||
@staticmethod
|
||||
def get_state() -> Dict[str, Any]:
|
||||
kpis_default = {
|
||||
"open_cases": 0,
|
||||
"new_cases": 0,
|
||||
"unassigned_cases": 0,
|
||||
"deadlines_today": 0,
|
||||
"overdue_deadlines": 0,
|
||||
}
|
||||
|
||||
return {
|
||||
"kpis": MissionService.get_kpis(),
|
||||
"active_calls": MissionService.get_active_calls(),
|
||||
"employee_deadlines": MissionService.get_employee_deadlines(),
|
||||
"active_alerts": MissionService.get_active_alerts(),
|
||||
"live_feed": MissionService.get_live_feed(20),
|
||||
"kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default),
|
||||
"active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, []),
|
||||
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
|
||||
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
|
||||
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
|
||||
"config": {
|
||||
"display_queues": MissionService.parse_json_setting("mission_display_queues", []),
|
||||
"sound_enabled": str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true",
|
||||
"sound_volume": int(MissionService.get_setting_value("mission_sound_volume", "70") or 70),
|
||||
"sound_events": MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]),
|
||||
"kpi_visible": MissionService.parse_json_setting(
|
||||
"mission_kpi_visible",
|
||||
"display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []),
|
||||
"sound_enabled": MissionService._safe(
|
||||
"config.sound_enabled",
|
||||
lambda: str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true",
|
||||
True,
|
||||
),
|
||||
"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"],
|
||||
),
|
||||
"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
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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):
|
||||
amount: Optional[float] = None
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@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")
|
||||
async def get_tags(sag_id: int):
|
||||
"""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})
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
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()
|
||||
accepted_tokens = {s for s in (env_secret, db_secret) if s}
|
||||
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:
|
||||
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")
|
||||
|
||||
if token and token.strip() in accepted_tokens:
|
||||
logger.debug("✅ Telefoni callback accepted path=%s auth=token ip=%s", path, client_ip)
|
||||
return
|
||||
|
||||
if whitelist:
|
||||
client_ip = _get_client_ip(request)
|
||||
if ip_in_whitelist(client_ip, whitelist):
|
||||
return
|
||||
if token and accepted_tokens:
|
||||
logger.warning(
|
||||
"⚠️ Telefoni callback token mismatch path=%s ip=%s provided=%s accepted_sources=%s",
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@ -358,6 +358,7 @@ async function loadUsers() {
|
||||
opt.textContent = `${u.full_name || u.username || ('#' + u.user_id)}${u.telefoni_extension ? ' (' + u.telefoni_extension + ')' : ''}`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = '';
|
||||
} catch (e) {
|
||||
console.error('Failed loading telefoni users', e);
|
||||
}
|
||||
@ -500,6 +501,16 @@ async function unlinkCase(callId) {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
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();
|
||||
document.getElementById('btnRefresh').addEventListener('click', loadCalls);
|
||||
document.getElementById('filterUser').addEventListener('change', loadCalls);
|
||||
|
||||
@ -11,11 +11,14 @@ import email
|
||||
from email.header import decode_header
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
import base64
|
||||
from uuid import uuid4
|
||||
|
||||
# Try to import aiosmtplib, but don't fail if not available
|
||||
try:
|
||||
@ -1013,3 +1016,101 @@ class EmailService:
|
||||
error_msg = f"❌ Failed to send email: {str(e)}"
|
||||
logger.error(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"
|
||||
}
|
||||
|
||||
public_prefixes = {
|
||||
"/api/v1/mission/webhook/telefoni/",
|
||||
"/api/v1/mission/webhook/uptime",
|
||||
}
|
||||
|
||||
# Yealink Action URL callbacks (secured inside telefoni module by token/IP)
|
||||
public_paths.add("/api/v1/telefoni/established")
|
||||
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/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)
|
||||
|
||||
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