Compare commits

..

28 Commits

Author SHA1 Message Date
Christian
a8eaf6e2a9 feat: enhance tag management and search functionality
- Updated the index.html template to include a new column for "Næste todo" in the sag table.
- Added new JavaScript functions to load and manage case statuses in settings.html, including normalization and rendering of statuses.
- Introduced a new tag search feature in tags_admin.html, allowing users to filter tags by name, type, and module with pagination support.
- Enhanced the backend router.py to include a new endpoint for listing tag usage across modules with server-side filtering and pagination.
- Improved the overall UI and UX of the tag administration page, including responsive design adjustments and better error handling.
2026-03-20 18:43:45 +01:00
Christian
92b888b78f Add migrations for seeding tags and enhancing todo steps
- Created migration 146 to seed case type tags with various categories and keywords.
- Created migration 147 to seed brand and type tags, including a comprehensive list of brands and case types.
- Added migration 148 to introduce a new column `is_next` in `sag_todo_steps` for persistent next-task selection.
- Implemented a new script `run_migrations.py` to facilitate running SQL migrations against the PostgreSQL database with options for dry runs and error handling.
2026-03-20 00:24:58 +01:00
Christian
dcae962481 release: v2.2.65 fix AI prompt tests and case email threading 2026-03-18 13:49:33 +01:00
Christian
243e4375e0 Add QuickCreate heuristic fallback when AI unavailable 2026-03-18 10:29:45 +01:00
Christian
153eb728e2 Fix QuickCreate AI request payload 2026-03-18 10:25:47 +01:00
Christian
73803f894b Fix SAG detail right column nesting 2026-03-18 09:58:31 +01:00
Christian
60d692c085 Fix SAG tab pane top rendering fallback 2026-03-18 09:46:33 +01:00
Christian
beaea0288c release: v2.2.60 enforce active sag tab top view 2026-03-18 09:29:57 +01:00
Christian
e07932f2cc release: v2.2.59 robust sag tab content scrolling 2026-03-18 08:57:29 +01:00
Christian
7a95623094 release: v2.2.58 sag tab top-position UX 2026-03-18 08:36:54 +01:00
Christian
9a3ada380f release: v2.2.57 email+sag tab stability 2026-03-18 07:33:32 +01:00
Christian
eb5e14e2a1 release: v2.2.56 email layout + supplier invoice stabilization 2026-03-18 07:14:28 +01:00
Christian
074ab6a62a feat(email): add deadline and enhanced company search in email-to-sag flow 2026-03-17 22:08:05 +01:00
Christian
15feb18361 release: v2.2.53 notes for email-to-sag phase 1 2026-03-17 21:58:40 +01:00
Christian
695854a272 feat(email): add functionality to send emails with attachments from case tab 2026-03-17 21:51:43 +01:00
Christian
1d7107bff0 Release v2.2.52: harden admin users loading on v2 2026-03-07 03:14:45 +01:00
Christian
7678b58cb4 Harden admin users endpoint fallback on partial schemas 2026-03-07 03:14:29 +01:00
Christian
7e77266d97 Fix admin users list on partially migrated v2 DB 2026-03-07 03:02:45 +01:00
Christian
ba9622250a Release v2.2.51: fix user admin actions + archived sync monitor 2026-03-07 02:52:00 +01:00
Christian
e3094d7ed0 Fix user admin actions on v2 + add archived sync monitor in settings 2026-03-07 02:39:57 +01:00
Christian
959c9b4401 Fix: restore case email compose button in sag email tab 2026-03-06 16:11:05 +01:00
Christian
acdc94cd18 Fix: force --no-cache on release builds; uvicorn workers=2 + keepalive 2026-03-05 09:00:57 +01:00
Christian
ed01f07f86 Release v2.2.49: sag relation tree UX, type dropdown, 12x quick-action modals, email service 2026-03-05 08:41:59 +01:00
Christian
1323320fed Release v2.2.48: sag sale-item fallback and mission webhook ping fixes 2026-03-04 07:40:18 +01:00
Christian
9fc57feda4 Release v2.2.47: webhook GET ping + v2 default API port 2026-03-04 07:22:39 +01:00
Christian
2bd5a3e057 Release v2.2.46: mission schema resilience and repair migration 2026-03-04 07:12:29 +01:00
Christian
4760b8b3c4 Add Mission Control navigation and dashboard option (v2.2.45) 2026-03-04 07:11:06 +01:00
Christian
701cc63375 Hotfix: cleanup legacy v2 containers + auto-expire ringing calls (v2.2.44) 2026-03-04 00:33:12 +01:00
102 changed files with 17794 additions and 1085 deletions

38
.github/skills/gui-starter/SKILL.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: gui-starter
description: "Use when building or updating BMC Hub GUI pages, templates, layout, styling, dark mode toggle, responsive Bootstrap 5 UI, or Nordic Top themed frontend components."
---
# BMC Hub GUI Starter
## Purpose
Use this skill when implementing or refining frontend UI in BMC Hub.
## Project UI Rules
- Follow the Nordic Top style from `docs/design_reference/`.
- Keep a minimalist, clean layout with card-based sections.
- Use Deep Blue as default primary accent: `#0f4c75`.
- Support dark mode with a visible toggle.
- Use CSS variables so accent colors can be changed dynamically.
- Build mobile-first with Bootstrap 5 grid utilities.
## Preferred Workflow
1. Identify existing template/page and preserve established structure when present.
2. Define or update theme tokens as CSS variables (light + dark).
3. Implement responsive layout first, then enhance desktop spacing/typography.
4. Add or maintain dark mode toggle logic (persist preference in localStorage when relevant).
5. Reuse patterns from `docs/design_reference/components.html`, `docs/design_reference/index.html`, `docs/design_reference/customers.html`, and `docs/design_reference/form.html`.
6. Validate visual consistency and avoid introducing one-off styles unless necessary.
## Implementation Guardrails
- Do not hardcode colors repeatedly; map them to CSS variables.
- Do not remove dark mode support from existing pages.
- Do not break existing navigation/topbar behavior.
- Avoid large framework changes unless explicitly requested.
- Keep accessibility basics in place: color contrast, visible focus states, semantic HTML.
## Deliverables
When using this skill, provide:
- Updated frontend files (HTML/CSS/JS) with concise, intentional styling.
- A short summary of what changed and why.
- Notes about any remaining UI tradeoffs or follow-up refinements.

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ htmlcov/
.coverage
.pytest_cache/
.mypy_cache/
RELEASE_NOTES_v2.2.38.md

17
RELEASE_NOTES_v2.2.44.md Normal file
View File

@ -0,0 +1,17 @@
# Release Notes v2.2.44
Dato: 4. marts 2026
## Fixes
- `updateto.sh` rydder nu automatisk legacy containere (`bmc-hub-api-v2`, `bmc-hub-postgres-v2`) før deploy.
- Forebygger port-lock konflikter på især Postgres host-port (`5433`) under compose opstart.
- Mission Control: automatisk timeout på hængende `ringing` opkald, så de ikke bliver stående i Incoming Calls.
## Ændrede filer
- `updateto.sh`
- `app/dashboard/backend/mission_service.py`
- `VERSION`
## Drift
- Deploy: `./updateto.sh v2.2.44`
- Verificér: `curl http://localhost:8001/health`

18
RELEASE_NOTES_v2.2.45.md Normal file
View File

@ -0,0 +1,18 @@
# Release Notes v2.2.45
Dato: 4. marts 2026
## Forbedringer
- Tilføjet direkte menu-link til Mission Control i Support-dropdownen, så siden er hurtigere at finde.
- Tilføjet Mission Control som valgmulighed under Standard Dashboard i Indstillinger.
- Opdateret dashboard-fallback logik, så `/dashboard/mission-control` behandles som et kendt standardvalg.
## Ændrede filer
- `app/shared/frontend/base.html`
- `app/settings/frontend/settings.html`
- `VERSION`
- `RELEASE_NOTES_v2.2.45.md`
## Drift
- Deploy: `./updateto.sh v2.2.45`
- Verificér: `curl http://localhost:8001/dashboard/mission-control`

19
RELEASE_NOTES_v2.2.46.md Normal file
View File

@ -0,0 +1,19 @@
# Release Notes v2.2.46
Dato: 4. marts 2026
## Fixes og driftssikring
- Mission Control backend tåler nu manglende mission-tabeller uden at crashe requests, og logger tydelige advarsler.
- Tilføjet idempotent reparationsmigration for Mission Control schema (`143_mission_control_repair.sql`) til miljøer med delvist oprettede tabeller.
- Opdateret `.gitignore` med release-note undtagelse fra tidligere drift.
## Ændrede filer
- `app/dashboard/backend/mission_service.py`
- `migrations/143_mission_control_repair.sql`
- `.gitignore`
- `VERSION`
- `RELEASE_NOTES_v2.2.46.md`
## Drift
- Deploy: `./updateto.sh v2.2.46`
- Migration (hvis nødvendig): `docker compose exec db psql -U bmc_hub -d bmc_hub -f migrations/143_mission_control_repair.sql`

17
RELEASE_NOTES_v2.2.47.md Normal file
View File

@ -0,0 +1,17 @@
# Release Notes v2.2.47
Dato: 4. marts 2026
## Fixes
- Mission webhook GET for ringing accepterer nu token-only ping uden `call_id` og returnerer `200 OK`.
- `updateto.sh` bruger nu automatisk port `8001` som default i v2-mappen (`/srv/podman/bmc_hub_v2`), med fortsat støtte for `API_PORT` override i `.env`.
## Ændrede filer
- `app/dashboard/backend/mission_router.py`
- `updateto.sh`
- `VERSION`
- `RELEASE_NOTES_v2.2.47.md`
## Drift
- Deploy: `./updateto.sh v2.2.47`
- Verificér webhook ping: `curl -i "http://localhost:8001/api/v1/mission/webhook/telefoni/ringing?token=<TOKEN>"`

21
RELEASE_NOTES_v2.2.48.md Normal file
View File

@ -0,0 +1,21 @@
# Release Notes v2.2.48
Dato: 4. marts 2026
## Fixes
- `sag` aggregering fejler ikke længere hvis tabellen `sag_salgsvarer` mangler; API returnerer fortsat tidsdata og tom salgsliste i stedet for `500`.
- Salgsliste-endpoints i `sag` returnerer nu tom liste med advarsel i log, hvis `sag_salgsvarer` ikke findes.
- Mission webhooks for `answered` og `hangup` accepterer nu også token-only `GET` ping uden `call_id` (samme kompatibilitet som `ringing`).
## Ændrede filer
- `app/modules/sag/backend/router.py`
- `app/dashboard/backend/mission_router.py`
- `VERSION`
- `RELEASE_NOTES_v2.2.48.md`
## Drift
- Deploy: `./updateto.sh v2.2.48`
- Valider webhook ping:
- `curl -i "http://localhost:8001/api/v1/mission/webhook/telefoni/ringing?token=<TOKEN>"`
- `curl -i "http://localhost:8001/api/v1/mission/webhook/telefoni/answered?token=<TOKEN>"`
- `curl -i "http://localhost:8001/api/v1/mission/webhook/telefoni/hangup?token=<TOKEN>"`

40
RELEASE_NOTES_v2.2.49.md Normal file
View 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`

18
RELEASE_NOTES_v2.2.50.md Normal file
View File

@ -0,0 +1,18 @@
# Release Notes v2.2.50
Dato: 6. marts 2026
## Fixes
- Sag: “Ny email”-compose er gendannet i E-mail-fanen på sager.
- Tilføjet synlig compose-sektion med felter for Til/Cc/Bcc/Emne/Besked samt vedhæftning af sagsfiler.
- Knap `Ny email` er nu koblet til afsendelse via `/api/v1/sag/{sag_id}/emails/send`.
- Compose prefiller modtager (primær kontakt hvis muligt) og emne (`Sag #<id>:`).
- Vedhæftningslisten opdateres fra sagsfiler, også når filpanelet ikke er synligt.
## Ændrede filer
- `app/modules/sag/templates/detail.html`
- `VERSION`
- `RELEASE_NOTES_v2.2.50.md`
## Drift
- Deploy: `./updateto.sh v2.2.50`

21
RELEASE_NOTES_v2.2.51.md Normal file
View File

@ -0,0 +1,21 @@
# Release Notes v2.2.51
Dato: 7. marts 2026
## Fixes
- Settings: Bruger-administration i v2 bruger nu stabile admin-endpoints for statusændring og password reset.
- Settings: Forbedrede fejlbeskeder ved brugerhandlinger (status/password), så 4xx/5xx vises tydeligt i UI.
- Ticket Sync: Tilføjet Archived Sync monitor i Settings med knapper for Simply/vTiger import og løbende status-check.
- Ticket Sync: Nyt endpoint `/api/v1/ticket/archived/status` returnerer parity (remote vs lokal) og samlet `overall_synced`.
- Sikkerhed: Sync/import endpoints er låst til admin/superadmin (`users.manage` eller `system.admin`).
## Ændrede filer
- `app/settings/frontend/settings.html`
- `app/ticket/backend/router.py`
- `app/system/backend/sync_router.py`
- `app/auth/backend/admin.py`
- `VERSION`
- `RELEASE_NOTES_v2.2.51.md`
## Drift
- Deploy: `./updateto.sh v2.2.51`

16
RELEASE_NOTES_v2.2.52.md Normal file
View File

@ -0,0 +1,16 @@
# Release Notes v2.2.52
Dato: 7. marts 2026
## Fixes
- Auth Admin: `GET /api/v1/admin/users` er gjort ekstra robust mod delvist migreret database schema.
- Endpointet falder nu tilbage til en simplere query, hvis join/kolonner for grupper eller telefoni mangler.
- Reducerer risiko for UI-fejl: "Kunne ikke indlæse brugere" på v2.
## Ændrede filer
- `app/auth/backend/admin.py`
- `VERSION`
- `RELEASE_NOTES_v2.2.52.md`
## Drift
- Deploy: `./updateto.sh v2.2.52`

42
RELEASE_NOTES_v2.2.53.md Normal file
View File

@ -0,0 +1,42 @@
# Release Notes - v2.2.53
Dato: 17. marts 2026
## Fokus
Email til SAG flow med manuel godkendelse som standard, tydelig UI-handling og bedre sporbarhed.
## Tilføjet
- Manual approval gate i email pipeline (`awaiting_user_action` state), så mails parkeres til brugerhandling før automatisk routing.
- Ny feature-flag i config: `EMAIL_REQUIRE_MANUAL_APPROVAL` (default `true`).
- Nye email API endpoints:
- `GET /api/v1/emails/sag-options`
- `GET /api/v1/emails/search-customers`
- `GET /api/v1/emails/search-sager`
- `POST /api/v1/emails/{email_id}/create-sag`
- `POST /api/v1/emails/{email_id}/link-sag`
- Email stats udvidet med `awaiting_user_action` i summary/processing stats.
- Email frontend opgraderet med forslagspanel og hurtigknapper:
- Bekræft forslag
- Ret type
- Opret ny sag
- Tilknyt eksisterende sag
- Markér spam
- Oprettelse af SAG fra email understøtter nu:
- type
- sekundær label
- ansvarlig bruger
- gruppe
- startdato
- prioritet
- Ny migration: `145_sag_start_date.sql` (`start_date` på `sag_sager`).
## Driftsnoter
- Kør migration `145_sag_start_date.sql` før brug af startdato-feltet i email->sag flow.
- Manuel approval er aktiv som standard; auto-oprettelse er dermed deaktiveret i fase 1.
## Backup
- Fallback zip af nuværende email-funktion er oprettet i `backups/email_feature/`.

28
RELEASE_NOTES_v2.2.54.md Normal file
View File

@ -0,0 +1,28 @@
# Release Notes - v2.2.54
Dato: 17. marts 2026
## Fokus
Forbedringer i email til SAG workflow med deadline-felt og markant bedre firma/kunde-søgning i UI.
## Tilføjet
- Deadline understøttes nu i email->sag oprettelse.
- Backend request-model udvidet med `deadline`.
- `create-sag` gemmer nu deadline på `sag_sager`.
- Frontend forslagspanel har fået dedikeret deadline-felt.
- Kundevalg i email-panelet er opgraderet til en “super firma-søgning”:
- Live dropdown-resultater i stedet for simpel datalist.
- Bedre ranking af resultater (exact/prefix/relevans).
- Hurtig valg med klik, inklusive visning af CVR/domæne/email metadata.
## Opdaterede filer
- `app/emails/backend/router.py`
- `app/emails/frontend/emails.html`
## Bemærkninger
- Ingen breaking API changes.
- Ingen ekstra migration nødvendig for denne release.

22
RELEASE_NOTES_v2.2.56.md Normal file
View File

@ -0,0 +1,22 @@
# Release Notes v2.2.56
Dato: 2026-03-18
## Fokus
Stabilisering af email-visning og hardening af supplier-invoices flows.
## Aendringer
- Rettet layout-overflow i email-detaljevisning, saa lange emner, afsenderadresser, HTML-indhold og filnavne ikke skubber kolonnerne ud af layoutet.
- Tilfoejet robust wrapping/truncering i emails UI for bedre responsiv opfoersel.
- Tilfoejet manglende "Klar til Bogforing" tab i supplier-invoices navigation.
- Rettet endpoint mismatch for AI template-analyse i supplier-invoices frontend.
- Fjernet JS-funktionskonflikter i supplier-invoices ved at adskille single/bulk send flows.
- Tilfoejet backend endpoint til at markere supplier-invoices som betalt.
- Fjernet route-konflikt for send-to-economic ved at flytte legacy placeholder til separat sti.
- Forbedret approve-flow ved at bruge dynamisk brugeropslag i stedet for hardcoded vaerdi.
## Berorte filer
- app/emails/frontend/emails.html
- app/billing/frontend/supplier_invoices.html
- app/billing/backend/supplier_invoices.py
- RELEASE_NOTES_v2.2.56.md

18
RELEASE_NOTES_v2.2.57.md Normal file
View File

@ -0,0 +1,18 @@
# Release Notes v2.2.57
Dato: 2026-03-18
## Fokus
Stabilisering af UI i Email- og SAG-modulerne.
## Aendringer
- Email-visning: yderligere hardening af HTML-tabeller i mail-body, inklusive normalisering af inline styles for at undgaa layout break.
- Email-visning: forbedret overflow-haandtering for bredt indhold (tabeller, celler og media).
- SAG detaljeside: forbedret tab-loading, saa data hentes ved faneskift for Varekob & Salg, Abonnement og Paamindelser.
- SAG detaljeside: robust fallback for reminder user-id via `/api/v1/auth/me`.
- SAG detaljeside: rettet API-kald for reminders og kalender til stabil case-id reference.
## Berorte filer
- app/emails/frontend/emails.html
- app/modules/sag/templates/detail.html
- RELEASE_NOTES_v2.2.57.md

15
RELEASE_NOTES_v2.2.58.md Normal file
View File

@ -0,0 +1,15 @@
# Release Notes v2.2.58
Dato: 2026-03-18
## Fokus
Forbedret UX paa SAG detaljesiden, saa fanernes indhold vises i toppen ved faneskift.
## Aendringer
- SAG tabs: aktiv tab-pane flyttes til toppen af tab-content ved faneskift.
- SAG tabs: automatisk scroll til fanebjaelken efter faneskift.
- SAG tabs: samme top-positionering og scroll ved `?tab=` deep-link aktivering.
## Berorte filer
- app/modules/sag/templates/detail.html
- RELEASE_NOTES_v2.2.58.md

16
RELEASE_NOTES_v2.2.59.md Normal file
View File

@ -0,0 +1,16 @@
# Release Notes v2.2.59
Dato: 2026-03-18
## Fokus
Stabil scroll/navigation i SAG-faner, saa bruger lander ved reelt indhold i den valgte fane.
## Aendringer
- Fjernet DOM-reordering af tab-pane elementer i SAG detaljesiden.
- Ny scroll-logik: ved faneskift scrolles til foerste meningsfulde indholdselement i aktiv fane.
- Scroll-offset tager hoejde for navbar-hoejde.
- Deep-link (`?tab=...`) bruger nu samme robuste scroll-adfaerd.
## Berorte filer
- app/modules/sag/templates/detail.html
- RELEASE_NOTES_v2.2.59.md

17
RELEASE_NOTES_v2.2.60.md Normal file
View File

@ -0,0 +1,17 @@
# Release Notes v2.2.60
Dato: 2026-03-18
## Fokus
Korrekt top-visning af aktiv fane paa SAG detaljesiden.
## Aendringer
- Tvang korrekt tab-pane synlighed i `#caseTabsContent`:
- inaktive faner skjules (`display: none`)
- kun aktiv fane vises (`display: block`)
- Fjernet tidligere scroll/DOM-workaround til fanevisning.
- Resultat: aktiv fane vises i toppen under fanebjaelken uden tom top-sektion.
## Berorte filer
- app/modules/sag/templates/detail.html
- RELEASE_NOTES_v2.2.60.md

15
RELEASE_NOTES_v2.2.61.md Normal file
View File

@ -0,0 +1,15 @@
# Release Notes v2.2.61
Dato: 18. marts 2026
## Fixes
- Rettet SAG-fanevisning i sag-detaljesiden, så kun den aktive fane vises i toppen.
- Tilføjet direkte klik-fallback på faneknapper (`onclick`) for robust aktivering, også hvis Bootstrap tab-events fejler.
- Sat eksplicit start-visibility på tab-panes for at undgå "lang side"-effekten med indhold langt nede.
- Fjernet to ødelagte CSS-blokke i toppen af templaten, som kunne skabe ustabil styling/parsing.
## Berørte filer
- `app/modules/sag/templates/detail.html`
- `RELEASE_NOTES_v2.2.61.md`

14
RELEASE_NOTES_v2.2.62.md Normal file
View File

@ -0,0 +1,14 @@
# Release Notes v2.2.62
Dato: 18. marts 2026
## Fixes
- Rettet grid/nesting i SAG detaljevisning, så højre kolonne ligger i samme row som venstre/center.
- `Hardware`, `Salgspipeline`, `Opkaldshistorik` og `Todo-opgaver` vises nu i højre kolonne som forventet.
- Fjernet en for tidlig afsluttende `</div>` i detaljer-layoutet, som tidligere fik højre modulkolonne til at falde ned under venstre indhold.
## Berørte filer
- `app/modules/sag/templates/detail.html`
- `RELEASE_NOTES_v2.2.62.md`

14
RELEASE_NOTES_v2.2.63.md Normal file
View File

@ -0,0 +1,14 @@
# Release Notes v2.2.63
Dato: 18. marts 2026
## Fixes
- Rettet QuickCreate AI-analyse request i frontend.
- `POST /api/v1/sag/analyze-quick-create` får nu korrekt payload med både `text` og `user_id` i body.
- Forbedret fejllog i frontend ved AI-fejl (inkl. HTTP status), så fejl ikke bliver skjult som generisk "Analysis failed".
## Berørte filer
- `app/shared/frontend/quick_create_modal.html`
- `RELEASE_NOTES_v2.2.63.md`

18
RELEASE_NOTES_v2.2.64.md Normal file
View File

@ -0,0 +1,18 @@
# Release Notes v2.2.64
Dato: 18. marts 2026
## Fixes
- Forbedret QuickCreate robusthed når AI/LLM er utilgængelig.
- Tilføjet lokal heuristisk fallback i `CaseAnalysisService`, så brugeren stadig får:
- foreslået titel
- foreslået prioritet
- simple tags
- kunde-match forsøg
- Fjernet afhængighed af at Ollama altid svarer, så QuickCreate ikke længere ender i tom AI-unavailable flow ved midlertidige AI-fejl.
## Berørte filer
- `app/services/case_analysis_service.py`
- `RELEASE_NOTES_v2.2.64.md`

View File

@ -1 +1 @@
2.2.43
2.2.52

View File

@ -2,6 +2,7 @@
Auth Admin API - Users, Groups, Permissions management
"""
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel, Field
from app.core.auth_dependencies import require_permission
from app.core.auth_service import AuthService
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
@ -13,23 +14,94 @@ logger = logging.getLogger(__name__)
router = APIRouter()
class UserStatusUpdateRequest(BaseModel):
is_active: bool
class UserPasswordResetRequest(BaseModel):
new_password: str = Field(..., min_length=8, max_length=128)
def _users_column_exists(column_name: str) -> bool:
result = execute_query_single(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = %s
LIMIT 1
""",
(column_name,)
)
return bool(result)
def _table_exists(table_name: str) -> bool:
result = execute_query_single(
"""
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = %s
LIMIT 1
""",
(table_name,)
)
return bool(result)
@router.get("/admin/users", dependencies=[Depends(require_permission("users.manage"))])
async def list_users():
users = execute_query(
"""
SELECT u.user_id, u.username, u.email, u.full_name,
u.is_active, u.is_superadmin, u.is_2fa_enabled,
u.telefoni_extension, u.telefoni_aktiv, u.telefoni_phone_ip, u.telefoni_phone_username,
u.created_at, u.last_login_at,
COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups
FROM users u
LEFT JOIN user_groups ug ON u.user_id = ug.user_id
LEFT JOIN groups g ON ug.group_id = g.id
GROUP BY u.user_id
ORDER BY u.user_id
"""
)
return users
is_2fa_expr = "u.is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
telefoni_extension_expr = "u.telefoni_extension" if _users_column_exists("telefoni_extension") else "NULL::varchar AS telefoni_extension"
telefoni_active_expr = "u.telefoni_aktiv" if _users_column_exists("telefoni_aktiv") else "FALSE AS telefoni_aktiv"
telefoni_ip_expr = "u.telefoni_phone_ip" if _users_column_exists("telefoni_phone_ip") else "NULL::varchar AS telefoni_phone_ip"
telefoni_username_expr = "u.telefoni_phone_username" if _users_column_exists("telefoni_phone_username") else "NULL::varchar AS telefoni_phone_username"
last_login_expr = "u.last_login_at" if _users_column_exists("last_login_at") else "NULL::timestamp AS last_login_at"
has_user_groups = _table_exists("user_groups")
has_groups = _table_exists("groups")
if has_user_groups and has_groups:
groups_join = "LEFT JOIN user_groups ug ON u.user_id = ug.user_id LEFT JOIN groups g ON ug.group_id = g.id"
groups_select = "COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups"
else:
groups_join = ""
groups_select = "ARRAY[]::varchar[] AS groups"
try:
users = execute_query(
f"""
SELECT u.user_id, u.username, u.email, u.full_name,
u.is_active, u.is_superadmin, {is_2fa_expr},
{telefoni_extension_expr}, {telefoni_active_expr}, {telefoni_ip_expr}, {telefoni_username_expr},
u.created_at, {last_login_expr},
{groups_select}
FROM users u
{groups_join}
GROUP BY u.user_id
ORDER BY u.user_id
"""
)
return users
except Exception as exc:
logger.warning("⚠️ Admin user query fallback triggered: %s", exc)
try:
users = execute_query(
f"""
SELECT u.user_id, u.username, u.email, u.full_name,
u.is_active, u.is_superadmin, {is_2fa_expr},
{telefoni_extension_expr}, {telefoni_active_expr}, {telefoni_ip_expr}, {telefoni_username_expr},
u.created_at, {last_login_expr},
ARRAY[]::varchar[] AS groups
FROM users u
ORDER BY u.user_id
"""
)
return users
except Exception as fallback_exc:
logger.error("❌ Failed to load admin users (fallback): %s", fallback_exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not load users") from fallback_exc
@router.post("/admin/users", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("users.manage"))])
@ -94,6 +166,48 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
return {"message": "Groups updated"}
@router.patch("/admin/users/{user_id}", dependencies=[Depends(require_permission("users.manage"))])
async def update_user_status(user_id: int, payload: UserStatusUpdateRequest):
user = execute_query_single(
"SELECT user_id, username FROM users WHERE user_id = %s",
(user_id,)
)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
execute_update(
"UPDATE users SET is_active = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(payload.is_active, user_id)
)
logger.info("✅ Updated user status via admin: %s -> active=%s", user.get("username"), payload.is_active)
return {"message": "User status updated", "user_id": user_id, "is_active": payload.is_active}
@router.post("/admin/users/{user_id}/reset-password", dependencies=[Depends(require_permission("users.manage"))])
async def admin_reset_user_password(user_id: int, payload: UserPasswordResetRequest):
user = execute_query_single(
"SELECT user_id, username FROM users WHERE user_id = %s",
(user_id,)
)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
try:
password_hash = AuthService.hash_password(payload.new_password)
except Exception as exc:
logger.error("❌ Password hash failed for user_id=%s: %s", user_id, exc)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Kunne ikke hashe adgangskoden") from exc
execute_update(
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(password_hash, user_id)
)
logger.info("✅ Password reset via admin for user: %s", user.get("username"))
return {"message": "Password reset", "user_id": user_id}
@router.post("/admin/users/{user_id}/2fa/reset")
async def reset_user_2fa(
user_id: int,

View File

@ -74,6 +74,8 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
requires_2fa_setup = (
not user.get("is_shadow_admin", False)
and not settings.AUTH_DISABLE_2FA
and AuthService.is_2fa_supported()
and not user.get("is_2fa_enabled", False)
)
@ -139,10 +141,18 @@ async def setup_2fa(current_user: dict = Depends(get_current_user)):
detail="Shadow admin cannot configure 2FA",
)
result = AuthService.setup_user_2fa(
user_id=current_user["id"],
username=current_user["username"]
)
try:
result = AuthService.setup_user_2fa(
user_id=current_user["id"],
username=current_user["username"]
)
except RuntimeError as exc:
if "2FA columns missing" in str(exc):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA er ikke tilgaengelig i denne database (mangler kolonner).",
)
raise
return result

View File

@ -25,8 +25,26 @@ class BackupService:
"""Service for managing backup operations"""
def __init__(self):
self.backup_dir = Path(settings.BACKUP_STORAGE_PATH)
self.backup_dir.mkdir(parents=True, exist_ok=True)
configured_backup_dir = Path(settings.BACKUP_STORAGE_PATH)
self.backup_dir = configured_backup_dir
try:
self.backup_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
# Local development can run outside Docker where /app is not writable.
# Fall back to the workspace data path so app startup does not fail.
if str(configured_backup_dir).startswith('/app/'):
project_root = Path(__file__).resolve().parents[3]
fallback_dir = project_root / 'data' / 'backups'
logger.warning(
"⚠️ Backup path %s not writable (%s). Using fallback %s",
configured_backup_dir,
exc,
fallback_dir,
)
fallback_dir.mkdir(parents=True, exist_ok=True)
self.backup_dir = fallback_dir
else:
raise
# Subdirectories for different backup types
self.db_dir = self.backup_dir / "database"

View File

@ -1703,6 +1703,10 @@ async def delete_supplier_invoice(invoice_id: int):
class ApproveRequest(BaseModel):
approved_by: str
class MarkPaidRequest(BaseModel):
paid_date: Optional[date] = None
@router.post("/supplier-invoices/{invoice_id}/approve")
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
"""Approve supplier invoice for payment"""
@ -1735,6 +1739,58 @@ async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
raise HTTPException(status_code=500, detail=str(e))
@router.post("/supplier-invoices/{invoice_id}/mark-paid")
async def mark_supplier_invoice_paid(invoice_id: int, request: MarkPaidRequest):
"""Mark supplier invoice as paid."""
try:
invoice = execute_query_single(
"SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s",
(invoice_id,)
)
if not invoice:
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
if invoice['status'] == 'paid':
return {"success": True, "invoice_id": invoice_id, "status": "paid"}
if invoice['status'] not in ('approved', 'sent_to_economic'):
raise HTTPException(
status_code=400,
detail=(
f"Faktura har status '{invoice['status']}' - "
"kun 'approved' eller 'sent_to_economic' kan markeres som betalt"
)
)
execute_update(
"""UPDATE supplier_invoices
SET status = 'paid', updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(invoice_id,)
)
logger.info(
"✅ Marked supplier invoice %s (ID: %s) as paid (date: %s)",
invoice['invoice_number'],
invoice_id,
request.paid_date,
)
return {
"success": True,
"invoice_id": invoice_id,
"status": "paid",
"paid_date": request.paid_date,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to mark invoice {invoice_id} as paid: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/supplier-invoices/{invoice_id}/send-to-economic")
async def send_to_economic(invoice_id: int):
"""
@ -2204,7 +2260,7 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
@router.post("/supplier-invoices/{invoice_id}/send-to-economic")
@router.post("/supplier-invoices/{invoice_id}/send-to-economic-legacy-unimplemented")
async def send_invoice_to_economic(invoice_id: int):
"""Send supplier invoice to e-conomic - requires separate implementation"""
raise HTTPException(status_code=501, detail="e-conomic integration kommer senere")

View File

@ -173,6 +173,11 @@
<i class="bi bi-calendar-check me-2"></i>Til Betaling
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="ready-tab" data-bs-toggle="tab" href="#ready-content" onclick="switchToReadyTab()">
<i class="bi bi-check-circle me-2"></i>Klar til Bogføring
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="lines-tab" data-bs-toggle="tab" href="#lines-content" onclick="switchToLinesTab()">
<i class="bi bi-list-ul me-2"></i>Varelinjer
@ -248,7 +253,7 @@
<strong><span id="selectedKassekladdeCount">0</span> fakturaer valgt</strong>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomic()" title="Send til e-conomic kassekladde">
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomicKassekladde()" title="Send til e-conomic kassekladde">
<i class="bi bi-send me-1"></i>Send til e-conomic
</button>
</div>
@ -1392,7 +1397,7 @@ async function markSingleAsPaid(invoiceId) {
}
// Helper function to send single invoice to e-conomic
async function sendToEconomic(invoiceId) {
async function sendToEconomicById(invoiceId) {
if (!confirm('Send denne faktura til e-conomic?')) return;
try {
@ -1680,7 +1685,7 @@ async function loadReadyForBookingView() {
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${invoice.id})" title="Se/Rediger detaljer">
<i class="bi bi-pencil-square"></i>
</button>
<button class="btn btn-sm btn-primary" onclick="sendToEconomic(${invoice.id})" title="Send til e-conomic">
<button class="btn btn-sm btn-primary" onclick="sendToEconomicById(${invoice.id})" title="Send til e-conomic">
<i class="bi bi-send"></i>
</button>
</td>
@ -4051,12 +4056,11 @@ async function bulkMarkAsPaid() {
for (const invoiceId of invoiceIds) {
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
method: 'PATCH',
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
status: 'paid',
payment_date: new Date().toISOString().split('T')[0]
paid_date: new Date().toISOString().split('T')[0]
})
});
@ -4087,12 +4091,11 @@ async function markInvoiceAsPaid(invoiceId) {
if (!confirm('Marker denne faktura som betalt?')) return;
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
method: 'PATCH',
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
status: 'paid',
payment_date: new Date().toISOString().split('T')[0]
paid_date: new Date().toISOString().split('T')[0]
})
});
@ -4557,7 +4560,7 @@ async function approveInvoice() {
const response = await fetch(`/api/v1/supplier-invoices/${currentInvoiceId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved_by: 'CurrentUser' }) // TODO: Get from auth
body: JSON.stringify({ approved_by: getApprovalUser() })
});
if (response.ok) {
@ -4610,7 +4613,7 @@ async function quickApprove(invoiceId) {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved_by: 'CurrentUser' })
body: JSON.stringify({ approved_by: getApprovalUser() })
});
if (response.ok) {
@ -4955,7 +4958,7 @@ async function createTemplateFromInvoice(invoiceId, vendorId) {
}
// Step 2: AI analyze
const aiResp = await fetch('/api/v1/supplier-invoices/ai-analyze', {
const aiResp = await fetch('/api/v1/supplier-invoices/ai/analyze', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
@ -5117,7 +5120,7 @@ async function sendSingleToEconomic(invoiceId) {
}
// Bulk send selected invoices to e-conomic
async function bulkSendToEconomic() {
async function bulkSendToEconomicKassekladde() {
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
const invoiceIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.invoiceId));
@ -5165,6 +5168,16 @@ async function bulkSendToEconomic() {
}
}
function getApprovalUser() {
const bodyUser = document.body?.dataset?.currentUser;
if (bodyUser && bodyUser.trim()) return bodyUser.trim();
const metaUser = document.querySelector('meta[name="current-user"]')?.content;
if (metaUser && metaUser.trim()) return metaUser.trim();
return 'System';
}
// Select vendor for file (when <100% match)
async function selectVendorForFile(fileId, vendorId) {
if (!vendorId) return;

View File

@ -15,6 +15,21 @@ logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
def _users_column_exists(column_name: str) -> bool:
result = execute_query_single(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = %s
LIMIT 1
""",
(column_name,),
)
return bool(result)
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
@ -70,9 +85,11 @@ async def get_current_user(
}
# Get additional user details from database
is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
user_details = execute_query_single(
"SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,))
f"SELECT email, full_name, {is_2fa_expr} FROM users WHERE user_id = %s",
(user_id,),
)
return {
"id": user_id,

View File

@ -15,6 +15,28 @@ import logging
logger = logging.getLogger(__name__)
_users_column_cache: Dict[str, bool] = {}
def _users_column_exists(column_name: str) -> bool:
if column_name in _users_column_cache:
return _users_column_cache[column_name]
result = execute_query_single(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = %s
LIMIT 1
""",
(column_name,),
)
exists = bool(result)
_users_column_cache[column_name] = exists
return exists
# JWT Settings
SECRET_KEY = settings.JWT_SECRET_KEY
ALGORITHM = "HS256"
@ -25,6 +47,11 @@ pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"],
class AuthService:
"""Service for authentication and authorization"""
@staticmethod
def is_2fa_supported() -> bool:
"""Return True only when required 2FA columns exist in users table."""
return _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret")
@staticmethod
def hash_password(password: str) -> str:
@ -89,6 +116,9 @@ class AuthService:
@staticmethod
def setup_user_2fa(user_id: int, username: str) -> Dict:
"""Create and store a new TOTP secret (not enabled until verified)"""
if not AuthService.is_2fa_supported():
raise RuntimeError("2FA columns missing in users table")
secret = AuthService.generate_2fa_secret()
execute_update(
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
@ -103,6 +133,9 @@ class AuthService:
@staticmethod
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Enable 2FA after verifying TOTP code"""
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
return False
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
@ -123,6 +156,9 @@ class AuthService:
@staticmethod
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Disable 2FA after verifying TOTP code"""
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
return False
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
@ -151,10 +187,11 @@ class AuthService:
if not user:
return False
execute_update(
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
if _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret"):
execute_update(
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod
@ -256,13 +293,18 @@ class AuthService:
request_username = (username or "").strip().lower()
# Get user
is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
totp_expr = "totp_secret" if _users_column_exists("totp_secret") else "NULL::text AS totp_secret"
last_2fa_expr = "last_2fa_at" if _users_column_exists("last_2fa_at") else "NULL::timestamp AS last_2fa_at"
user = execute_query_single(
"""SELECT user_id, username, email, password_hash, full_name,
f"""SELECT user_id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until,
is_2fa_enabled, totp_secret, last_2fa_at
{is_2fa_expr}, {totp_expr}, {last_2fa_expr}
FROM users
WHERE username = %s OR email = %s""",
(username, username))
(username, username),
)
if not user:
# Shadow Admin fallback (only when no regular user matches)
@ -367,10 +409,11 @@ class AuthService:
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
return None, "Invalid 2FA code"
execute_update(
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user['user_id'],)
)
if _users_column_exists("last_2fa_at"):
execute_update(
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user['user_id'],)
)
# Success! Reset failed attempts and update last login
execute_update(
@ -416,6 +459,9 @@ class AuthService:
@staticmethod
def is_user_2fa_enabled(user_id: int) -> bool:
"""Check if user has 2FA enabled"""
if not _users_column_exists("is_2fa_enabled"):
return False
user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,)

View File

@ -105,6 +105,7 @@ class Settings(BaseSettings):
EMAIL_AI_ENABLED: bool = False
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled)
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
EMAIL_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing
EMAIL_MAX_FETCH_PER_RUN: int = 50
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
EMAIL_WORKFLOWS_ENABLED: bool = True

View File

@ -6,6 +6,7 @@ PostgreSQL connection and helpers using psycopg2
import psycopg2
from psycopg2.extras import RealDictCursor
from psycopg2.pool import SimpleConnectionPool
from functools import lru_cache
from typing import Optional
import logging
@ -128,3 +129,34 @@ def execute_query_single(query: str, params: tuple = None):
"""Execute query and return single row (backwards compatibility for fetchone=True)"""
result = execute_query(query, params)
return result[0] if result and len(result) > 0 else None
@lru_cache(maxsize=256)
def table_has_column(table_name: str, column_name: str, schema: str = "public") -> bool:
"""Return whether a column exists in the current database schema."""
conn = get_db_connection()
try:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = %s
AND table_name = %s
AND column_name = %s
LIMIT 1
""",
(schema, table_name, column_name),
)
return cursor.fetchone() is not None
except Exception as e:
logger.warning(
"Schema lookup failed for %s.%s.%s: %s",
schema,
table_name,
column_name,
e,
)
return False
finally:
release_db_connection(conn)

View File

@ -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")
@ -218,6 +238,13 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
@router.get("/mission/webhook/telefoni/ringing")
async def mission_telefoni_ringing_get(request: Request, token: Optional[str] = Query(None)):
_validate_mission_webhook_token(request, token)
# Allow token-only GET calls (no call payload) for phone webhook validation/ping.
if not _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid"):
logger.info("☎️ Mission webhook ringing ping method=%s", request.method)
return {"status": "ok", "mode": "ping"}
event = _event_from_query(request)
return await mission_telefoni_ringing(event, request, token)
@ -261,6 +288,12 @@ async def mission_telefoni_answered(event: MissionCallEvent, request: Request, t
@router.get("/mission/webhook/telefoni/answered")
async def mission_telefoni_answered_get(request: Request, token: Optional[str] = Query(None)):
_validate_mission_webhook_token(request, token)
if not _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid"):
logger.info("✅ Mission webhook answered ping method=%s", request.method)
return {"status": "ok", "mode": "ping"}
event = _event_from_query(request)
return await mission_telefoni_answered(event, request, token)
@ -304,6 +337,12 @@ async def mission_telefoni_hangup(event: MissionCallEvent, request: Request, tok
@router.get("/mission/webhook/telefoni/hangup")
async def mission_telefoni_hangup_get(request: Request, token: Optional[str] = Query(None)):
_validate_mission_webhook_token(request, token)
if not _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid"):
logger.info("📴 Mission webhook hangup ping method=%s", request.method)
return {"status": "ok", "mode": "ping"}
event = _event_from_query(request)
return await mission_telefoni_hangup(event, request, token)

View File

@ -1,10 +1,54 @@
import json
import logging
from typing import Any, Dict, Optional
from app.core.database import execute_query, execute_query_single
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}",))
return bool(row and row.get("table_name"))
@staticmethod
def get_ring_timeout_seconds() -> int:
raw = MissionService.get_setting_value("mission_call_ring_timeout_seconds", "180") or "180"
try:
value = int(raw)
except (TypeError, ValueError):
value = 180
return max(30, min(value, 3600))
@staticmethod
def expire_stale_ringing_calls() -> None:
if not MissionService._table_exists("mission_call_state"):
return
timeout_seconds = MissionService.get_ring_timeout_seconds()
execute_query(
"""
UPDATE mission_call_state
SET state = 'hangup',
ended_at = COALESCE(ended_at, NOW()),
updated_at = NOW()
WHERE state = 'ringing'
AND started_at < (NOW() - make_interval(secs => %s))
""",
(timeout_seconds,),
)
@staticmethod
def get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]:
row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,))
@ -84,6 +128,10 @@ class MissionService:
customer_name: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
if not MissionService._table_exists("mission_events"):
logger.warning("Mission table missing: mission_events (event skipped)")
return {}
rows = execute_query(
"""
INSERT INTO mission_events (event_type, severity, title, source, customer_name, payload)
@ -144,6 +192,11 @@ class MissionService:
@staticmethod
def get_active_calls() -> list[Dict[str, Any]]:
if not MissionService._table_exists("mission_call_state"):
logger.warning("Mission table missing: mission_call_state (active calls unavailable)")
return []
MissionService.expire_stale_ringing_calls()
rows = execute_query(
"""
SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at
@ -156,6 +209,10 @@ class MissionService:
@staticmethod
def get_active_alerts() -> list[Dict[str, Any]]:
if not MissionService._table_exists("mission_uptime_alerts"):
logger.warning("Mission table missing: mission_uptime_alerts (active alerts unavailable)")
return []
rows = execute_query(
"""
SELECT alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, updated_at
@ -168,6 +225,10 @@ class MissionService:
@staticmethod
def get_live_feed(limit: int = 20) -> list[Dict[str, Any]]:
if not MissionService._table_exists("mission_events"):
logger.warning("Mission table missing: mission_events (live feed unavailable)")
return []
rows = execute_query(
"""
SELECT id, event_type, severity, title, source, customer_name, payload, created_at
@ -181,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 "",
"",
),
},
}

View File

@ -125,10 +125,24 @@ async def dashboard(request: Request):
from app.core.database import execute_query
result = execute_query_single(unknown_query)
unknown_count = result['count'] if result else 0
raw_alerts = execute_query(bankruptcy_query) or []
try:
result = execute_query_single(unknown_query)
unknown_count = result['count'] if result else 0
except Exception as exc:
if "tticket_worklog" in str(exc):
logger.warning("⚠️ tticket_worklog table not found; defaulting unknown worklog count to 0")
unknown_count = 0
else:
raise
try:
raw_alerts = execute_query(bankruptcy_query) or []
except Exception as exc:
if "email_messages" in str(exc):
logger.warning("⚠️ email_messages table not found; skipping bankruptcy alerts")
raw_alerts = []
else:
raise
bankruptcy_alerts = []
for alert in raw_alerts:

View File

@ -142,10 +142,131 @@ class ProcessingStats(BaseModel):
fetched: int = 0
saved: int = 0
classified: int = 0
awaiting_user_action: int = 0
rules_matched: int = 0
errors: int = 0
class CreateSagFromEmailRequest(BaseModel):
titel: Optional[str] = None
beskrivelse: Optional[str] = None
customer_id: Optional[int] = None
contact_id: Optional[int] = None
case_type: str = "support"
secondary_label: Optional[str] = None
start_date: Optional[date] = None
deadline: Optional[date] = None
priority: Optional[str] = None
ansvarlig_bruger_id: Optional[int] = None
assigned_group_id: Optional[int] = None
created_by_user_id: int = 1
relation_type: str = "kommentar"
class LinkEmailToSagRequest(BaseModel):
sag_id: int
relation_type: str = "kommentar"
note: Optional[str] = None
forfatter: str = "E-mail Motor"
mark_processed: bool = True
@router.get("/emails/sag-options")
async def get_sag_assignment_options():
"""Return users and groups for SAG assignment controls in email UI."""
try:
users = execute_query(
"""
SELECT user_id AS id,
COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS name
FROM users
WHERE is_active = true
ORDER BY COALESCE(full_name, username, user_id::text)
"""
) or []
groups = execute_query(
"""
SELECT id, name
FROM groups
ORDER BY name
"""
) or []
return {"users": users, "groups": groups}
except Exception as e:
logger.error("❌ Error loading SAG assignment options: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/search-customers")
async def search_customers(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
"""Autocomplete customers for email-to-case flow."""
try:
q_clean = q.strip()
like = f"%{q_clean}%"
prefix = f"{q_clean}%"
rows = execute_query(
"""
SELECT
id,
name,
email,
email_domain,
cvr_number,
CASE
WHEN LOWER(name) = LOWER(%s) THEN 500
WHEN LOWER(name) LIKE LOWER(%s) THEN 300
WHEN COALESCE(email_domain, '') ILIKE %s THEN 200
WHEN COALESCE(cvr_number, '') ILIKE %s THEN 180
WHEN COALESCE(email, '') ILIKE %s THEN 120
ELSE 50
END AS rank_score
FROM customers
WHERE (
name ILIKE %s
OR COALESCE(email, '') ILIKE %s
OR COALESCE(email_domain, '') ILIKE %s
OR COALESCE(cvr_number, '') ILIKE %s
)
ORDER BY rank_score DESC, name ASC
LIMIT %s
""",
(q_clean, prefix, prefix, prefix, prefix, like, like, like, like, limit)
)
return rows or []
except Exception as e:
logger.error("❌ Error searching customers from email router: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/search-sager")
async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
"""Autocomplete SAG cases for linking emails to existing cases."""
try:
like = f"%{q.strip()}%"
rows = execute_query(
"""
SELECT s.id, s.titel, s.status, s.priority, c.name AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND (
s.titel ILIKE %s
OR COALESCE(s.beskrivelse, '') ILIKE %s
OR CAST(s.id AS TEXT) ILIKE %s
)
ORDER BY s.updated_at DESC
LIMIT %s
""",
(like, like, like, limit)
)
return rows or []
except Exception as e:
logger.error("❌ Error searching sager from email router: %s", e)
raise HTTPException(status_code=500, detail=str(e))
# Email Endpoints
@router.get("/emails", response_model=List[EmailListItem])
async def list_emails(
@ -375,6 +496,202 @@ async def link_email(email_id: int, payload: Dict):
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/create-sag")
async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailRequest):
"""Create a new SAG from an email and persist the email-case relation."""
try:
email_row = execute_query(
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,)
)
if not email_row:
raise HTTPException(status_code=404, detail="Email not found")
email_data = email_row[0]
customer_id = payload.customer_id or email_data.get('customer_id')
if not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
template_key = (payload.case_type or 'support').strip().lower()[:50]
priority = (payload.priority or 'normal').strip().lower()
if priority not in {'low', 'normal', 'high', 'urgent'}:
raise HTTPException(status_code=400, detail="priority must be one of: low, normal, high, urgent")
case_result = execute_query(
"""
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, priority, start_date, deadline)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, titel, customer_id, status, template_key, priority, start_date, deadline, created_at
""",
(
titel,
beskrivelse,
template_key,
'åben',
customer_id,
payload.ansvarlig_bruger_id,
payload.assigned_group_id,
payload.created_by_user_id,
priority,
payload.start_date,
payload.deadline,
)
)
if not case_result:
raise HTTPException(status_code=500, detail="Failed to create SAG")
sag = case_result[0]
sag_id = sag['id']
# Link email to SAG (audit trail)
execute_update(
"""
INSERT INTO sag_emails (sag_id, email_id)
VALUES (%s, %s)
ON CONFLICT (sag_id, email_id) DO NOTHING
""",
(sag_id, email_id)
)
execute_update(
"""
UPDATE email_messages
SET linked_case_id = %s,
status = 'processed',
folder = 'Processed',
processed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(sag_id, email_id)
)
if payload.contact_id:
execute_update(
"""
INSERT INTO sag_kontakter (sag_id, contact_id, role)
VALUES (%s, %s, %s)
ON CONFLICT DO NOTHING
""",
(sag_id, payload.contact_id, 'primary')
)
relation_type = (payload.relation_type or 'kommentar').strip().lower()
if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
system_note = (
f"E-mail knyttet som {relation_type}.\n"
f"Emne: {email_data.get('subject') or '(ingen emne)'}\n"
f"Fra: {email_data.get('sender_email') or '(ukendt)'}"
)
if payload.secondary_label:
system_note += f"\nLabel: {payload.secondary_label.strip()[:60]}"
execute_update(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(sag_id, 'E-mail Motor', system_note, True)
)
return {
"success": True,
"email_id": email_id,
"sag": sag,
"message": "SAG oprettet fra e-mail"
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating SAG from email %s: %s", email_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/link-sag")
async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
"""Link an email to an existing SAG and optionally append a system note."""
try:
email_row = execute_query(
"SELECT id, subject, sender_email FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,)
)
if not email_row:
raise HTTPException(status_code=404, detail="Email not found")
sag_row = execute_query(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(payload.sag_id,)
)
if not sag_row:
raise HTTPException(status_code=404, detail="SAG not found")
execute_update(
"""
INSERT INTO sag_emails (sag_id, email_id)
VALUES (%s, %s)
ON CONFLICT (sag_id, email_id) DO NOTHING
""",
(payload.sag_id, email_id)
)
if payload.mark_processed:
execute_update(
"""
UPDATE email_messages
SET linked_case_id = %s,
status = 'processed',
folder = 'Processed',
processed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(payload.sag_id, email_id)
)
else:
execute_update(
"""
UPDATE email_messages
SET linked_case_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(payload.sag_id, email_id)
)
relation_type = (payload.relation_type or 'kommentar').strip().lower()
if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
email_data = email_row[0]
note = payload.note or (
f"E-mail knyttet som {relation_type}. "
f"Emne: {email_data.get('subject') or '(ingen emne)'}"
)
execute_update(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(payload.sag_id, payload.forfatter, note, True)
)
return {
"success": True,
"email_id": email_id,
"sag_id": payload.sag_id,
"message": "E-mail knyttet til SAG"
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error linking email %s to SAG %s: %s", email_id, payload.sag_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/extract-vendor-suggestion")
async def extract_vendor_suggestion(email_id: int):
"""
@ -1206,6 +1523,7 @@ async def get_processing_stats():
SELECT
COUNT(*) as total_emails,
COUNT(*) FILTER (WHERE status = 'new') as new_emails,
COUNT(*) FILTER (WHERE status = 'awaiting_user_action') as awaiting_user_action,
COUNT(*) FILTER (WHERE status = 'processed') as processed_emails,
COUNT(*) FILTER (WHERE status = 'error') as error_emails,
COUNT(*) FILTER (WHERE has_attachments = true) as with_attachments,
@ -1225,6 +1543,7 @@ async def get_processing_stats():
return {
"total_emails": 0,
"new_emails": 0,
"awaiting_user_action": 0,
"processed_emails": 0,
"error_emails": 0,
"with_attachments": 0,
@ -1494,6 +1813,7 @@ async def get_email_stats():
SELECT
COUNT(*) as total_emails,
COUNT(CASE WHEN status = 'new' THEN 1 END) as new_emails,
COUNT(CASE WHEN status = 'awaiting_user_action' THEN 1 END) as awaiting_user_action,
COUNT(CASE WHEN status = 'processed' THEN 1 END) as processed_emails,
COUNT(CASE WHEN classification = 'invoice' THEN 1 END) as invoices,
COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations,

View File

@ -11,6 +11,10 @@
height: calc(100vh - 140px);
overflow: hidden;
}
.email-container > * {
min-width: 0;
}
/* Left Sidebar - Email List (25%) */
.email-list-sidebar {
@ -210,6 +214,7 @@
/* Center Pane - Email Content (50%) */
.email-content-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-card);
@ -220,6 +225,7 @@
.email-content-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0,0,0,0.1);
min-width: 0;
}
.email-content-subject {
@ -227,6 +233,8 @@
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
overflow-wrap: anywhere;
word-break: break-word;
}
.email-content-meta {
@ -234,17 +242,20 @@
align-items: center;
gap: 1rem;
flex-wrap: wrap;
min-width: 0;
}
.sender-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.sender-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.sender-name {
@ -255,6 +266,8 @@
.sender-email {
font-size: 0.8rem;
color: var(--text-secondary);
overflow-wrap: anywhere;
word-break: break-word;
}
.email-timestamp {
@ -267,6 +280,14 @@
border-bottom: 1px solid rgba(0,0,0,0.05);
background: var(--bg-body);
flex-wrap: wrap;
overflow-x: hidden;
}
.attachment-chip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-actions .btn-primary {
@ -284,7 +305,56 @@
flex: 1;
padding: 1.5rem;
overflow-y: auto;
overflow-x: hidden;
line-height: 1.6;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.email-body pre,
.email-html-body,
.email-html-body * {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.email-html-body table {
display: block;
overflow-x: auto;
width: 100% !important;
max-width: 100% !important;
table-layout: auto !important;
border-collapse: collapse;
}
.email-html-body tbody,
.email-html-body thead,
.email-html-body tfoot,
.email-html-body tr,
.email-html-body td,
.email-html-body th {
max-width: 100% !important;
}
.email-html-body td,
.email-html-body th {
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.email-html-body img,
.email-html-body video,
.email-html-body iframe {
max-width: 100% !important;
height: auto !important;
}
.email-html-body [style*="position:fixed"],
.email-html-body [style*="position: fixed"] {
position: static !important;
}
.email-body iframe {
@ -307,6 +377,14 @@
margin-bottom: 0.5rem;
transition: all 0.2s;
color: var(--text-primary);
min-width: 0;
}
.attachment-item .flex-grow-1 {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-item:hover {
@ -342,6 +420,7 @@
flex-direction: column;
gap: 1rem;
overflow-y: auto;
min-width: 0;
}
.analysis-card {
@ -436,6 +515,87 @@
color: var(--accent);
margin-bottom: 0.75rem;
}
.suggestion-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.suggestion-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.suggestion-field label {
font-size: 0.72rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.suggestion-field input,
.suggestion-field select {
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
background: var(--bg-body);
color: var(--text-primary);
padding: 0.45rem 0.6rem;
font-size: 0.85rem;
}
.suggestion-field.full {
grid-column: 1 / -1;
}
.quick-action-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.customer-search-wrap {
position: relative;
}
.customer-search-results {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.12);
border-radius: 10px;
max-height: 260px;
overflow-y: auto;
z-index: 12;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.customer-search-item {
padding: 0.55rem 0.65rem;
border-bottom: 1px solid rgba(0,0,0,0.06);
cursor: pointer;
}
.customer-search-item:last-child {
border-bottom: none;
}
.customer-search-item:hover {
background: var(--accent-light);
}
.customer-search-name {
font-size: 0.85rem;
font-weight: 600;
}
.customer-search-meta {
font-size: 0.74rem;
color: var(--text-secondary);
}
/* Responsive Design */
@media (max-width: 1200px) {
@ -871,6 +1031,9 @@
<button class="filter-pill" data-filter="processed" onclick="setFilter('processed')">
Behandlet <span class="count" id="countProcessed">0</span>
</button>
<button class="filter-pill" data-filter="awaiting_user_action" onclick="setFilter('awaiting_user_action')">
Afventer <span class="count" id="countAwaiting">0</span>
</button>
<button class="filter-pill" data-filter="case_notification" onclick="setFilter('case_notification')">
Sag <span class="count" id="countCase">0</span>
</button>
@ -1312,6 +1475,8 @@ let emails = [];
let selectedEmails = new Set();
let emailSearchTimeout = null;
let autoRefreshInterval = null;
let sagAssignmentOptions = { users: [], groups: [] };
let customerSearchHideTimeout = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
@ -1322,6 +1487,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadStats();
setupEventListeners();
setupKeyboardShortcuts();
preloadSagAssignmentOptions();
startAutoRefresh();
});
@ -1431,13 +1597,12 @@ async function loadEmails(searchQuery = '') {
// Handle special filters
if (currentFilter === 'active') {
// Show only new, error, or flagged (pending review) emails
// If searching, ignore status filter to allow global search
if (!searchQuery) {
url += '&status=new';
}
// Active queue includes both new and awaiting manual handling.
// We fetch list data and filter client-side because API status filter is single-value.
} else if (currentFilter === 'processed') {
url += '&status=processed';
} else if (currentFilter === 'awaiting_user_action') {
url += '&status=awaiting_user_action';
} else if (currentFilter !== 'all') {
// Classification filter
url += `&classification=${currentFilter}`;
@ -1455,6 +1620,11 @@ async function loadEmails(searchQuery = '') {
}
emails = await response.json();
if (currentFilter === 'active' && !searchQuery) {
emails = emails.filter((email) => ['new', 'awaiting_user_action'].includes(email.status || 'new'));
}
console.log('Loaded emails:', emails.length, 'items');
renderEmailList(emails);
@ -1722,12 +1892,12 @@ function renderEmailDetail(email) {
return `
${canPreview ? `
<button onclick="previewAttachment(${email.id}, ${att.id}, '${escapeHtml(att.filename)}', '${att.content_type}')"
class="btn btn-sm btn-outline-primary" title="Se ${att.filename}">
class="btn btn-sm btn-outline-primary attachment-chip" title="Se ${att.filename}">
<i class="bi bi-eye me-1"></i>${att.filename}
</button>
` : `
<a href="/api/v1/emails/${email.id}/attachments/${att.id}"
class="btn btn-sm btn-outline-secondary"
class="btn btn-sm btn-outline-secondary attachment-chip"
download="${att.filename}"
title="Download ${att.filename}">
<i class="bi bi-download me-1"></i>${att.filename}
@ -1747,62 +1917,178 @@ function renderEmailDetail(email) {
// If HTML, inject it as innerHTML after rendering
if (email.body_html) {
const htmlDiv = pane.querySelector('.email-html-body');
if (htmlDiv) htmlDiv.innerHTML = email.body_html;
if (htmlDiv) {
htmlDiv.innerHTML = email.body_html;
normalizeEmailHtmlLayout(htmlDiv);
}
}
}
function normalizeEmailHtmlLayout(container) {
if (!container) return;
const tables = container.querySelectorAll('table');
tables.forEach((table) => {
table.style.maxWidth = '100%';
table.style.width = '100%';
table.style.tableLayout = 'auto';
table.removeAttribute('width');
});
const cells = container.querySelectorAll('td, th');
cells.forEach((cell) => {
cell.style.whiteSpace = 'normal';
cell.style.wordBreak = 'break-word';
cell.style.overflowWrap = 'anywhere';
});
const images = container.querySelectorAll('img, iframe, video');
images.forEach((el) => {
el.style.maxWidth = '100%';
if (el.tagName === 'IMG' || el.tagName === 'VIDEO') {
el.style.height = 'auto';
}
});
}
function renderEmailAnalysis(email) {
const aiAnalysisTab = document.getElementById('aiAnalysisTab');
if (!aiAnalysisTab) {
console.error('aiAnalysisTab element not found in DOM');
return;
}
const classification = email.classification || 'general';
const confidence = email.confidence_score || 0;
// Opdater kun AI Analysis tab indholdet - ikke hele sidebar
aiAnalysisTab.innerHTML = `
${!email.customer_id && !email.supplier_id ? `
<div class="analysis-card border border-warning">
<h6 class="text-warning"><i class="bi bi-person-question-fill me-2"></i>Ukendt Afsender</h6>
<div class="text-muted small mb-2">
<i class="bi bi-envelope me-1"></i>${escapeHtml(email.sender_email || '')}
${email.sender_name ? `<br><i class="bi bi-person me-1"></i>${escapeHtml(email.sender_name)}` : ''}
</div>
<div class="d-flex flex-column gap-2">
<button class="btn btn-sm btn-outline-primary w-100"
onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
<i class="bi bi-person-plus me-2"></i>Opret som Kunde
</button>
<button class="btn btn-sm btn-outline-secondary w-100"
onclick="quickCreateVendor(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
<i class="bi bi-shop me-2"></i>Opret som Leverandør
</button>
</div>
</div>
` : `
<div class="analysis-card">
<h6 class="text-success"><i class="bi bi-person-check-fill me-2"></i>Linket Til</h6>
<ul class="metadata-list">
${email.customer_id ? `<li class="metadata-item"><div class="metadata-label">Kunde</div><div class="metadata-value">${escapeHtml(email.customer_name || '#' + email.customer_id)}</div></li>` : ''}
${email.supplier_id ? `<li class="metadata-item"><div class="metadata-label">Leverandør</div><div class="metadata-value">${escapeHtml(email.supplier_name || '#' + email.supplier_id)}</div></li>` : ''}
</ul>
</div>
`}
const primaryType = suggestPrimaryType(email);
const secondaryLabel = suggestSecondaryLabel(email);
const selectedCustomerName = email.customer_name || '';
${getClassificationActions(email) ? `
const userOptions = (sagAssignmentOptions.users || []).map((user) =>
`<option value="${user.id}">${escapeHtml(user.name)}</option>`
).join('');
const groupOptions = (sagAssignmentOptions.groups || []).map((group) =>
`<option value="${group.id}">${escapeHtml(group.name)}</option>`
).join('');
aiAnalysisTab.innerHTML = `
<div class="analysis-card">
<h6><i class="bi bi-lightning-charge-fill me-2"></i>Hurtig Action</h6>
<div class="d-flex flex-column gap-2">
${getClassificationActions(email)}
<h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
<div class="quick-action-row mb-3">
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion()">
<i class="bi bi-check2-circle me-1"></i>Bekræft Forslag
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()">
<i class="bi bi-arrow-repeat me-1"></i>Ret Type
</button>
<button class="btn btn-sm btn-outline-danger" onclick="markAsSpam()">
<i class="bi bi-slash-circle me-1"></i>Spam
</button>
</div>
<div class="suggestion-grid">
<div class="suggestion-field">
<label for="casePrimaryType">Primær Type</label>
<select id="casePrimaryType">
<option value="support" ${primaryType === 'support' ? 'selected' : ''}>Support</option>
<option value="bogholderi" ${primaryType === 'bogholderi' ? 'selected' : ''}>Bogholderi</option>
<option value="leverandor" ${primaryType === 'leverandor' ? 'selected' : ''}>Leverandør</option>
<option value="helhedsopgave" ${primaryType === 'helhedsopgave' ? 'selected' : ''}>Helhedsopgave</option>
<option value="andet" ${primaryType === 'andet' ? 'selected' : ''}>Andet</option>
</select>
</div>
<div class="suggestion-field">
<label for="caseSecondaryLabel">Sekundær Label</label>
<input id="caseSecondaryLabel" type="text" maxlength="60" value="${escapeHtml(secondaryLabel)}" placeholder="fx Fakturaspørgsmål">
</div>
<div class="suggestion-field full">
<label for="caseCustomerSearch">Kunde</label>
<div class="customer-search-wrap">
<input id="caseCustomerSearch" value="${escapeHtml(selectedCustomerName)}" placeholder="Søg kunde, CVR, domæne..." oninput="searchCustomersForCurrentEmail(this.value)" onfocus="searchCustomersForCurrentEmail(this.value)" onblur="hideCustomerSearchResultsDelayed()">
<div id="caseCustomerResults" class="customer-search-results" style="display:none;"></div>
</div>
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
</div>
<div class="suggestion-field">
<label for="caseAssignee">Ansvarlig Bruger</label>
<select id="caseAssignee">
<option value="">Ingen bruger</option>
${userOptions}
</select>
</div>
<div class="suggestion-field">
<label for="caseGroup">Gruppe</label>
<select id="caseGroup">
<option value="">Ingen gruppe</option>
${groupOptions}
</select>
</div>
<div class="suggestion-field">
<label for="caseStartDate">Startdato</label>
<input id="caseStartDate" type="date" value="${todayAsDateString()}">
</div>
<div class="suggestion-field">
<label for="caseDeadline">Deadline</label>
<input id="caseDeadline" type="date" value="">
</div>
<div class="suggestion-field">
<label for="casePriority">Prioritet</label>
<select id="casePriority">
<option value="low">Lav</option>
<option value="normal" selected>Normal</option>
<option value="high">Høj</option>
<option value="urgent">Akut</option>
</select>
</div>
<div class="suggestion-field full">
<label for="caseTitle">Titel</label>
<input id="caseTitle" type="text" value="${escapeHtml((email.subject || `E-mail fra ${email.sender_email || 'ukendt'}`).slice(0, 250))}">
</div>
</div>
<div class="quick-action-row mt-3">
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
</button>
<button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()">
<i class="bi bi-link-45deg me-1"></i>Tilknyt Eksisterende Sag
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="setPrimaryType('support')">Support</button>
<button class="btn btn-sm btn-outline-secondary" onclick="setPrimaryType('leverandor')">Leverandør</button>
</div>
<div id="linkExistingPanel" class="mt-3" style="display:none;">
<div class="suggestion-field full">
<label for="existingSagSearch">Søg Sag</label>
<input id="existingSagSearch" list="existingSagResults" placeholder="Søg på titel eller ID..." oninput="searchSagerForCurrentEmail(this.value)">
<datalist id="existingSagResults"></datalist>
<input id="existingSagId" type="hidden" value="">
</div>
<div class="suggestion-field full mt-2">
<label for="existingSagRelationType">Tilføj mail som</label>
<select id="existingSagRelationType">
<option value="kommentar">Kommentar</option>
<option value="intern_note">Intern note</option>
<option value="kundeopdatering">Kundeopdatering</option>
</select>
</div>
<button class="btn btn-sm btn-primary mt-2" onclick="linkCurrentEmailToExistingSag()">
<i class="bi bi-link me-1"></i>Tilknyt Sag
</button>
</div>
</div>
` : ''}
<div class="analysis-card">
<h6><i class="bi bi-robot me-2"></i>AI Klassificering</h6>
<div class="confidence-meter">
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${confidence * 100}%"></div>
@ -1812,7 +2098,7 @@ function renderEmailAnalysis(email) {
<span><strong>${Math.round(confidence * 100)}%</strong></span>
</div>
</div>
<select class="classification-select" id="classificationSelect" onchange="updateClassification()">
<option value="invoice" ${classification === 'invoice' ? 'selected' : ''}>📄 Faktura</option>
<option value="order_confirmation" ${classification === 'order_confirmation' ? 'selected' : ''}>📦 Ordrebekræftelse</option>
@ -1824,52 +2110,242 @@ function renderEmailAnalysis(email) {
<option value="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option>
<option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option>
</select>
<button class="btn btn-sm btn-primary w-100" onclick="saveClassification()">
<i class="bi bi-check-lg me-2"></i>Gem Klassificering
</button>
</div>
<div class="analysis-card">
<h6><i class="bi bi-info-circle me-2"></i>Metadata</h6>
<ul class="metadata-list">
<li class="metadata-item">
<div class="metadata-label">Message ID</div>
<div class="metadata-value" style="font-size: 0.7rem; word-break: break-all;">${email.message_id || 'N/A'}</div>
</li>
<li class="metadata-item">
<div class="metadata-label">Modtaget</div>
<div class="metadata-value">${formatDateTime(email.received_date)}</div>
</li>
<li class="metadata-item">
<div class="metadata-label">Status</div>
<div class="metadata-value">${email.status || 'new'}</div>
</li>
${email.extracted_invoice_number ? `
<li class="metadata-item">
<div class="metadata-label">Fakturanummer</div>
<div class="metadata-value">${email.extracted_invoice_number}</div>
</li>
` : ''}
${email.extracted_amount ? `
<li class="metadata-item">
<div class="metadata-label">Beløb</div>
<div class="metadata-value">${email.extracted_amount} ${email.extracted_currency || 'DKK'}</div>
</li>
` : ''}
</ul>
</div>
${email.matched_rules && email.matched_rules.length > 0 ? `
<div class="analysis-card">
<h6><i class="bi bi-lightning-charge me-2"></i>Matchede Regler</h6>
<div class="rules-indicator">
<i class="bi bi-check-circle-fill"></i>
<span>${email.matched_rules.length} regel(er) matchet</span>
</div>
</div>
` : ''}
`;
const statusBadge = document.getElementById('emailActionStatus');
if (statusBadge) {
statusBadge.textContent = email.status || 'new';
}
}
function todayAsDateString() {
return new Date().toISOString().split('T')[0];
}
function suggestPrimaryType(email) {
const classification = (email.classification || '').toLowerCase();
if (classification === 'invoice') return 'bogholderi';
if (classification === 'time_confirmation') return 'support';
if (classification === 'case_notification') return 'support';
if (email.supplier_id) return 'leverandor';
return 'support';
}
function suggestSecondaryLabel(email) {
const classification = (email.classification || '').toLowerCase();
const mapping = {
invoice: 'Fakturasporgsmal',
time_confirmation: 'Tidsbekraftelse',
case_notification: 'Sag opdatering',
order_confirmation: 'Ordre bekraftelse',
freight_note: 'Fragt opdatering',
general: 'Generel henvendelse'
};
return mapping[classification] || 'Kunde henvendelse';
}
async function preloadSagAssignmentOptions() {
try {
const response = await fetch('/api/v1/emails/sag-options');
if (!response.ok) return;
sagAssignmentOptions = await response.json();
} catch (error) {
console.warn('Could not preload sag assignment options:', error);
}
}
function focusTypeEditor() {
document.getElementById('casePrimaryType')?.focus();
}
function setPrimaryType(value) {
const el = document.getElementById('casePrimaryType');
if (el) el.value = value;
}
function toggleLinkExistingPanel() {
const panel = document.getElementById('linkExistingPanel');
if (!panel) return;
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
function getSelectedIdFromDatalist(inputId, datalistId) {
const input = document.getElementById(inputId);
const datalist = document.getElementById(datalistId);
if (!input || !datalist) return null;
const option = [...datalist.options].find((opt) => opt.value === input.value);
return option ? option.dataset.id : null;
}
function hideCustomerSearchResultsDelayed() {
clearTimeout(customerSearchHideTimeout);
customerSearchHideTimeout = setTimeout(() => {
const resultsEl = document.getElementById('caseCustomerResults');
if (resultsEl) resultsEl.style.display = 'none';
}, 180);
}
function renderCustomerSearchResults(customers) {
const resultsEl = document.getElementById('caseCustomerResults');
if (!resultsEl) return;
if (!customers || customers.length === 0) {
resultsEl.innerHTML = '<div class="customer-search-item"><div class="customer-search-meta">Ingen kunder fundet</div></div>';
resultsEl.style.display = 'block';
return;
}
resultsEl.innerHTML = customers.map((customer) => {
const metaParts = [];
if (customer.cvr_number) metaParts.push(`CVR ${escapeHtml(customer.cvr_number)}`);
if (customer.email_domain) metaParts.push(escapeHtml(customer.email_domain));
if (customer.email) metaParts.push(escapeHtml(customer.email));
const meta = metaParts.join(' • ');
return `
<div class="customer-search-item" onmousedown="selectCustomerForCurrentEmail(${customer.id}, '${escapeHtml(customer.name).replace(/'/g, "\\'")}')">
<div class="customer-search-name">${escapeHtml(customer.name)} <span class="text-muted">#${customer.id}</span></div>
<div class="customer-search-meta">${meta || 'Ingen ekstra data'}</div>
</div>
`;
}).join('');
resultsEl.style.display = 'block';
}
function selectCustomerForCurrentEmail(customerId, customerName) {
const input = document.getElementById('caseCustomerSearch');
const hidden = document.getElementById('caseCustomerId');
const resultsEl = document.getElementById('caseCustomerResults');
if (input) input.value = customerName;
if (hidden) hidden.value = String(customerId);
if (resultsEl) resultsEl.style.display = 'none';
}
async function searchCustomersForCurrentEmail(query) {
const hidden = document.getElementById('caseCustomerId');
if (hidden) hidden.value = '';
if (!query || query.length < 2) {
const resultsEl = document.getElementById('caseCustomerResults');
if (resultsEl) resultsEl.style.display = 'none';
return;
}
try {
const response = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`);
if (!response.ok) return;
const customers = await response.json();
renderCustomerSearchResults(customers);
} catch (error) {
console.warn('Customer search failed:', error);
}
}
async function searchSagerForCurrentEmail(query) {
if (!query || query.length < 2) return;
try {
const response = await fetch(`/api/v1/emails/search-sager?q=${encodeURIComponent(query)}`);
if (!response.ok) return;
const sager = await response.json();
const datalist = document.getElementById('existingSagResults');
if (!datalist) return;
datalist.innerHTML = sager.map((sag) => {
const display = `SAG-${sag.id}: ${sag.titel || '(uden titel)'}`;
return `<option value="${escapeHtml(display)}" data-id="${sag.id}"></option>`;
}).join('');
} catch (error) {
console.warn('SAG search failed:', error);
}
}
function confirmSuggestion() {
createCaseFromCurrentForm();
}
function getCaseFormPayload() {
const customerIdHidden = document.getElementById('caseCustomerId')?.value;
const resolvedCustomerId = customerIdHidden || null;
return {
titel: document.getElementById('caseTitle')?.value?.trim() || null,
customer_id: resolvedCustomerId ? Number(resolvedCustomerId) : null,
case_type: document.getElementById('casePrimaryType')?.value || 'support',
secondary_label: document.getElementById('caseSecondaryLabel')?.value?.trim() || null,
start_date: document.getElementById('caseStartDate')?.value || null,
deadline: document.getElementById('caseDeadline')?.value || null,
priority: document.getElementById('casePriority')?.value || 'normal',
ansvarlig_bruger_id: document.getElementById('caseAssignee')?.value ? Number(document.getElementById('caseAssignee').value) : null,
assigned_group_id: document.getElementById('caseGroup')?.value ? Number(document.getElementById('caseGroup').value) : null,
relation_type: 'kommentar'
};
}
async function createCaseFromCurrentForm() {
if (!currentEmailId) return;
const payload = getCaseFormPayload();
if (!payload.customer_id) {
showError('Vælg kunde før sag-oprettelse');
return;
}
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Sag-oprettelse fejlede');
}
const result = await response.json();
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
loadEmails();
await loadEmailDetail(currentEmailId);
} catch (error) {
showError(error.message || 'Kunne ikke oprette sag');
}
}
async function linkCurrentEmailToExistingSag() {
if (!currentEmailId) return;
const selectedSagId = getSelectedIdFromDatalist('existingSagSearch', 'existingSagResults') || document.getElementById('existingSagId')?.value;
if (!selectedSagId) {
showError('Vælg en eksisterende sag først');
return;
}
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/link-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: Number(selectedSagId),
relation_type: document.getElementById('existingSagRelationType')?.value || 'kommentar'
})
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Tilknytning fejlede');
}
showSuccess(`E-mail knyttet til SAG-${selectedSagId}`);
loadEmails();
await loadEmailDetail(currentEmailId);
} catch (error) {
showError(error.message || 'Kunne ikke knytte e-mail til sag');
}
}
function showEmptyState() {
@ -1914,12 +2390,14 @@ async function loadStats() {
const response = await fetch('/api/v1/emails/stats/summary');
const stats = await response.json();
// Calculate active emails (new + error + flagged)
const activeCount = stats.new_emails || 0;
const newCount = stats.new_emails || 0;
const awaitingCount = stats.awaiting_user_action || 0;
const activeCount = newCount + awaitingCount;
document.getElementById('countActive').textContent = activeCount;
document.getElementById('countAll').textContent = stats.total_emails || 0;
document.getElementById('countProcessed').textContent = stats.processed_emails || 0;
document.getElementById('countAwaiting').textContent = awaitingCount;
document.getElementById('countInvoice').textContent = stats.invoices || 0;
document.getElementById('countOrder').textContent = 0;
document.getElementById('countFreight').textContent = 0;
@ -2272,11 +2750,20 @@ async function createTimeEntry(emailId) {
}
async function createCase(emailId) {
showError('Sags-modul er ikke implementeret endnu');
if (currentEmailId !== emailId) {
await selectEmail(emailId);
}
setPrimaryType('support');
focusTypeEditor();
showInfo('Sagsforslag klar. Udfyld felter og klik Opret Ny Sag.');
}
async function linkToCustomer(emailId) {
showError('Kunde-linking er ikke implementeret endnu');
if (currentEmailId !== emailId) {
await selectEmail(emailId);
}
document.getElementById('caseCustomerSearch')?.focus();
showInfo('Søg og vælg kunde i forslagspanelet.');
}
// ─── Quick Create Customer ────────────────────────────────────────────────
@ -2688,6 +3175,10 @@ function formatClassification(classification) {
function getStatusBadge(email) {
const status = email.status || 'new';
const approvalStatus = email.approval_status;
if (status === 'awaiting_user_action') {
return '<span class="badge bg-warning text-dark badge-sm ms-1"><i class="bi bi-person-check me-1"></i>Afventer</span>';
}
if (status === 'processed' || status === 'archived') {
return '<span class="badge bg-success badge-sm ms-1"><i class="bi bi-check-circle me-1"></i>Behandlet</span>';

View File

@ -280,6 +280,7 @@ class TodoStepCreate(TodoStepBase):
class TodoStepUpdate(BaseModel):
"""Schema for updating a todo step"""
is_done: Optional[bool] = None
is_next: Optional[bool] = None
class TodoStep(TodoStepBase):
@ -287,6 +288,7 @@ class TodoStep(TodoStepBase):
id: int
sag_id: int
is_done: bool
is_next: bool = False
created_by_user_id: Optional[int] = None
created_by_name: Optional[str] = None
created_at: datetime

View File

@ -1,6 +1,7 @@
import logging
import os
import shutil
import json
from pathlib import Path
from datetime import datetime
from typing import List, Optional
@ -26,6 +27,11 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _table_exists(table_name: str) -> bool:
row = execute_query_single("SELECT to_regclass(%s) AS table_name", (f"public.{table_name}",))
return bool(row and row.get("table_name"))
def _get_user_id_from_request(request: Request) -> int:
user_id = getattr(request.state, "user_id", None)
if user_id is not None:
@ -45,15 +51,64 @@ def _get_user_id_from_request(request: Request) -> int:
def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = []
seen = set()
def _add_status(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
allowed_statuses.append(candidate)
try:
setting_row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("case_statuses",))
if setting_row and setting_row.get("value"):
parsed = json.loads(setting_row.get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
else:
value = ""
_add_status(value)
except Exception:
pass
# Include historical/current DB statuses so legacy values remain valid
try:
rows = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in rows:
_add_status(row.get("status"))
except Exception:
pass
if not allowed_statuses:
allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
allowed_map = {s.lower(): s for s in allowed_statuses}
if not status_value:
return "åben"
return allowed_map.get("åben", allowed_statuses[0])
normalized = str(status_value).strip().lower()
if normalized == "afventer":
return "åben"
if normalized in {"åben", "lukket"}:
return normalized
return "åben"
if normalized in allowed_map:
return allowed_map[normalized]
# Backward compatibility for legacy mapping
if normalized == "afventer" and "åben" in allowed_map:
return allowed_map["åben"]
# Do not force unknown values back to default; preserve user-entered/custom DB values
raw_value = str(status_value).strip()
if raw_value:
return raw_value
return allowed_map.get("åben", allowed_statuses[0])
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
@ -109,6 +164,30 @@ 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)
thread_email_id: Optional[int] = None
thread_key: Optional[str] = None
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):
"""
@ -213,6 +292,10 @@ async def list_all_sale_items(
):
"""List all sale items across cases (orders overview)."""
try:
if not _table_exists("sag_salgsvarer"):
logger.warning("⚠️ sag_salgsvarer table missing - returning empty sale items list")
return []
query = """
SELECT si.*, s.titel AS sag_titel, s.customer_id, c.name AS customer_name
FROM sag_salgsvarer si
@ -378,7 +461,7 @@ async def list_todo_steps(sag_id: int):
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
WHERE t.sag_id = %s AND t.deleted_at IS NULL
ORDER BY t.is_done ASC, t.due_date NULLS LAST, t.created_at DESC
ORDER BY t.is_done ASC, t.is_next DESC, t.due_date NULLS LAST, t.created_at DESC
"""
return execute_query(query, (sag_id,)) or []
except Exception as e:
@ -433,34 +516,63 @@ async def create_todo_step(sag_id: int, request: Request, data: TodoStepCreate):
@router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep)
async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate):
try:
if data.is_done is None:
raise HTTPException(status_code=400, detail="is_done is required")
if data.is_done is None and data.is_next is None:
raise HTTPException(status_code=400, detail="Provide is_done or is_next")
user_id = _get_user_id_from_request(request)
if data.is_done:
update_query = """
UPDATE sag_todo_steps
SET is_done = TRUE,
completed_by_user_id = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(update_query, (user_id, step_id))
else:
update_query = """
UPDATE sag_todo_steps
SET is_done = FALSE,
completed_by_user_id = NULL,
completed_at = NULL
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(update_query, (step_id,))
if not result:
step_row = execute_query_single(
"SELECT id, sag_id, is_done FROM sag_todo_steps WHERE id = %s AND deleted_at IS NULL",
(step_id,)
)
if not step_row:
raise HTTPException(status_code=404, detail="Todo step not found")
if data.is_done is not None:
user_id = _get_user_id_from_request(request)
if data.is_done:
update_query = """
UPDATE sag_todo_steps
SET is_done = TRUE,
is_next = FALSE,
completed_by_user_id = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
execute_query(update_query, (user_id, step_id))
else:
update_query = """
UPDATE sag_todo_steps
SET is_done = FALSE,
completed_by_user_id = NULL,
completed_at = NULL
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
execute_query(update_query, (step_id,))
if data.is_next is not None:
if step_row.get("is_done") and data.is_next:
raise HTTPException(status_code=400, detail="Completed todo cannot be marked as next")
if data.is_next:
execute_query(
"""
UPDATE sag_todo_steps
SET is_next = FALSE
WHERE sag_id = %s AND deleted_at IS NULL
""",
(step_row["sag_id"],)
)
execute_query(
"""
UPDATE sag_todo_steps
SET is_next = %s
WHERE id = %s AND deleted_at IS NULL
""",
(bool(data.is_next), step_id)
)
return execute_query(
"""
SELECT
@ -519,8 +631,12 @@ async def update_sag(sag_id: int, updates: dict):
updates["status"] = _normalize_case_status(updates.get("status"))
if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates:
updates["start_date"] = _normalize_optional_timestamp(updates.get("start_date"), "start_date")
if "deferred_until" in updates:
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
if "priority" in updates:
updates["priority"] = (str(updates.get("priority") or "").strip().lower() or "normal")
if "ansvarlig_bruger_id" in updates:
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
_validate_user_id(updates["ansvarlig_bruger_id"])
@ -536,6 +652,8 @@ async def update_sag(sag_id: int, updates: dict):
"status",
"ansvarlig_bruger_id",
"assigned_group_id",
"priority",
"start_date",
"deadline",
"deferred_until",
"deferred_until_case_id",
@ -568,6 +686,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)
@ -748,6 +946,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."""
@ -1205,6 +1412,10 @@ async def get_varekob_salg(sag_id: int, include_subcases: bool = True):
if not check:
raise HTTPException(status_code=404, detail="Case not found")
has_sale_items_table = _table_exists("sag_salgsvarer")
if not has_sale_items_table:
logger.warning("⚠️ sag_salgsvarer table missing - sale item aggregation skipped for sag_id=%s", sag_id)
if include_subcases:
case_tree_query = """
WITH RECURSIVE normalized_relations AS (
@ -1268,36 +1479,39 @@ async def get_varekob_salg(sag_id: int, include_subcases: bool = True):
"""
time_entries = execute_query(time_query, (sag_id,))
sale_items_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
ELSE kilde_sag_id
END AS parent_id,
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
ELSE målsag_id
END AS child_id
FROM sag_relationer
WHERE deleted_at IS NULL
),
case_tree AS (
SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL
UNION
SELECT nr.child_id
FROM normalized_relations nr
JOIN case_tree ct ON nr.parent_id = ct.id
)
SELECT si.*, s.titel AS source_sag_titel
FROM sag_salgsvarer si
JOIN case_tree ct ON si.sag_id = ct.id
LEFT JOIN sag_sager s ON s.id = si.sag_id
ORDER BY si.line_date DESC NULLS LAST, si.id DESC
"""
sale_items = execute_query(sale_items_query, (sag_id,))
if has_sale_items_table:
sale_items_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
ELSE kilde_sag_id
END AS parent_id,
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
ELSE målsag_id
END AS child_id
FROM sag_relationer
WHERE deleted_at IS NULL
),
case_tree AS (
SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL
UNION
SELECT nr.child_id
FROM normalized_relations nr
JOIN case_tree ct ON nr.parent_id = ct.id
)
SELECT si.*, s.titel AS source_sag_titel
FROM sag_salgsvarer si
JOIN case_tree ct ON si.sag_id = ct.id
LEFT JOIN sag_sager s ON s.id = si.sag_id
ORDER BY si.line_date DESC NULLS LAST, si.id DESC
"""
sale_items = execute_query(sale_items_query, (sag_id,))
else:
sale_items = []
else:
case_tree = execute_query(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
@ -1312,14 +1526,17 @@ async def get_varekob_salg(sag_id: int, include_subcases: bool = True):
"""
time_entries = execute_query(time_query, (sag_id,))
sale_items_query = """
SELECT si.*, s.titel AS source_sag_titel
FROM sag_salgsvarer si
LEFT JOIN sag_sager s ON s.id = si.sag_id
WHERE si.sag_id = %s
ORDER BY si.line_date DESC NULLS LAST, si.id DESC
"""
sale_items = execute_query(sale_items_query, (sag_id,))
if has_sale_items_table:
sale_items_query = """
SELECT si.*, s.titel AS source_sag_titel
FROM sag_salgsvarer si
LEFT JOIN sag_sager s ON s.id = si.sag_id
WHERE si.sag_id = %s
ORDER BY si.line_date DESC NULLS LAST, si.id DESC
"""
sale_items = execute_query(sale_items_query, (sag_id,))
else:
sale_items = []
total_entries = len(time_entries or [])
total_hours = 0
@ -1497,6 +1714,10 @@ async def list_sale_items(sag_id: int):
if not check:
raise HTTPException(status_code=404, detail="Case not found")
if not _table_exists("sag_salgsvarer"):
logger.warning("⚠️ sag_salgsvarer table missing - returning empty sale items list for sag_id=%s", sag_id)
return []
query = """
SELECT si.*, s.titel AS source_sag_titel
FROM sag_salgsvarer si
@ -2015,6 +2236,230 @@ 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),
})
in_reply_to_header = None
references_header = None
if payload.thread_email_id:
thread_row = None
try:
thread_row = execute_query_single(
"""
SELECT id, message_id, in_reply_to, email_references
FROM email_messages
WHERE id = %s
""",
(payload.thread_email_id,),
)
except Exception:
# Backward compatibility for DBs without in_reply_to/email_references columns.
thread_row = execute_query_single(
"""
SELECT id, message_id
FROM email_messages
WHERE id = %s
""",
(payload.thread_email_id,),
)
if thread_row:
base_message_id = str(thread_row.get("message_id") or "").strip()
if base_message_id and not base_message_id.startswith("<"):
base_message_id = f"<{base_message_id}>"
if base_message_id:
in_reply_to_header = base_message_id
existing_refs = str(thread_row.get("email_references") or "").strip()
if existing_refs:
references_header = f"{existing_refs} {base_message_id}".strip()
else:
references_header = base_message_id
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,
in_reply_to=in_reply_to_header,
references=references_header,
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_result = None
try:
insert_email_query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text, body_html,
in_reply_to, email_references,
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, %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,
in_reply_to_header,
references_header,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
"sent",
"sag_outbound",
sag_id,
),
)
except Exception:
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, thread_email_id=%s, thread_key=%s, recipients=%s)",
sag_id,
email_id,
payload.thread_email_id,
payload.thread_key,
", ".join(to_addresses),
)
return {
"status": "sent",
"email_id": email_id,
"message": send_message,
}
# ============================================================================
# SOLUTIONS
# ============================================================================

View File

@ -1,4 +1,5 @@
import logging
import json
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
@ -56,6 +57,50 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
return None
def _fetch_case_status_options() -> list[str]:
default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
values = []
seen = set()
def _add(value: Optional[str]) -> None:
candidate = str(value or "").strip()
if not candidate:
return
key = candidate.lower()
if key in seen:
return
seen.add(key)
values.append(candidate)
setting_row = execute_query(
"SELECT value FROM settings WHERE key = %s",
("case_statuses",)
)
if setting_row and setting_row[0].get("value"):
try:
parsed = json.loads(setting_row[0].get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
value = ""
if isinstance(item, str):
value = item.strip()
elif isinstance(item, dict):
value = str(item.get("value") or "").strip()
_add(value)
except Exception:
pass
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
for row in statuses:
_add(row.get("status"))
for default in default_statuses:
_add(default)
return values
@router.get("/sag", response_class=HTMLResponse)
async def sager_liste(
request: Request,
@ -77,7 +122,9 @@ async def sager_liste(
c.name as customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
g.name AS assigned_group_name,
nt.title AS next_todo_title,
nt.due_date AS next_todo_due_date
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
@ -90,6 +137,22 @@ async def sager_liste(
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id
LEFT JOIN LATERAL (
SELECT t.title, t.due_date
FROM sag_todo_steps t
WHERE t.sag_id = s.id
AND t.deleted_at IS NULL
AND t.is_done = FALSE
ORDER BY
CASE
WHEN t.is_next THEN 0
WHEN t.due_date IS NOT NULL THEN 1
ELSE 2
END,
t.due_date ASC NULLS LAST,
t.created_at ASC
LIMIT 1
) nt ON true
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
WHERE s.deleted_at IS NULL
"""
@ -162,7 +225,11 @@ async def sager_liste(
sager = [s for s in sager if s['id'] in tagged_ids]
# Fetch all distinct statuses and tags for filters
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
status_options = _fetch_case_status_options()
current_status = str(status or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ())
toggle_include_deferred_url = str(
@ -174,7 +241,7 @@ async def sager_liste(
"sager": sager,
"relations_map": relations_map,
"child_ids": list(child_ids),
"statuses": [s['status'] for s in statuses],
"statuses": status_options,
"all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status,
"current_tag": tag,
@ -451,7 +518,10 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = []
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
status_options = _fetch_case_status_options()
current_status = str(sag.get("status") or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail.html", {
@ -475,7 +545,7 @@ async def sag_detaljer(request: Request, sag_id: int):
"nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options,
"pipeline_stages": pipeline_stages,
"status_options": [s["status"] for s in statuses],
"status_options": status_options,
"is_deadline_overdue": is_deadline_overdue,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),

View File

@ -33,7 +33,7 @@ class RelationService:
# 2. Fetch details for these cases
placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})"
tree_cases_query = f"SELECT id, titel, status, type, template_key FROM sag_sager WHERE id IN ({placeholders})"
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
# 3. Fetch all edges between these cases

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -17,12 +17,14 @@
.table-wrapper {
background: var(--bg-card);
border-radius: 12px;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.sag-table {
width: 100%;
min-width: 1760px;
margin: 0;
}
@ -32,12 +34,13 @@
}
.sag-table thead th {
padding: 0.8rem 1rem;
padding: 0.6rem 0.75rem;
font-weight: 600;
font-size: 0.85rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.3px;
border: none;
white-space: nowrap;
}
.sag-table tbody tr {
@ -51,9 +54,30 @@
}
.sag-table tbody td {
padding: 0.6rem 1rem;
vertical-align: middle;
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
vertical-align: top;
font-size: 0.86rem;
white-space: nowrap;
}
.sag-table td.col-company,
.sag-table td.col-contact,
.sag-table td.col-owner,
.sag-table td.col-group,
.sag-table td.col-desc {
white-space: normal;
}
.sag-table td.col-company,
.sag-table td.col-contact,
.sag-table td.col-owner,
.sag-table td.col-group {
max-width: 180px;
}
.sag-table td.col-desc {
min-width: 260px;
max-width: 360px;
}
.sag-id {
@ -246,7 +270,7 @@
{% endblock %}
{% block content %}
<div class="container-fluid" style="max-width: 1400px; padding-top: 2rem;">
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="margin: 0; color: var(--accent);">
@ -330,17 +354,19 @@
<table class="sag-table">
<thead>
<tr>
<th style="width: 90px;">ID</th>
<th>Titel & Beskrivelse</th>
<th style="width: 90px;">SagsID</th>
<th style="width: 180px;">Virksom.</th>
<th style="width: 150px;">Kontakt</th>
<th style="width: 300px;">Beskr.</th>
<th style="width: 120px;">Type</th>
<th style="width: 180px;">Kunde</th>
<th style="width: 150px;">Hovedkontakt</th>
<th style="width: 160px;">Ansvarlig</th>
<th style="width: 160px;">Gruppe</th>
<th style="width: 100px;">Status</th>
<th style="width: 120px;">Udsat start</th>
<th style="width: 120px;">Oprettet</th>
<th style="width: 120px;">Opdateret</th>
<th style="width: 110px;">Prioritet</th>
<th style="width: 160px;">Ansvarl.</th>
<th style="width: 170px;">Gruppe/Level</th>
<th style="width: 240px;">Næste todo</th>
<th style="width: 120px;">Opret.</th>
<th style="width: 120px;">Start arbejde</th>
<th style="width: 140px;">Start inden</th>
<th style="width: 120px;">Deadline</th>
</tr>
</thead>
<tbody id="sagTableBody">
@ -357,7 +383,13 @@
{% endif %}
<span class="sag-id">#{{ sag.id }}</span>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'">
<div class="sag-titel">{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
@ -366,29 +398,36 @@
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }}
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ sag.priority if sag.priority else 'normal' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
{% if sag.next_todo_title %}
<div>{{ sag.next_todo_title }}</div>
{% if sag.next_todo_due_date %}
<div class="small text-muted">Forfald: {{ sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
{% endif %}
{% else %}
-
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.updated_at.strftime('%d/%m-%Y') if sag.updated_at else '-' }}
{{ sag.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }}
</td>
</tr>
{% if has_relations %}
@ -402,7 +441,13 @@
<td>
<span class="sag-id">#{{ related_sag.id }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}'">
{% for rt in all_rel_types %}
<span class="relation-badge">{{ rt }}</span>
{% endfor %}
@ -414,29 +459,36 @@
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ related_sag.priority if related_sag.priority else 'normal' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
{% if related_sag.next_todo_title %}
<div>{{ related_sag.next_todo_title }}</div>
{% if related_sag.next_todo_due_date %}
<div class="small text-muted">Forfald: {{ related_sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
{% endif %}
{% else %}
-
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.updated_at.strftime('%d/%m-%Y') if related_sag.updated_at else '-' }}
{{ related_sag.start_date.strftime('%d/%m-%Y') if related_sag.start_date else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.deadline.strftime('%d/%m-%Y') if related_sag.deadline else '-' }}
</td>
</tr>
{% endif %}

View File

@ -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")

View File

@ -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);

View File

@ -12,15 +12,31 @@ import os
import shutil
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.config import settings
logger = logging.getLogger(__name__)
# APIRouter instance (module_loader kigger efter denne)
router = APIRouter()
# Upload directory for logos
LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
# Upload directory for logos (works in both Docker and local development)
_logo_base_dir = os.path.abspath(settings.UPLOAD_DIR)
LOGO_UPLOAD_DIR = os.path.join(_logo_base_dir, "webshop_logos")
try:
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
except OSError as exc:
if _logo_base_dir.startswith('/app/'):
_fallback_base = os.path.abspath('uploads')
LOGO_UPLOAD_DIR = os.path.join(_fallback_base, "webshop_logos")
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
logger.warning(
"⚠️ Webshop logo dir %s not writable (%s). Using fallback %s",
_logo_base_dir,
exc,
LOGO_UPLOAD_DIR,
)
else:
raise
# ============================================================================

View File

@ -67,12 +67,12 @@ class CaseAnalysisService:
return analysis
else:
logger.warning("⚠️ Ollama returned no result, using empty analysis")
return self._empty_analysis(text)
logger.warning("⚠️ Ollama returned no result, using heuristic fallback analysis")
return await self._heuristic_fallback_analysis(text)
except Exception as e:
logger.error(f"❌ Case analysis failed: {e}", exc_info=True)
return self._empty_analysis(text)
return await self._heuristic_fallback_analysis(text)
def _build_analysis_prompt(self) -> str:
"""Build Danish system prompt for case analysis"""
@ -470,6 +470,73 @@ Returner JSON med suggested_title, suggested_description, priority, customer_hin
confidence=0.0,
ai_reasoning="AI unavailable - fill fields manually"
)
async def _heuristic_fallback_analysis(self, text: str) -> QuickCreateAnalysis:
"""Local fallback when AI service is unavailable."""
cleaned_text = (text or "").strip()
if not cleaned_text:
return self._empty_analysis(text)
lowered = cleaned_text.lower()
# Priority heuristics based on urgency wording.
urgent_terms = ["nede", "kritisk", "asap", "omgående", "straks", "akut", "haster"]
high_terms = ["hurtigt", "vigtigt", "snarest", "prioriter"]
low_terms = ["når i får tid", "ikke hastende", "lavprioriteret"]
if any(term in lowered for term in urgent_terms):
priority = SagPriority.URGENT
elif any(term in lowered for term in high_terms):
priority = SagPriority.HIGH
elif any(term in lowered for term in low_terms):
priority = SagPriority.LOW
else:
priority = SagPriority.NORMAL
# Basic title heuristic: first non-empty line/sentence, clipped to 80 chars.
first_line = cleaned_text.splitlines()[0].strip()
first_sentence = re.split(r"[.!?]", first_line)[0].strip()
title_source = first_sentence or first_line or cleaned_text
title = title_source[:80].strip()
if not title:
title = "Ny sag"
# Lightweight keyword tags.
keyword_tags = {
"printer": "printer",
"mail": "mail",
"email": "mail",
"vpn": "vpn",
"net": "netværk",
"wifi": "wifi",
"server": "server",
"laptop": "laptop",
"adgang": "adgang",
"onboarding": "onboarding",
}
suggested_tags: List[str] = []
for key, tag in keyword_tags.items():
if key in lowered and tag not in suggested_tags:
suggested_tags.append(tag)
# Try simple customer matching from long words in text.
candidate_hints = []
for token in re.findall(r"[A-Za-z0-9ÆØÅæøå._-]{3,}", cleaned_text):
if token.lower() in {"ring", "kunde", "sag", "skal", "have", "virker", "ikke"}:
continue
candidate_hints.append(token)
customer_id, customer_name = await self._match_customer(candidate_hints[:8])
return QuickCreateAnalysis(
suggested_title=title,
suggested_description=cleaned_text,
suggested_priority=priority,
suggested_customer_id=customer_id,
suggested_customer_name=customer_name,
suggested_tags=suggested_tags,
confidence=0.35,
ai_reasoning="AI service unavailable - using local fallback suggestions"
)
def _get_cached_analysis(self, text: str) -> Optional[QuickCreateAnalysis]:
"""Get cached analysis if available and not expired"""

View File

@ -6,6 +6,7 @@ Adapted from OmniSync for BMC Hub timetracking use cases
import logging
import json
import asyncio
from typing import Dict, Optional, List
from datetime import datetime
import aiohttp

View File

@ -49,6 +49,7 @@ class EmailProcessorService:
'fetched': 0,
'saved': 0,
'classified': 0,
'awaiting_user_action': 0,
'rules_matched': 0,
'errors': 0
}
@ -86,6 +87,8 @@ class EmailProcessorService:
if result.get('classified'):
stats['classified'] += 1
if result.get('awaiting_user_action'):
stats['awaiting_user_action'] += 1
if result.get('rules_matched'):
stats['rules_matched'] += 1
@ -109,6 +112,7 @@ class EmailProcessorService:
email_id = email_data.get('id')
stats = {
'classified': False,
'awaiting_user_action': False,
'workflows_executed': 0,
'rules_matched': False
}
@ -123,6 +127,22 @@ class EmailProcessorService:
if settings.EMAIL_AUTO_CLASSIFY:
await self._classify_and_update(email_data)
stats['classified'] = True
# Step 3.5: Gate automation by manual-approval policy and confidence
# Phase-1 policy: suggestions are generated automatically, actions are user-approved.
classification = (email_data.get('classification') or '').strip().lower()
confidence = float(email_data.get('confidence_score') or 0.0)
require_manual_approval = getattr(settings, 'EMAIL_REQUIRE_MANUAL_APPROVAL', True)
if require_manual_approval:
await self._set_awaiting_user_action(email_id, reason='manual_approval_required')
stats['awaiting_user_action'] = True
return stats
if not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD:
await self._set_awaiting_user_action(email_id, reason='low_confidence')
stats['awaiting_user_action'] = True
return stats
# Step 4: Execute workflows based on classification
workflow_processed = False
@ -172,6 +192,25 @@ class EmailProcessorService:
except Exception as e:
logger.error(f"❌ Error in process_single_email for {email_id}: {e}")
raise e
async def _set_awaiting_user_action(self, email_id: Optional[int], reason: str):
"""Park an email for manual review before any automatic routing/action."""
if not email_id:
return
execute_update(
"""
UPDATE email_messages
SET status = 'awaiting_user_action',
folder = COALESCE(folder, 'INBOX'),
auto_processed = false,
processed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(email_id,)
)
logger.info("🛑 Email %s moved to awaiting_user_action (%s)", email_id, reason)
async def _classify_and_update(self, email_data: Dict):
"""Classify email and update database"""

View File

@ -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,107 @@ 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,
in_reply_to: Optional[str] = None,
references: 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
if in_reply_to:
msg['In-Reply-To'] = in_reply_to
if references:
msg['References'] = references
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

View File

@ -10,6 +10,7 @@ from app.core.config import settings
import httpx
import time
import logging
import json
logger = logging.getLogger(__name__)
router = APIRouter()
@ -75,7 +76,7 @@ async def get_setting(key: str):
query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,))
if not result and key in {"case_types", "case_type_module_defaults"}:
if not result and key in {"case_types", "case_type_module_defaults", "case_statuses"}:
seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
@ -108,6 +109,25 @@ async def get_setting(key: str):
)
)
if key == "case_statuses":
execute_query(
seed_query,
(
"case_statuses",
json.dumps([
{"value": "åben", "is_closed": False},
{"value": "under behandling", "is_closed": False},
{"value": "afventer", "is_closed": False},
{"value": "løst", "is_closed": True},
{"value": "lukket", "is_closed": True},
], ensure_ascii=False),
"system",
"Sagsstatus værdier og lukkede markeringer",
"json",
True,
)
)
result = execute_query(query, (key,))
if not result:
@ -180,7 +200,7 @@ async def sync_settings_from_env():
@router.get("/users", response_model=List[User], tags=["Users"])
async def get_users(is_active: Optional[bool] = None):
"""Get all users"""
query = "SELECT user_id as id, username, email, full_name, is_active, last_login, created_at FROM users"
query = "SELECT user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at FROM users"
params = []
if is_active is not None:
@ -195,7 +215,7 @@ async def get_users(is_active: Optional[bool] = None):
@router.get("/users/{user_id}", response_model=User, tags=["Users"])
async def get_user(user_id: int):
"""Get user by ID"""
query = "SELECT user_id as id, username, email, full_name, is_active, last_login, created_at FROM users WHERE user_id = %s"
query = "SELECT user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at FROM users WHERE user_id = %s"
result = execute_query(query, (user_id,))
if not result:
@ -219,7 +239,7 @@ async def create_user(user: UserCreate):
query = """
INSERT INTO users (username, email, password_hash, full_name, is_active)
VALUES (%s, %s, %s, %s, true)
RETURNING user_id as id, username, email, full_name, is_active, last_login, created_at
RETURNING user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at
"""
result = execute_query(query, (user.username, user.email, password_hash, user.full_name))
@ -260,7 +280,7 @@ async def update_user(user_id: int, user: UserUpdate):
UPDATE users
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
WHERE user_id = %s
RETURNING user_id as id, username, email, full_name, is_active, last_login, created_at
RETURNING user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at
"""
result = execute_query(query, tuple(params))
@ -578,9 +598,12 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
start = time.perf_counter()
try:
use_chat_api = model.startswith("qwen3")
model_normalized = (model or "").strip().lower()
# qwen models are more reliable with /api/chat than /api/generate.
use_chat_api = model_normalized.startswith("qwen")
async with httpx.AsyncClient(timeout=60.0) as client:
timeout = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
if use_chat_api:
response = await client.post(
f"{endpoint}/api/chat",
@ -611,7 +634,14 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
detail=f"AI endpoint fejl: {response.status_code} - {response.text[:300]}",
)
data = response.json()
try:
data = response.json()
except Exception as parse_error:
raise HTTPException(
status_code=502,
detail=f"AI endpoint returnerede ugyldig JSON: {str(parse_error)}",
)
if use_chat_api:
message_data = data.get("message", {})
ai_response = (message_data.get("content") or message_data.get("thinking") or "").strip()
@ -634,8 +664,12 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
except HTTPException:
raise
except httpx.TimeoutException as e:
logger.error(f"❌ AI prompt test timed out for {key}: {repr(e)}")
raise HTTPException(status_code=504, detail="AI test timed out (model svarer for langsomt)")
except Exception as e:
logger.error(f"❌ AI prompt test failed for {key}: {e}")
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {str(e)}")
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")

View File

@ -764,6 +764,62 @@
</div>
</div>
<!-- Archived Ticket Sync + Monitor -->
<div class="card mb-4">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0 fw-bold">Archived Tickets Sync</h6>
<small class="text-muted">Overvaager om alle archived tickets er synket ned (kildeantal vs lokal DB)</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary" id="archivedOverallBadge">Status ukendt</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadArchivedSyncStatus()" id="btnCheckArchivedSync">
<i class="bi bi-arrow-repeat me-1"></i>Tjek nu
</button>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0">Simply archived</h6>
<span class="badge bg-secondary" id="archivedSimplyBadge">Ukendt</span>
</div>
<div class="small text-muted mb-2">Remote: <span id="archivedSimplyRemoteCount">-</span> | Lokal: <span id="archivedSimplyLocalCount">-</span> | Diff: <span id="archivedSimplyDiff">-</span></div>
<div class="small text-muted mb-3">Beskeder lokalt: <span id="archivedSimplyMessagesCount">-</span></div>
<div class="d-grid">
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedSimply()" id="btnSyncArchivedSimply">
<i class="bi bi-cloud-download me-2"></i>Sync Simply Archived
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0">vTiger Cases archived</h6>
<span class="badge bg-secondary" id="archivedVtigerBadge">Ukendt</span>
</div>
<div class="small text-muted mb-2">Remote: <span id="archivedVtigerRemoteCount">-</span> | Lokal: <span id="archivedVtigerLocalCount">-</span> | Diff: <span id="archivedVtigerDiff">-</span></div>
<div class="small text-muted mb-3">Beskeder lokalt: <span id="archivedVtigerMessagesCount">-</span></div>
<div class="d-grid">
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedVtiger()" id="btnSyncArchivedVtiger">
<i class="bi bi-cloud-download me-2"></i>Sync vTiger Archived
</button>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">Sidst tjekket: <span id="archivedLastChecked">Aldrig</span></small>
<small class="text-muted" id="archivedStatusHint">Polling aktiv naar Sync-fanen er aaben.</small>
</div>
</div>
</div>
<!-- Sync Log -->
<div class="card">
<div class="card-header bg-white">
@ -1040,7 +1096,8 @@ async def scan_document(file_path: str):
<option value="/ticket/dashboard/technician/v2" {% if default_dashboard_path == '/ticket/dashboard/technician/v2' %}selected{% endif %}>Tekniker Dashboard V2</option>
<option value="/ticket/dashboard/technician/v3" {% if default_dashboard_path == '/ticket/dashboard/technician/v3' %}selected{% endif %}>Tekniker Dashboard V3</option>
<option value="/dashboard/sales" {% if default_dashboard_path == '/dashboard/sales' %}selected{% endif %}>Salg Dashboard</option>
{% if default_dashboard_path and default_dashboard_path not in ['/ticket/dashboard/technician/v1', '/ticket/dashboard/technician/v2', '/ticket/dashboard/technician/v3', '/dashboard/sales'] %}
<option value="/dashboard/mission-control" {% if default_dashboard_path == '/dashboard/mission-control' %}selected{% endif %}>Mission Control</option>
{% if default_dashboard_path and default_dashboard_path not in ['/ticket/dashboard/technician/v1', '/ticket/dashboard/technician/v2', '/ticket/dashboard/technician/v3', '/dashboard/sales', '/dashboard/mission-control'] %}
<option value="{{ default_dashboard_path }}" selected>Nuværende (tilpasset): {{ default_dashboard_path }}</option>
{% endif %}
</select>
@ -1086,6 +1143,33 @@ async def scan_document(file_path: str):
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Sagsstatus</h5>
<p class="text-muted mb-0">Styr hvilke status-værdier der kan vælges, og marker hvilke der er lukkede.</p>
</div>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="caseStatusInput" placeholder="F.eks. afventer kunde" style="max-width: 260px;">
<button class="btn btn-primary" onclick="addCaseStatus()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Status</th>
<th class="text-center" style="width: 150px;">Lukket værdi</th>
<th class="text-end" style="width: 100px;">Handling</th>
</tr>
</thead>
<tbody id="caseStatusesTableBody">
<tr><td colspan="3" class="text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
@ -1605,6 +1689,8 @@ async function loadSettings() {
displaySettingsByCategory();
renderTelefoniSettings();
await loadCaseTypesSetting();
await loadCaseStatusesSetting();
await loadTagsManagement();
await loadNextcloudInstances();
} catch (error) {
console.error('Error loading settings:', error);
@ -1974,6 +2060,132 @@ const CASE_MODULE_LABELS = {
};
let caseTypeModuleDefaultsCache = {};
let caseStatusesCache = [];
function normalizeCaseStatuses(raw) {
const normalized = [];
const seen = new Set();
const source = Array.isArray(raw) ? raw : [];
source.forEach((item) => {
const row = typeof item === 'string'
? { value: item, is_closed: false }
: (item && typeof item === 'object' ? item : null);
if (!row) return;
const value = String(row.value || '').trim();
if (!value) return;
const key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
normalized.push({
value,
is_closed: Boolean(row.is_closed)
});
});
const defaults = [
{ value: 'åben', is_closed: false },
{ value: 'under behandling', is_closed: false },
{ value: 'afventer', is_closed: false },
{ value: 'løst', is_closed: true },
{ value: 'lukket', is_closed: true }
];
defaults.forEach((item) => {
const key = item.value.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
normalized.push(item);
}
});
return normalized;
}
function renderCaseStatuses(rows) {
const tbody = document.getElementById('caseStatusesTableBody');
if (!tbody) return;
if (!Array.isArray(rows) || !rows.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-muted">Ingen statusværdier defineret</td></tr>';
return;
}
tbody.innerHTML = rows.map((row, index) => `
<tr>
<td><span class="fw-semibold">${escapeHtml(row.value)}</span></td>
<td class="text-center">
<div class="form-check form-switch d-inline-flex">
<input class="form-check-input" type="checkbox" id="caseStatusClosed_${index}" ${row.is_closed ? 'checked' : ''}
onchange="toggleCaseStatusClosed(${index}, this.checked)">
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeCaseStatus(${index})" title="Slet status">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function loadCaseStatusesSetting() {
try {
const response = await fetch('/api/v1/settings/case_statuses');
if (!response.ok) {
caseStatusesCache = normalizeCaseStatuses([]);
renderCaseStatuses(caseStatusesCache);
return;
}
const setting = await response.json();
const parsed = JSON.parse(setting.value || '[]');
caseStatusesCache = normalizeCaseStatuses(parsed);
renderCaseStatuses(caseStatusesCache);
} catch (error) {
console.error('Error loading case statuses:', error);
caseStatusesCache = normalizeCaseStatuses([]);
renderCaseStatuses(caseStatusesCache);
}
}
async function saveCaseStatuses() {
await updateSetting('case_statuses', JSON.stringify(caseStatusesCache));
renderCaseStatuses(caseStatusesCache);
}
async function addCaseStatus() {
const input = document.getElementById('caseStatusInput');
if (!input) return;
const value = input.value.trim();
if (!value) return;
const exists = caseStatusesCache.some((row) => String(row.value || '').toLowerCase() === value.toLowerCase());
if (!exists) {
caseStatusesCache.push({ value, is_closed: false });
await saveCaseStatuses();
}
input.value = '';
}
async function removeCaseStatus(index) {
caseStatusesCache = caseStatusesCache.filter((_, i) => i !== index);
if (!caseStatusesCache.length) {
caseStatusesCache = normalizeCaseStatuses([]);
}
await saveCaseStatuses();
}
async function toggleCaseStatusClosed(index, checked) {
if (!caseStatusesCache[index]) return;
caseStatusesCache[index].is_closed = Boolean(checked);
await saveCaseStatuses();
}
function normalizeCaseTypeModuleDefaults(raw, caseTypes) {
const normalized = {};
@ -2184,14 +2396,16 @@ async function loadUsers() {
async function loadAdminUsers() {
try {
const response = await fetch('/api/v1/admin/users');
if (!response.ok) throw new Error('Failed to load users');
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke indlaese brugere'));
}
usersCache = await response.json();
displayUsers(usersCache);
populateTelefoniTestUsers(usersCache);
} catch (error) {
console.error('Error loading users:', error);
const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted py-5">Kunne ikke indlæse brugere</td></tr>';
tbody.innerHTML = `<tr><td colspan="11" class="text-center text-muted py-5">${escapeHtml(error.message || 'Kunne ikke indlaese brugere')}</td></tr>`;
}
}
@ -2472,17 +2686,21 @@ async function createUser() {
async function toggleUserActive(userId, isActive) {
try {
const response = await fetch(`/api/v1/users/${userId}`, {
method: 'PUT',
const response = await fetch(`/api/v1/admin/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: isActive })
});
if (response.ok) {
loadUsers();
if (!response.ok) {
alert(await getErrorMessage(response, 'Kunne ikke opdatere brugerstatus'));
return;
}
loadUsers();
} catch (error) {
console.error('Error toggling user:', error);
alert('Kunne ikke opdatere brugerstatus');
}
}
@ -2665,13 +2883,18 @@ async function resetPassword(userId) {
if (!newPassword) return;
try {
const response = await fetch(`/api/v1/users/${userId}/reset-password?new_password=${newPassword}`, {
method: 'POST'
const response = await fetch(`/api/v1/admin/users/${userId}/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_password: newPassword })
});
if (response.ok) {
alert('Adgangskode nulstillet!');
if (!response.ok) {
alert(await getErrorMessage(response, 'Kunne ikke nulstille adgangskode'));
return;
}
alert('Adgangskode nulstillet!');
} catch (error) {
console.error('Error resetting password:', error);
alert('Kunne ikke nulstille adgangskode');
@ -3017,6 +3240,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
// Load data for tab
if (tab === 'users') {
loadUsers();
} else if (tab === 'tags') {
loadTagsManagement();
} else if (tab === 'telefoni') {
renderTelefoniSettings();
} else if (tab === 'ai-prompts') {
@ -3091,13 +3316,19 @@ let showInactive = false;
async function loadTagsManagement() {
try {
const response = await fetch('/api/v1/tags');
if (!response.ok) throw new Error('Failed to load tags');
if (!response.ok) {
const msg = await getErrorMessage(response, 'Kunne ikke indlæse tags');
throw new Error(msg);
}
allTagsData = await response.json();
updateTagsStats();
renderTagsGrid();
} catch (error) {
console.error('Error loading tags:', error);
showNotification('Fejl ved indlæsning af tags', 'error');
allTagsData = [];
updateTagsStats();
renderTagsGrid();
showNotification('Fejl ved indlæsning af tags: ' + (error.message || 'ukendt fejl'), 'error');
}
}
@ -3312,6 +3543,7 @@ if (tagsNavLink) {
// ====== SYNC MANAGEMENT ======
let syncLog = [];
let archivedSyncPollInterval = null;
async function loadSyncStats() {
try {
@ -3396,9 +3628,195 @@ async function parseApiError(response, fallbackMessage) {
return '2FA kræves for sync API. Aktivér 2FA på din bruger og log ind igen.';
}
if (response.status === 403) {
if (String(detailMessage).includes('Missing required permission') || String(detailMessage).includes('Superadmin access required')) {
return 'Kun admin/superadmin må starte eller overvåge sync.';
}
}
return detailMessage;
}
function updateArchivedSourceBadge(sourceKey, isSynced, hasError) {
const badgeId = sourceKey === 'simplycrm' ? 'archivedSimplyBadge' : 'archivedVtigerBadge';
const badge = document.getElementById(badgeId);
if (!badge) return;
if (hasError) {
badge.className = 'badge bg-danger';
badge.textContent = 'Fejl';
return;
}
if (isSynced === true) {
badge.className = 'badge bg-success';
badge.textContent = 'Synket';
return;
}
badge.className = 'badge bg-warning text-dark';
badge.textContent = 'Mangler';
}
function startArchivedSyncPolling() {
if (archivedSyncPollInterval) return;
archivedSyncPollInterval = setInterval(() => {
loadArchivedSyncStatus();
}, 15000);
}
function stopArchivedSyncPolling() {
if (!archivedSyncPollInterval) return;
clearInterval(archivedSyncPollInterval);
archivedSyncPollInterval = null;
}
async function loadArchivedSyncStatus() {
const overallBadge = document.getElementById('archivedOverallBadge');
const lastChecked = document.getElementById('archivedLastChecked');
const hint = document.getElementById('archivedStatusHint');
try {
const response = await fetch('/api/v1/ticket/archived/status');
if (!response.ok) {
const errorMessage = await parseApiError(response, 'Kunne ikke hente archived status');
throw new Error(errorMessage);
}
const status = await response.json();
const simply = (status.sources || {}).simplycrm || {};
const vtiger = (status.sources || {}).vtiger || {};
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value === null || value === undefined ? '-' : value;
};
setText('archivedSimplyRemoteCount', simply.remote_total_tickets);
setText('archivedSimplyLocalCount', simply.local_total_tickets);
setText('archivedSimplyDiff', simply.diff);
setText('archivedSimplyMessagesCount', simply.local_total_messages);
setText('archivedVtigerRemoteCount', vtiger.remote_total_tickets);
setText('archivedVtigerLocalCount', vtiger.local_total_tickets);
setText('archivedVtigerDiff', vtiger.diff);
setText('archivedVtigerMessagesCount', vtiger.local_total_messages);
updateArchivedSourceBadge('simplycrm', simply.is_synced, !!simply.error);
updateArchivedSourceBadge('vtiger', vtiger.is_synced, !!vtiger.error);
if (overallBadge) {
if (status.overall_synced === true) {
overallBadge.className = 'badge bg-success';
overallBadge.textContent = 'Alt synced';
} else {
overallBadge.className = 'badge bg-warning text-dark';
overallBadge.textContent = 'Ikke fuldt synced';
}
}
if (lastChecked) {
lastChecked.textContent = new Date().toLocaleString('da-DK');
}
if (hint) {
const errors = [simply.error, vtiger.error].filter(Boolean);
hint.textContent = errors.length > 0
? `Statusfejl: ${errors.join(' | ')}`
: 'Polling aktiv mens Sync-fanen er åben.';
}
} catch (error) {
if (overallBadge) {
overallBadge.className = 'badge bg-danger';
overallBadge.textContent = 'Statusfejl';
}
if (hint) {
hint.textContent = error.message;
}
console.error('Error loading archived sync status:', error);
}
}
async function syncArchivedSimply() {
const btn = document.getElementById('btnSyncArchivedSimply');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
try {
addSyncLogEntry('Simply Archived Sync Startet', 'Importerer archived tickets fra Simply...', 'info');
const response = await fetch('/api/v1/ticket/archived/simply/import?limit=5000&include_messages=true&force=false', {
method: 'POST'
});
if (!response.ok) {
const errorMessage = await parseApiError(response, 'Simply archived sync fejlede');
throw new Error(errorMessage);
}
const result = await response.json();
const details = [
`Importeret: ${result.imported || 0}`,
`Opdateret: ${result.updated || 0}`,
`Sprunget over: ${result.skipped || 0}`,
`Fejl: ${result.errors || 0}`,
`Beskeder: ${result.messages_imported || 0}`
].join(' | ');
addSyncLogEntry('Simply Archived Sync Fuldført', details, 'success');
await loadArchivedSyncStatus();
showNotification('Simply archived sync fuldført!', 'success');
} catch (error) {
addSyncLogEntry('Simply Archived Sync Fejl', error.message, 'error');
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Sync Simply Archived';
}
}
async function syncArchivedVtiger() {
const btn = document.getElementById('btnSyncArchivedVtiger');
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
try {
addSyncLogEntry('vTiger Archived Sync Startet', 'Importerer archived tickets fra vTiger Cases...', 'info');
const response = await fetch('/api/v1/ticket/archived/vtiger/import?limit=5000&include_messages=true&force=false', {
method: 'POST'
});
if (!response.ok) {
const errorMessage = await parseApiError(response, 'vTiger archived sync fejlede');
throw new Error(errorMessage);
}
const result = await response.json();
const details = [
`Importeret: ${result.imported || 0}`,
`Opdateret: ${result.updated || 0}`,
`Sprunget over: ${result.skipped || 0}`,
`Fejl: ${result.errors || 0}`,
`Beskeder: ${result.messages_imported || 0}`
].join(' | ');
addSyncLogEntry('vTiger Archived Sync Fuldført', details, 'success');
await loadArchivedSyncStatus();
showNotification('vTiger archived sync fuldført!', 'success');
} catch (error) {
addSyncLogEntry('vTiger Archived Sync Fejl', error.message, 'error');
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Sync vTiger Archived';
}
}
async function syncFromVtiger() {
const btn = document.getElementById('btnSyncVtiger');
btn.disabled = true;
@ -3577,9 +3995,17 @@ if (syncNavLink) {
syncNavLink.addEventListener('click', () => {
loadSyncStats();
loadSyncLog();
loadArchivedSyncStatus();
startArchivedSyncPolling();
});
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopArchivedSyncPolling();
}
});
// Notification helper
function showNotification(message, type = 'info') {
// Create toast notification

View File

@ -246,12 +246,14 @@
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li>
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
</ul>
</li>
@ -280,21 +282,6 @@
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
<li><hr class="dropdown-divider"></li>
<li class="dropdown-submenu">
<a class="dropdown-item dropdown-toggle py-2" href="#" data-submenu-toggle="timetracking">
<span><i class="bi bi-clock-history me-2"></i>Timetracking</span>
<i class="bi bi-chevron-right small opacity-75"></i>
</a>
<ul class="dropdown-menu" data-submenu="timetracking">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
</ul>
</li>
@ -305,6 +292,19 @@
</li>
</ul>
<div class="d-flex align-items-center gap-3">
<div class="dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-clock-history me-2"></i>Data migration
</a>
<ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
</div>
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
<i class="bi bi-plus-circle-fill fs-5"></i>
</button>
@ -320,6 +320,7 @@
<ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="#" data-bs-toggle="modal" data-bs-target="#profileModal">Profil</a></li>
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
<li><hr class="dropdown-divider"></li>

View File

@ -303,15 +303,19 @@
async function performAnalysis(text) {
try {
const userId = getUserId();
const response = await fetch(`/api/v1/sag/analyze-quick-create?user_id=${userId}`, {
const response = await fetch('/api/v1/sag/analyze-quick-create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify({text})
body: JSON.stringify({
text,
user_id: parseInt(userId, 10)
})
});
if (!response.ok) {
throw new Error('Analysis failed');
const errorText = await response.text();
throw new Error(`Analysis failed (${response.status}): ${errorText || 'unknown error'}`);
}
const analysis = await response.json();

View File

@ -31,15 +31,17 @@ SYNC RULES:
"""
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from app.core.database import execute_query
from app.core.auth_dependencies import require_any_permission
from app.services.vtiger_service import get_vtiger_service
import re
logger = logging.getLogger(__name__)
router = APIRouter()
sync_admin_access = require_any_permission("users.manage", "system.admin")
def normalize_name(name: str) -> str:
@ -53,7 +55,7 @@ def normalize_name(name: str) -> str:
@router.post("/sync/vtiger")
async def sync_from_vtiger() -> Dict[str, Any]:
async def sync_from_vtiger(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
"""
Link vTiger accounts to existing Hub customers
Matches by CVR or normalized name, updates vtiger_id
@ -186,7 +188,7 @@ async def sync_from_vtiger() -> Dict[str, Any]:
@router.post("/sync/vtiger-contacts")
async def sync_vtiger_contacts() -> Dict[str, Any]:
async def sync_vtiger_contacts(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
"""
SIMPEL TILGANG - Sync contacts from vTiger and link to customers
Step 1: Fetch all contacts from vTiger
@ -446,7 +448,7 @@ async def sync_vtiger_contacts() -> Dict[str, Any]:
@router.post("/sync/economic")
async def sync_from_economic() -> Dict[str, Any]:
async def sync_from_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
"""
Sync customers from e-conomic (PRIMARY SOURCE)
Creates/updates Hub customers with e-conomic data
@ -606,7 +608,7 @@ async def sync_from_economic() -> Dict[str, Any]:
@router.post("/sync/cvr-to-economic")
async def sync_cvr_to_economic() -> Dict[str, Any]:
async def sync_cvr_to_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
"""
Find customers in Hub with CVR but without e-conomic customer number
Search e-conomic for matching CVR and update Hub
@ -668,7 +670,7 @@ async def sync_cvr_to_economic() -> Dict[str, Any]:
@router.get("/sync/diagnostics")
async def sync_diagnostics() -> Dict[str, Any]:
async def sync_diagnostics(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
"""
Diagnostics: Check contact linking coverage
Shows why contacts aren't linking to customers

View File

@ -6,7 +6,7 @@ from typing import Optional, List, Literal
from datetime import datetime
# Tag types
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing']
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type']
TagGroupBehavior = Literal['multi', 'single', 'toggle']
@ -37,6 +37,7 @@ class TagBase(BaseModel):
icon: Optional[str] = None
is_active: bool = True
tag_group_id: Optional[int] = None
catch_words: Optional[List[str]] = None
class TagCreate(TagBase):
"""Tag creation model"""
@ -59,6 +60,7 @@ class TagUpdate(BaseModel):
icon: Optional[str] = None
is_active: Optional[bool] = None
tag_group_id: Optional[int] = None
catch_words: Optional[List[str]] = None
class EntityTagBase(BaseModel):

View File

@ -1,8 +1,10 @@
"""
Tag system API endpoints
"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional
import json
import re
from app.tags.backend.models import (
Tag, TagCreate, TagUpdate,
EntityTag, EntityTagCreate,
@ -14,6 +16,197 @@ from app.core.database import execute_query, execute_query_single, execute_updat
router = APIRouter(prefix="/tags")
MODULE_LABELS = {
"case": "Sager",
"email": "Email",
"ticket": "Tickets",
"customer": "Kunder",
"contact": "Kontakter",
"time_entry": "Tid",
"order": "Ordrer",
"comment": "Ticket kommentarer",
"worklog": "Ticket worklog",
}
def _module_label_for_entity_type(entity_type: Optional[str]) -> str:
key = str(entity_type or "").strip().lower()
if not key:
return "Ukendt modul"
return MODULE_LABELS.get(key, f"Ukendt modul ({key})")
def _entity_reference_payload(entity_type: Optional[str], entity_id: Optional[int]) -> dict:
etype = str(entity_type or "").strip().lower()
eid = int(entity_id or 0)
default_label = f"#{eid}" if eid else "Ukendt"
if not etype or not eid:
return {"entity_title": default_label, "entity_url": None}
try:
if etype == "case":
row = execute_query_single(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(eid,),
)
if row:
title = str(row.get("titel") or "Sag").strip()
return {"entity_title": title, "entity_url": f"/sag/{eid}"}
elif etype == "email":
row = execute_query_single(
"SELECT id, subject FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(eid,),
)
if row:
title = str(row.get("subject") or "Email").strip()
return {"entity_title": title, "entity_url": f"/emails?id={eid}"}
elif etype == "ticket":
row = execute_query_single(
"SELECT id, ticket_number, subject FROM tticket_tickets WHERE id = %s",
(eid,),
)
if row:
ticket_number = str(row.get("ticket_number") or "").strip()
subject = str(row.get("subject") or "Ticket").strip()
title = f"{ticket_number} - {subject}" if ticket_number else subject
return {"entity_title": title, "entity_url": f"/ticket/tickets/{eid}"}
elif etype == "customer":
row = execute_query_single("SELECT id, name FROM customers WHERE id = %s", (eid,))
if row:
title = str(row.get("name") or "Kunde").strip()
return {"entity_title": title, "entity_url": f"/customers/{eid}"}
elif etype == "contact":
row = execute_query_single(
"SELECT id, first_name, last_name, email FROM contacts WHERE id = %s",
(eid,),
)
if row:
name = " ".join(
[str(row.get("first_name") or "").strip(), str(row.get("last_name") or "").strip()]
).strip()
title = name or str(row.get("email") or "Kontakt").strip()
return {"entity_title": title, "entity_url": f"/contacts/{eid}"}
elif etype == "time_entry":
row = execute_query_single(
"SELECT id, description, worked_date FROM tmodule_times WHERE id = %s",
(eid,),
)
if row:
description = str(row.get("description") or "Tidsregistrering").strip()
return {"entity_title": description[:90], "entity_url": "/timetracking/registrations"}
elif etype == "order":
row = execute_query_single(
"SELECT id, order_number, total_amount FROM tmodule_orders WHERE id = %s",
(eid,),
)
if row:
order_number = str(row.get("order_number") or "Ordre").strip()
total_amount = row.get("total_amount")
suffix = f" ({total_amount} kr.)" if total_amount is not None else ""
return {"entity_title": f"{order_number}{suffix}", "entity_url": "/timetracking/orders"}
elif etype == "worklog":
row = execute_query_single(
"""
SELECT w.id, w.description, w.ticket_id, t.ticket_number
FROM tticket_worklog w
LEFT JOIN tticket_tickets t ON t.id = w.ticket_id
WHERE w.id = %s
""",
(eid,),
)
if row:
ticket_id = row.get("ticket_id")
ticket_number = str(row.get("ticket_number") or "Ticket").strip()
description = str(row.get("description") or "Worklog").strip()
url = f"/ticket/tickets/{ticket_id}" if ticket_id else None
return {"entity_title": f"{ticket_number} - {description[:70]}", "entity_url": url}
elif etype == "comment":
row = execute_query_single(
"""
SELECT c.id, c.comment_text, c.ticket_id, t.ticket_number
FROM tticket_comments c
LEFT JOIN tticket_tickets t ON t.id = c.ticket_id
WHERE c.id = %s
""",
(eid,),
)
if row:
ticket_id = row.get("ticket_id")
ticket_number = str(row.get("ticket_number") or "Ticket").strip()
comment_text = str(row.get("comment_text") or "Kommentar").strip()
url = f"/ticket/tickets/{ticket_id}" if ticket_id else None
return {"entity_title": f"{ticket_number} - {comment_text[:70]}", "entity_url": url}
except Exception:
pass
return {"entity_title": default_label, "entity_url": None}
def _normalize_catch_words(value) -> List[str]:
"""Normalize catch words from JSON/text/list to a clean lowercase list."""
if value is None:
return []
if isinstance(value, list):
words = value
elif isinstance(value, str):
stripped = value.strip()
if not stripped:
return []
if stripped.startswith("["):
try:
parsed = json.loads(stripped)
words = parsed if isinstance(parsed, list) else []
except Exception:
words = [w.strip() for w in stripped.replace("\n", ",").split(",")]
else:
words = [w.strip() for w in stripped.replace("\n", ",").split(",")]
else:
words = []
cleaned = []
seen = set()
for word in words:
normalized = str(word or "").strip().lower()
if len(normalized) < 2:
continue
if normalized in seen:
continue
seen.add(normalized)
cleaned.append(normalized)
return cleaned
def _tag_row_to_response(row: dict) -> dict:
"""Ensure API response always exposes catch_words as a list."""
if not row:
return row
out = dict(row)
valid_types = {"workflow", "status", "category", "priority", "billing", "brand", "type"}
tag_type = str(out.get("type") or "").strip().lower()
if tag_type not in valid_types:
tag_type = "category"
out["type"] = tag_type
color = str(out.get("color") or "").strip()
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", color):
out["color"] = "#0f4c75"
if not out.get("name"):
out["name"] = "Unnamed tag"
out["catch_words"] = _normalize_catch_words(out.get("catch_words"))
return out
# ============= TAG GROUPS =============
@router.get("/groups", response_model=List[TagGroup])
@ -34,13 +227,131 @@ async def create_tag_group(group: TagGroupCreate):
# ============= TAG CRUD =============
@router.get("/usage")
async def list_tag_usage(
tag_name: Optional[str] = Query(None),
tag_type: Optional[TagType] = Query(None),
module: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=200),
sort_by: str = Query("tagged_at"),
sort_dir: str = Query("desc"),
):
"""List tag usage across modules with server-side filtering and pagination."""
where_parts = ["1=1"]
params: List[object] = []
if tag_name:
where_parts.append("LOWER(t.name) LIKE LOWER(%s)")
params.append(f"%{tag_name.strip()}%")
if tag_type:
where_parts.append("t.type = %s")
params.append(tag_type)
if module:
where_parts.append("LOWER(et.entity_type) = LOWER(%s)")
params.append(module.strip())
where_clause = " AND ".join(where_parts)
sortable = {
"tagged_at": "et.tagged_at",
"tag_name": "t.name",
"tag_type": "t.type",
"module": "et.entity_type",
"entity_id": "et.entity_id",
}
order_column = sortable.get(sort_by, "et.tagged_at")
order_direction = "ASC" if str(sort_dir).lower() == "asc" else "DESC"
count_query = f"""
SELECT COUNT(*) AS total
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE {where_clause}
"""
count_row = execute_query_single(count_query, tuple(params)) or {"total": 0}
total = int(count_row.get("total") or 0)
offset = (page - 1) * page_size
data_query = f"""
SELECT
et.id AS entity_tag_id,
et.entity_type,
et.entity_id,
et.tagged_at,
t.id AS tag_id,
t.name AS tag_name,
t.type AS tag_type,
t.color AS tag_color,
t.is_active AS tag_is_active
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE {where_clause}
ORDER BY {order_column} {order_direction}, et.id DESC
LIMIT %s OFFSET %s
"""
rows = execute_query(data_query, tuple(params + [page_size, offset])) or []
items = []
for row in rows:
entity_type = row.get("entity_type")
entity_ref = _entity_reference_payload(entity_type, row.get("entity_id"))
items.append(
{
"entity_tag_id": row.get("entity_tag_id"),
"tag_id": row.get("tag_id"),
"tag_name": row.get("tag_name"),
"tag_type": row.get("tag_type"),
"tag_color": row.get("tag_color"),
"tag_is_active": bool(row.get("tag_is_active")),
"module": _module_label_for_entity_type(entity_type),
"entity_type": entity_type,
"entity_id": row.get("entity_id"),
"entity_title": entity_ref.get("entity_title"),
"entity_url": entity_ref.get("entity_url"),
"tagged_at": row.get("tagged_at"),
}
)
module_rows = execute_query(
"SELECT DISTINCT entity_type FROM entity_tags ORDER BY entity_type",
(),
) or []
module_options = [
{
"value": row.get("entity_type"),
"label": _module_label_for_entity_type(row.get("entity_type")),
}
for row in module_rows
]
return {
"items": items,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": (total + page_size - 1) // page_size if total else 0,
},
"sort": {"sort_by": sort_by, "sort_dir": order_direction.lower()},
"module_options": module_options,
}
@router.get("", response_model=List[Tag])
async def list_tags(
type: Optional[TagType] = None,
is_active: Optional[bool] = None
):
"""List all tags with optional filtering"""
query = "SELECT * FROM tags WHERE 1=1"
query = """
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE 1=1
"""
params = []
if type:
@ -54,32 +365,52 @@ async def list_tags(
query += " ORDER BY type, name"
results = execute_query(query, tuple(params) if params else ())
return results
return [_tag_row_to_response(row) for row in (results or [])]
@router.get("/{tag_id}", response_model=Tag)
async def get_tag(tag_id: int):
"""Get single tag by ID"""
result = execute_query_single(
"SELECT * FROM tags WHERE id = %s",
"""
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE id = %s
""",
(tag_id,)
)
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
return result
return _tag_row_to_response(result)
@router.post("", response_model=Tag)
async def create_tag(tag: TagCreate):
"""Create new tag"""
query = """
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id, catch_words_json)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
"""
catch_words = _normalize_catch_words(tag.catch_words)
result = execute_query_single(
query,
(tag.name, tag.type, tag.description, tag.color, tag.icon, tag.is_active, tag.tag_group_id)
(
tag.name,
tag.type,
tag.description,
tag.color,
tag.icon,
tag.is_active,
tag.tag_group_id,
json.dumps(catch_words),
)
)
return result
if not result:
raise HTTPException(status_code=500, detail="Failed to create tag")
return _tag_row_to_response(result)
@router.put("/{tag_id}", response_model=Tag)
async def update_tag(tag_id: int, tag: TagUpdate):
@ -106,6 +437,9 @@ async def update_tag(tag_id: int, tag: TagUpdate):
if tag.tag_group_id is not None:
updates.append("tag_group_id = %s")
params.append(tag.tag_group_id)
if tag.catch_words is not None:
updates.append("catch_words_json = %s::jsonb")
params.append(json.dumps(_normalize_catch_words(tag.catch_words)))
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
@ -117,13 +451,15 @@ async def update_tag(tag_id: int, tag: TagUpdate):
UPDATE tags
SET {', '.join(updates)}
WHERE id = %s
RETURNING *
RETURNING id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
"""
result = execute_query_single(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
return result
return _tag_row_to_response(result)
@router.delete("/{tag_id}")
async def delete_tag(tag_id: int):
@ -214,20 +550,91 @@ async def remove_tag_from_entity_path(
async def get_entity_tags(entity_type: str, entity_id: int):
"""Get all tags for a specific entity"""
query = """
SELECT t.*
SELECT t.id, t.name, t.type, t.description, t.color, t.icon, t.is_active, t.tag_group_id,
COALESCE(t.catch_words_json, '[]'::jsonb) AS catch_words,
t.created_at, t.updated_at
FROM tags t
JOIN entity_tags et ON et.tag_id = t.id
WHERE et.entity_type = %s AND et.entity_id = %s
ORDER BY t.type, t.name
"""
results = execute_query(query, (entity_type, entity_id))
return results
return [_tag_row_to_response(row) for row in (results or [])]
@router.get("/entity/{entity_type}/{entity_id}/suggestions")
async def suggest_entity_tags(entity_type: str, entity_id: int):
"""Suggest tags based on catch words for brand/type tags."""
if entity_type != "case":
return []
case_row = execute_query_single(
"SELECT id, titel, beskrivelse, template_key FROM sag_sager WHERE id = %s",
(entity_id,),
)
if not case_row:
raise HTTPException(status_code=404, detail="Entity not found")
existing_rows = execute_query(
"SELECT tag_id FROM entity_tags WHERE entity_type = %s AND entity_id = %s",
(entity_type, entity_id),
) or []
existing_tag_ids = {int(row.get("tag_id")) for row in existing_rows if row.get("tag_id") is not None}
candidate_rows = execute_query(
"""
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE is_active = true
AND type IN ('brand', 'type')
ORDER BY type, name
""",
(),
) or []
haystack = " ".join(
[
str(case_row.get("titel") or ""),
str(case_row.get("beskrivelse") or ""),
str(case_row.get("template_key") or ""),
]
).lower()
suggestions = []
for row in candidate_rows:
tag_id = int(row.get("id"))
if tag_id in existing_tag_ids:
continue
catch_words = _normalize_catch_words(row.get("catch_words"))
if not catch_words:
continue
matched_words = [word for word in catch_words if word in haystack]
if not matched_words:
continue
suggestions.append(
{
"tag": _tag_row_to_response(row),
"matched_words": matched_words,
"score": len(matched_words),
}
)
suggestions.sort(key=lambda item: (-item["score"], item["tag"]["type"], item["tag"]["name"]))
return suggestions
@router.get("/search")
async def search_tags(q: str, type: Optional[TagType] = None):
"""Search tags by name (fuzzy search)"""
query = """
SELECT * FROM tags
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE is_active = true
AND LOWER(name) LIKE LOWER(%s)
"""
@ -240,7 +647,7 @@ async def search_tags(q: str, type: Optional[TagType] = None):
query += " ORDER BY name LIMIT 20"
results = execute_query(query, tuple(params))
return results
return [_tag_row_to_response(row) for row in (results or [])]
# ============= WORKFLOW MANAGEMENT =============

View File

@ -1,11 +1,8 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tag Administration - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
{% extends "shared/frontend/base.html" %}
{% block title %}Tag Administration - BMC Hub{% endblock %}
{% block extra_css %}
<style>
:root {
--primary-color: #0f4c75;
@ -14,6 +11,8 @@
--category-color: #0f4c75;
--priority-color: #dc3545;
--billing-color: #2d6a4f;
--brand-color: #006d77;
--type-color: #5c677d;
}
.tag-badge {
@ -37,6 +36,8 @@
.tag-type-category { background-color: var(--category-color); color: white; }
.tag-type-priority { background-color: var(--priority-color); color: white; }
.tag-type-billing { background-color: var(--billing-color); color: white; }
.tag-type-brand { background-color: var(--brand-color); color: white; }
.tag-type-type { background-color: var(--type-color); color: white; }
.tag-list-item {
padding: 1rem;
@ -53,6 +54,8 @@
.tag-list-item[data-type="category"] { border-left-color: var(--category-color); }
.tag-list-item[data-type="priority"] { border-left-color: var(--priority-color); }
.tag-list-item[data-type="billing"] { border-left-color: var(--billing-color); }
.tag-list-item[data-type="brand"] { border-left-color: var(--brand-color); }
.tag-list-item[data-type="type"] { border-left-color: var(--type-color); }
.color-preview {
width: 40px;
@ -60,9 +63,68 @@
border-radius: 8px;
border: 2px solid #dee2e6;
}
.section-tabs .nav-link {
color: var(--primary-color);
font-weight: 600;
}
.section-tabs .nav-link.active {
background-color: var(--primary-color);
color: #fff;
border-color: var(--primary-color);
}
.module-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
background: #e7f1f8;
color: #0b3552;
border: 1px solid #c7dceb;
}
.usage-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: #fff;
white-space: nowrap;
}
.usage-table .filter-cell {
min-width: 160px;
}
.usage-sort-btn {
border: 0;
background: transparent;
color: inherit;
font-weight: 600;
padding: 0;
}
.usage-sort-btn .bi {
font-size: 0.75rem;
opacity: 0.55;
}
.usage-sort-btn.active .bi {
opacity: 1;
}
@media (max-width: 991px) {
.usage-table .filter-cell {
min-width: 130px;
}
}
</style>
</head>
<body>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col">
@ -76,6 +138,17 @@
</div>
</div>
<ul class="nav nav-pills section-tabs mb-4" id="sectionTabs">
<li class="nav-item">
<button type="button" class="nav-link active" data-section="admin">Tag administration</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" data-section="search">Tag søgning</button>
</li>
</ul>
<div id="tagAdminSection">
<!-- Type Filter Tabs -->
<ul class="nav nav-tabs mb-4" id="typeFilter">
<li class="nav-item">
@ -106,6 +179,16 @@
<span class="tag-badge tag-type-billing">Billing</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="brand">
<span class="tag-badge tag-type-brand">Brand</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="type">
<span class="tag-badge tag-type-type">Type</span>
</a>
</li>
</ul>
<!-- Tags List -->
@ -120,6 +203,98 @@
</div>
</div>
</div>
</div>
<div id="tagSearchSection" class="d-none">
<div class="card mb-3">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1">Tag søgning på tværs af moduler</h5>
<p class="text-muted mb-0 small">Filtrer efter tag-navn, type og modul. Hver række viser tydeligt hvilket modul tagningen kommer fra.</p>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" id="resetUsageFiltersBtn">
<i class="bi bi-arrow-counterclockwise"></i> Nulstil filtre
</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle usage-table mb-2">
<thead>
<tr>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="tag_name">
Tag <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="tag_type">
Type <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="module">
Modul <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>Objekt</th>
<th>Entity type</th>
<th>
<button type="button" class="usage-sort-btn" data-sort-by="entity_id">
Entity ID <i class="bi bi-chevron-expand"></i>
</button>
</th>
<th>
<button type="button" class="usage-sort-btn active" data-sort-by="tagged_at">
Tagget <i class="bi bi-sort-down"></i>
</button>
</th>
</tr>
<tr>
<th class="filter-cell">
<input id="usageFilterTagName" type="search" class="form-control form-control-sm" placeholder="Søg tag-navn">
</th>
<th class="filter-cell">
<select id="usageFilterTagType" class="form-select form-select-sm">
<option value="">Alle typer</option>
<option value="workflow">workflow</option>
<option value="status">status</option>
<option value="category">category</option>
<option value="priority">priority</option>
<option value="billing">billing</option>
<option value="brand">brand</option>
<option value="type">type</option>
</select>
</th>
<th class="filter-cell">
<select id="usageFilterModule" class="form-select form-select-sm">
<option value="">Alle moduler</option>
</select>
</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody id="usageTableBody">
<tr>
<td colspan="7" class="text-center text-muted py-4">Indlæser...</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="small text-muted" id="usageSummary">-</div>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" id="usagePrevBtn">Forrige</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="usageNextBtn">Næste</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Tag Modal -->
@ -148,9 +323,17 @@
<option value="category">Category - Emne/område</option>
<option value="priority">Priority - Hastighed</option>
<option value="billing">Billing - Økonomi</option>
<option value="brand">Brand - Leverandør/produktbrand</option>
<option value="type">Type - Sagstype/arbejdstype</option>
</select>
</div>
<div class="mb-3">
<label for="tagCatchWords" class="form-label">Catch words</label>
<textarea class="form-control" id="tagCatchWords" rows="3" placeholder="fx: office 365, outlook, smtp"></textarea>
<small class="text-muted">Brug komma eller ny linje mellem ord. Bruges til auto-forslag på sager.</small>
</div>
<div class="mb-3">
<label for="tagDescription" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="tagDescription" rows="3"></textarea>
@ -186,19 +369,59 @@
</div>
</div>
</div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}
<script>
let allTags = [];
let currentFilter = 'all';
let usageDebounceTimer = null;
const usageState = {
filters: {
tag_name: '',
tag_type: '',
module: ''
},
page: 1,
page_size: 25,
sort_by: 'tagged_at',
sort_dir: 'desc',
total: 0,
total_pages: 0
};
// Load tags on page load
document.addEventListener('DOMContentLoaded', () => {
loadTags();
loadTagUsage();
setupEventListeners();
const initialSection = window.location.hash === '#search' ? 'search' : 'admin';
switchTagSection(initialSection, false);
});
function switchTagSection(section, updateHash = true) {
const normalized = section === 'search' ? 'search' : 'admin';
document.querySelectorAll('#sectionTabs .nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.section === normalized);
});
document.getElementById('tagAdminSection').classList.toggle('d-none', normalized !== 'admin');
document.getElementById('tagSearchSection').classList.toggle('d-none', normalized !== 'search');
if (updateHash) {
const hash = normalized === 'search' ? '#search' : '#admin';
window.history.replaceState(null, '', hash);
}
}
function setupEventListeners() {
// Section tabs
document.querySelectorAll('#sectionTabs button').forEach(btn => {
btn.addEventListener('click', () => {
switchTagSection(btn.dataset.section);
});
});
// Type filter tabs
document.querySelectorAll('#typeFilter a').forEach(tab => {
tab.addEventListener('click', (e) => {
@ -229,7 +452,9 @@
'status': '#ffd700',
'category': '#0f4c75',
'priority': '#dc3545',
'billing': '#2d6a4f'
'billing': '#2d6a4f',
'brand': '#006d77',
'type': '#5c677d'
};
if (colorMap[type]) {
document.getElementById('tagColor').value = colorMap[type];
@ -240,6 +465,61 @@
// Save button
document.getElementById('saveTagBtn').addEventListener('click', saveTag);
// Usage filters
document.getElementById('usageFilterTagName').addEventListener('input', () => {
usageState.filters.tag_name = document.getElementById('usageFilterTagName').value.trim();
usageState.page = 1;
debounceUsageLoad();
});
document.getElementById('usageFilterTagType').addEventListener('change', () => {
usageState.filters.tag_type = document.getElementById('usageFilterTagType').value;
usageState.page = 1;
loadTagUsage();
});
document.getElementById('usageFilterModule').addEventListener('change', () => {
usageState.filters.module = document.getElementById('usageFilterModule').value;
usageState.page = 1;
loadTagUsage();
});
document.getElementById('resetUsageFiltersBtn').addEventListener('click', () => {
usageState.filters = { tag_name: '', tag_type: '', module: '' };
usageState.page = 1;
document.getElementById('usageFilterTagName').value = '';
document.getElementById('usageFilterTagType').value = '';
document.getElementById('usageFilterModule').value = '';
loadTagUsage();
});
document.getElementById('usagePrevBtn').addEventListener('click', () => {
if (usageState.page > 1) {
usageState.page -= 1;
loadTagUsage();
}
});
document.getElementById('usageNextBtn').addEventListener('click', () => {
if (usageState.page < usageState.total_pages) {
usageState.page += 1;
loadTagUsage();
}
});
document.querySelectorAll('.usage-sort-btn').forEach(btn => {
btn.addEventListener('click', () => {
const sortBy = btn.dataset.sortBy;
if (usageState.sort_by === sortBy) {
usageState.sort_dir = usageState.sort_dir === 'asc' ? 'desc' : 'asc';
} else {
usageState.sort_by = sortBy;
usageState.sort_dir = sortBy === 'tagged_at' ? 'desc' : 'asc';
}
usageState.page = 1;
updateSortIndicators();
loadTagUsage();
});
});
// Modal reset on close
document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => {
document.getElementById('tagForm').reset();
@ -264,6 +544,131 @@
}
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function debounceUsageLoad() {
if (usageDebounceTimer) {
clearTimeout(usageDebounceTimer);
}
usageDebounceTimer = setTimeout(() => loadTagUsage(), 280);
}
function updateSortIndicators() {
document.querySelectorAll('.usage-sort-btn').forEach(btn => {
const icon = btn.querySelector('i');
if (!icon) return;
btn.classList.remove('active');
icon.className = 'bi bi-chevron-expand';
if (btn.dataset.sortBy === usageState.sort_by) {
btn.classList.add('active');
icon.className = usageState.sort_dir === 'asc' ? 'bi bi-sort-up' : 'bi bi-sort-down';
}
});
}
function renderUsageTable(items) {
const tbody = document.getElementById('usageTableBody');
if (!Array.isArray(items) || !items.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Ingen taggede rækker matcher filtrene.</td></tr>';
return;
}
tbody.innerHTML = items.map(row => {
const taggedAt = row.tagged_at ? new Date(row.tagged_at).toLocaleString('da-DK') : '-';
const color = /^#[0-9A-Fa-f]{6}$/.test(String(row.tag_color || '')) ? row.tag_color : '#0f4c75';
const inactiveBadge = row.tag_is_active ? '' : '<span class="badge bg-secondary ms-2">Inaktiv</span>';
const entityTitle = escapeHtml(row.entity_title || `#${row.entity_id || ''}`);
const entityCell = row.entity_url
? `<a href="${escapeHtml(row.entity_url)}" class="text-decoration-none fw-semibold">${entityTitle}</a>`
: `<span class="fw-semibold">${entityTitle}</span>`;
return `
<tr>
<td>
<span class="tag-badge" style="background:${color}; color:#fff; margin:0;">${escapeHtml(row.tag_name)}</span>
${inactiveBadge}
</td>
<td><span class="badge bg-light text-dark text-uppercase">${escapeHtml(row.tag_type)}</span></td>
<td><span class="module-badge"><i class="bi bi-box"></i>${escapeHtml(row.module)}</span></td>
<td>${entityCell}</td>
<td><span class="text-muted">${escapeHtml(row.entity_type)}</span></td>
<td><strong>#${escapeHtml(row.entity_id)}</strong></td>
<td class="small text-muted">${escapeHtml(taggedAt)}</td>
</tr>
`;
}).join('');
}
function renderUsageSummary() {
const summary = document.getElementById('usageSummary');
const prevBtn = document.getElementById('usagePrevBtn');
const nextBtn = document.getElementById('usageNextBtn');
const total = usageState.total;
const page = usageState.page;
const pageSize = usageState.page_size;
const from = total ? ((page - 1) * pageSize + 1) : 0;
const to = total ? Math.min(page * pageSize, total) : 0;
summary.textContent = `Viser ${from}-${to} af ${total} rækker`;
prevBtn.disabled = page <= 1;
nextBtn.disabled = page >= usageState.total_pages;
}
function fillModuleFilter(options) {
const select = document.getElementById('usageFilterModule');
const currentValue = usageState.filters.module;
const base = '<option value="">Alle moduler</option>';
const rows = (options || []).map(option => {
return `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`;
}).join('');
select.innerHTML = `${base}${rows}`;
select.value = currentValue || '';
}
async function loadTagUsage() {
const tbody = document.getElementById('usageTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Indlæser...</td></tr>';
try {
const params = new URLSearchParams({
page: String(usageState.page),
page_size: String(usageState.page_size),
sort_by: usageState.sort_by,
sort_dir: usageState.sort_dir
});
if (usageState.filters.tag_name) params.set('tag_name', usageState.filters.tag_name);
if (usageState.filters.tag_type) params.set('tag_type', usageState.filters.tag_type);
if (usageState.filters.module) params.set('module', usageState.filters.module);
const response = await fetch(`/api/v1/tags/usage?${params.toString()}`);
if (!response.ok) {
throw new Error('Kunne ikke hente tag søgning');
}
const payload = await response.json();
usageState.total = Number(payload?.pagination?.total || 0);
usageState.total_pages = Number(payload?.pagination?.total_pages || 0);
usageState.page = Number(payload?.pagination?.page || usageState.page);
fillModuleFilter(payload.module_options || []);
renderUsageTable(payload.items || []);
renderUsageSummary();
updateSortIndicators();
} catch (error) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">Fejl ved indlæsning af tag søgning: ${escapeHtml(error.message)}</td></tr>`;
document.getElementById('usageSummary').textContent = 'Fejl ved datahentning';
}
}
function renderTags() {
const container = document.getElementById('tagsList');
const filteredTags = currentFilter === 'all'
@ -293,6 +698,7 @@
${!tag.is_active ? '<span class="badge bg-secondary ms-2">Inaktiv</span>' : ''}
</div>
${tag.description ? `<p class="text-muted mb-0 small">${tag.description}</p>` : ''}
${Array.isArray(tag.catch_words) && tag.catch_words.length ? `<p class="mb-0 mt-1"><small class="text-muted">Catch words: ${tag.catch_words.join(', ')}</small></p>` : ''}
</div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})">
@ -315,7 +721,11 @@
description: document.getElementById('tagDescription').value || null,
color: document.getElementById('tagColorHex').value,
icon: document.getElementById('tagIcon').value || null,
is_active: document.getElementById('tagActive').checked
is_active: document.getElementById('tagActive').checked,
catch_words: document.getElementById('tagCatchWords').value
.split(/[\n,]+/)
.map(v => v.trim().toLowerCase())
.filter(v => v.length > 1)
};
try {
@ -352,6 +762,7 @@
document.getElementById('tagColorHex').value = tag.color;
document.getElementById('tagIcon').value = tag.icon || '';
document.getElementById('tagActive').checked = tag.is_active;
document.getElementById('tagCatchWords').value = Array.isArray(tag.catch_words) ? tag.catch_words.join(', ') : '';
document.querySelector('#createTagModal .modal-title').textContent = 'Rediger Tag';
new bootstrap.Modal(document.getElementById('createTagModal')).show();
@ -374,5 +785,4 @@
}
}
</script>
</body>
</html>
{% endblock %}

View File

@ -11,7 +11,7 @@ import json
import re
import asyncio
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import JSONResponse
from app.ticket.backend.ticket_service import TicketService
@ -55,11 +55,13 @@ from app.ticket.backend.models import (
TicketDeadlineUpdateRequest
)
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.auth_dependencies import require_any_permission
from datetime import date, datetime
logger = logging.getLogger(__name__)
router = APIRouter()
sync_admin_access = require_any_permission("users.manage", "system.admin")
def _get_first_value(data: dict, keys: List[str]) -> Optional[str]:
@ -127,6 +129,31 @@ def _escape_simply_value(value: str) -> str:
return value.replace("'", "''")
def _extract_count_value(rows: List[dict]) -> Optional[int]:
if not rows:
return None
row = rows[0] or {}
if not isinstance(row, dict):
return None
for key in ("total_count", "count", "count(*)", "COUNT(*)"):
value = row.get(key)
if value is not None:
try:
return int(value)
except (TypeError, ValueError):
continue
for value in row.values():
try:
return int(value)
except (TypeError, ValueError):
continue
return None
async def _vtiger_query_with_retry(vtiger, query_string: str, retries: int = 5, base_delay: float = 1.25) -> List[dict]:
"""Run vTiger query with exponential backoff on rate-limit responses."""
for attempt in range(retries + 1):
@ -1825,7 +1852,8 @@ async def import_simply_archived_tickets(
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
include_messages: bool = Query(True, description="Include comments and emails"),
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
force: bool = Query(False, description="Update even if sync hash matches")
force: bool = Query(False, description="Update even if sync hash matches"),
current_user: dict = Depends(sync_admin_access)
):
"""
One-time import of archived tickets from Simply-CRM.
@ -2157,7 +2185,8 @@ async def import_vtiger_archived_tickets(
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
include_messages: bool = Query(True, description="Include comments and emails"),
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
force: bool = Query(False, description="Update even if sync hash matches")
force: bool = Query(False, description="Update even if sync hash matches"),
current_user: dict = Depends(sync_admin_access)
):
"""
One-time import of archived tickets from vTiger (Cases module).
@ -2493,8 +2522,93 @@ async def import_vtiger_archived_tickets(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/archived/status", tags=["Archived Tickets"])
async def get_archived_sync_status(current_user: dict = Depends(sync_admin_access)):
"""
Return archived sync parity status for Simply-CRM and vTiger.
"""
source_keys = ("simplycrm", "vtiger")
sources: dict[str, dict] = {}
for source_key in source_keys:
local_ticket_row = execute_query_single(
"""
SELECT COUNT(*) AS total_tickets,
MAX(last_synced_at) AS last_synced_at
FROM tticket_archived_tickets
WHERE source_system = %s
""",
(source_key,)
) or {}
local_message_row = execute_query_single(
"""
SELECT COUNT(*) AS total_messages
FROM tticket_archived_messages m
INNER JOIN tticket_archived_tickets t ON t.id = m.archived_ticket_id
WHERE t.source_system = %s
""",
(source_key,)
) or {}
local_tickets = int(local_ticket_row.get("total_tickets") or 0)
local_messages = int(local_message_row.get("total_messages") or 0)
last_synced_value = local_ticket_row.get("last_synced_at")
if isinstance(last_synced_value, (datetime, date)):
last_synced_at_iso = last_synced_value.isoformat()
else:
last_synced_at_iso = None
sources[source_key] = {
"remote_total_tickets": None,
"local_total_tickets": local_tickets,
"local_total_messages": local_messages,
"last_synced_at": last_synced_at_iso,
"diff": None,
"is_synced": False,
"error": None,
}
try:
async with SimplyCRMService() as service:
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "Tickets")
simply_rows = await service.query(f"SELECT count(*) AS total_count FROM {module_name};")
simply_remote_count = _extract_count_value(simply_rows)
sources["simplycrm"]["remote_total_tickets"] = simply_remote_count
if simply_remote_count is not None:
sources["simplycrm"]["diff"] = simply_remote_count - sources["simplycrm"]["local_total_tickets"]
sources["simplycrm"]["is_synced"] = sources["simplycrm"]["diff"] == 0
elif service.last_query_error:
sources["simplycrm"]["error"] = service.last_query_error.get("message") or str(service.last_query_error)
except Exception as e:
logger.warning("⚠️ Simply-CRM archived status check failed: %s", e)
sources["simplycrm"]["error"] = str(e)
try:
vtiger = get_vtiger_service()
vtiger_rows = await _vtiger_query_with_retry(vtiger, "SELECT count(*) AS total_count FROM Cases;")
vtiger_remote_count = _extract_count_value(vtiger_rows)
sources["vtiger"]["remote_total_tickets"] = vtiger_remote_count
if vtiger_remote_count is not None:
sources["vtiger"]["diff"] = vtiger_remote_count - sources["vtiger"]["local_total_tickets"]
sources["vtiger"]["is_synced"] = sources["vtiger"]["diff"] == 0
elif vtiger.last_query_error:
sources["vtiger"]["error"] = vtiger.last_query_error.get("message") or str(vtiger.last_query_error)
except Exception as e:
logger.warning("⚠️ vTiger archived status check failed: %s", e)
sources["vtiger"]["error"] = str(e)
overall_synced = all(sources[key].get("is_synced") is True for key in source_keys)
return {
"checked_at": datetime.utcnow().isoformat(),
"overall_synced": overall_synced,
"sources": sources,
}
@router.get("/archived/simply/modules", tags=["Archived Tickets"])
async def list_simply_modules():
async def list_simply_modules(current_user: dict = Depends(sync_admin_access)):
"""
List available Simply-CRM modules (debug helper).
"""
@ -2510,7 +2624,8 @@ async def list_simply_modules():
@router.get("/archived/simply/ticket", tags=["Archived Tickets"])
async def fetch_simply_ticket(
ticket_number: Optional[str] = Query(None, description="Ticket number, e.g. TT934"),
external_id: Optional[str] = Query(None, description="VTiger record ID, e.g. 17x1234")
external_id: Optional[str] = Query(None, description="VTiger record ID, e.g. 17x1234"),
current_user: dict = Depends(sync_admin_access)
):
"""
Fetch a single HelpDesk ticket from Simply-CRM by ticket number or record id.
@ -2544,7 +2659,8 @@ async def fetch_simply_ticket(
@router.get("/archived/simply/record", tags=["Archived Tickets"])
async def fetch_simply_record(
record_id: str = Query(..., description="VTiger record ID, e.g. 11x2601"),
module: Optional[str] = Query(None, description="Optional module name for context")
module: Optional[str] = Query(None, description="Optional module name for context"),
current_user: dict = Depends(sync_admin_access)
):
"""
Fetch a single record from Simply-CRM by record id.

View File

@ -2,6 +2,21 @@
{% block title %}Tekniker Dashboard V1 - Overblik{% endblock %}
{% block extra_css %}
<style>
#caseTable thead th {
white-space: nowrap;
font-size: 0.78rem;
letter-spacing: 0.02em;
}
#caseTable tbody td {
font-size: 0.84rem;
vertical-align: top;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
@ -65,15 +80,22 @@
<table class="table table-sm table-hover mb-0" id="caseTable">
<thead class="table-light" id="tableHead">
<tr>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Status</th>
<th>Dato</th>
<th>SagsID</th>
<th>Virksom.</th>
<th>Kontakt</th>
<th>Beskr.</th>
<th>Type</th>
<th>Prioritet</th>
<th>Ansvarl.</th>
<th>Gruppe/Level</th>
<th>Opret.</th>
<th>Start arbejde</th>
<th>Start inden</th>
<th>Deadline</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
</tbody>
</table>
</div>
@ -167,8 +189,16 @@ const allData = {
{
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
@ -179,7 +209,16 @@ const allData = {
{
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
@ -191,9 +230,16 @@ const allData = {
item_type: {{ item.item_type | tojson | safe }},
item_id: {{ item.item_id }},
title: {{ item.title | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
task_reason: {{ item.task_reason | tojson | safe if item.task_reason else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }}
}{% if not loop.last %},{% endif %}
@ -205,7 +251,16 @@ const allData = {
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
group_name: {{ item.group_name | tojson | safe }},
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
customer_name: {{ item.customer_name | tojson | safe }},
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
@ -225,6 +280,32 @@ function formatShortDate(dateStr) {
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function renderCaseTableRow(item, idField = 'id', typeField = 'case') {
const itemId = item[idField];
const openType = typeField === 'item_type' ? item.item_type : 'case';
const description = item.beskrivelse || item.titel || item.title || '-';
const typeValue = item.case_type || item.item_type || '-';
const groupLevel = item.assigned_group_name || item.group_name || '-';
const priorityValue = item.priority || 'normal';
return `
<tr onclick="showCaseDetails(${itemId}, '${openType}')" style="cursor:pointer;">
<td>#${itemId}</td>
<td>${item.customer_name || '-'}</td>
<td>${item.kontakt_navn || '-'}</td>
<td>${description}</td>
<td>${typeValue}</td>
<td>${priorityValue}</td>
<td>${item.ansvarlig_navn || '-'}</td>
<td>${groupLevel}</td>
<td>${formatShortDate(item.created_at)}</td>
<td>${formatShortDate(item.start_date)}</td>
<td>${formatShortDate(item.deferred_until)}</td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`;
}
function toggleSection(filterName) {
const kpiCard = document.getElementById('kpi' + filterName.charAt(0).toUpperCase() + filterName.slice(1));
const listTitle = document.getElementById('listTitle');
@ -242,7 +323,7 @@ function toggleSection(filterName) {
if (currentFilter === filterName) {
currentFilter = null;
listTitle.textContent = 'Alle sager';
tableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
tableBody.innerHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
return;
}
@ -266,70 +347,43 @@ function filterAndPopulateTable(filterName) {
listTitle.innerHTML = '<i class="bi bi-inbox-fill text-primary"></i> Nye sager';
const data = allData.newCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-secondary">${item.status || 'Ny'}</span></td>
<td>${formatDate(item.created_at)}</td>
</tr>
`).join('');
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
}
} else if (filterName === 'myCases') {
listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager';
const data = allData.myCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
}
} else if (filterName === 'todayTasks') {
listTitle.innerHTML = '<i class="bi bi-calendar-check text-primary"></i> Dagens opgaver';
const data = allData.todayTasks || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
} else {
bodyHTML = data.map(item => {
const badge = item.item_type === 'case'
? '<span class="badge bg-primary">Sag</span>'
: '<span class="badge bg-info">Ticket</span>';
return `
<tr onclick="showCaseDetails(${item.item_id}, '${item.item_type}')" style="cursor:pointer;">
<td>#${item.item_id}</td>
<td>${item.title || '-'}<br><small class="text-muted">${item.task_reason || ''}</small></td>
<td>${item.customer_name || '-'}</td>
<td>${badge}</td>
<td>${formatDate(item.created_at)}</td>
</tr>
`;
const normalized = {
...item,
id: item.item_id,
titel: item.title,
beskrivelse: item.task_reason || item.beskrivelse,
deadline: item.deadline || item.due_at,
case_type: item.case_type || item.item_type
};
return renderCaseTableRow(normalized, 'id', 'item_type');
}).join('');
}
} else if (filterName === 'groupCases') {
listTitle.innerHTML = '<i class="bi bi-people-fill text-info"></i> Gruppe-sager';
const data = allData.groupCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}<br><span class="badge bg-secondary">${item.group_name || '-'}</span></td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
}
}

View File

@ -86,14 +86,38 @@
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Kunde</th><th>Oprettet</th></tr></thead>
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Virksom.</th>
<th>Kontakt</th>
<th>Beskr.</th>
<th>Type</th>
<th>Ansvarl.</th>
<th>Gruppe/Level</th>
<th>Opret.</th>
<th>Start arbejde</th>
<th>Start inden</th>
<th>Deadline</th>
</tr>
</thead>
<tbody>
{% for item in new_cases %}
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
<td>#{{ item.id }}</td><td>{{ item.titel }}</td><td>{{ item.customer_name }}</td><td>{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}</td>
<td>#{{ item.id }}</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
<td>{{ item.beskrivelse or item.titel or '-' }}</td>
<td>{{ item.case_type or '-' }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-3">Ingen nye sager</td></tr>
<tr><td colspan="11" class="text-center text-muted py-3">Ingen nye sager</td></tr>
{% endfor %}
</tbody>
</table>

View File

@ -32,59 +32,71 @@
<table class="table table-hover table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Virksom.</th>
<th>Kontakt</th>
<th>Beskr.</th>
<th>Type</th>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Status</th>
<th>Prioritet/Reason</th>
<th>Ansvarl.</th>
<th>Gruppe/Level</th>
<th>Opret.</th>
<th>Start arbejde</th>
<th>Start inden</th>
<th>Deadline</th>
<th>Handling</th>
</tr>
</thead>
<tbody>
{% for item in urgent_overdue %}
<tr>
<td><span class="badge bg-danger">Haste</span></td>
<td>#{{ item.item_id }}</td>
<td>{{ item.title }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>{{ item.attention_reason }}</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
<td>{{ item.beskrivelse or item.title or '-' }}</td>
<td>{{ item.case_type or item.item_type or '-' }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-danger">Åbn</a></td>
</tr>
{% endfor %}
{% for item in today_tasks %}
<tr>
<td><span class="badge bg-primary">I dag</span></td>
<td>#{{ item.item_id }}</td>
<td>{{ item.title }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>{{ item.task_reason }}</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
<td>{{ item.beskrivelse or item.title or item.task_reason or '-' }}</td>
<td>{{ item.case_type or item.item_type or '-' }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-outline-primary">Åbn</a></td>
</tr>
{% endfor %}
{% for item in my_cases %}
<tr>
<td><span class="badge bg-secondary">Min sag</span></td>
<td>#{{ item.id }}</td>
<td>{{ item.titel }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>-</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
<td>{{ item.beskrivelse or item.titel or '-' }}</td>
<td>{{ item.case_type or '-' }}</td>
<td>{{ item.ansvarlig_navn or '-' }}</td>
<td>{{ item.assigned_group_name or '-' }}</td>
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
<td><a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary">Åbn</a></td>
</tr>
{% endfor %}
{% if not urgent_overdue and not today_tasks and not my_cases %}
<tr>
<td colspan="8" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td>
<td colspan="11" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td>
</tr>
{% endif %}
</tbody>

View File

@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates
from typing import Optional, Dict, Any
from datetime import date
from app.core.database import execute_query, execute_update, execute_query_single
from app.core.database import execute_query, execute_update, execute_query_single, table_has_column
logger = logging.getLogger(__name__)
@ -18,6 +18,20 @@ router = APIRouter()
templates = Jinja2Templates(directory="app")
def _case_start_date_sql(alias: str = "s") -> str:
"""Select start_date only when the live schema actually has it."""
if table_has_column("sag_sager", "start_date"):
return f"{alias}.start_date"
return "NULL::date AS start_date"
def _case_type_sql(alias: str = "s") -> str:
"""Select case type across old/new sag schemas."""
if table_has_column("sag_sager", "type"):
return f"COALESCE({alias}.template_key, {alias}.type, 'ticket') AS case_type"
return f"COALESCE({alias}.template_key, 'ticket') AS case_type"
@router.get("/", include_in_schema=False)
async def ticket_root_redirect():
return RedirectResponse(url="/sag", status_code=302)
@ -362,6 +376,8 @@ async def new_ticket_page(request: Request):
def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""Collect live data slices for technician-focused dashboard variants."""
case_start_date_sql = _case_start_date_sql()
case_type_sql = _case_type_sql()
user_query = """
SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
FROM users
@ -371,16 +387,34 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
user_result = execute_query(user_query, (technician_user_id,))
technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}"
new_cases_query = """
new_cases_query = f"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.priority,
s.status,
s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
{case_type_sql},
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.status = 'åben'
ORDER BY s.created_at DESC
@ -388,16 +422,34 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""
new_cases = execute_query(new_cases_query)
my_cases_query = """
my_cases_query = f"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.priority,
s.status,
s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
{case_type_sql},
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket'
@ -406,19 +458,36 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""
my_cases = execute_query(my_cases_query, (technician_user_id,))
today_tasks_query = """
today_tasks_query = f"""
SELECT
'case' AS item_type,
s.id AS item_id,
s.titel AS title,
s.beskrivelse,
s.status,
s.deadline AS due_at,
s.created_at,
{case_start_date_sql},
s.deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
NULL::text AS priority,
{case_type_sql},
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
COALESCE(s.priority::text, 'normal') AS priority,
'Sag deadline i dag' AS task_reason
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.ansvarlig_bruger_id = %s
AND s.status <> 'lukket'
@ -430,14 +499,22 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
'ticket' AS item_type,
t.id AS item_id,
t.subject AS title,
NULL::text AS beskrivelse,
t.status,
NULL::date AS due_at,
t.created_at,
NULL::date AS start_date,
NULL::date AS deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
'ticket' AS case_type,
NULL::text AS kontakt_navn,
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
COALESCE(t.priority, 'normal') AS priority,
'Ticket oprettet i dag' AS task_reason
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
WHERE t.assigned_to_user_id = %s
AND t.status IN ('open', 'in_progress', 'pending_customer')
AND DATE(t.created_at) = CURRENT_DATE
@ -447,19 +524,36 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""
today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id))
urgent_overdue_query = """
urgent_overdue_query = f"""
SELECT
'case' AS item_type,
s.id AS item_id,
s.titel AS title,
s.beskrivelse,
s.status,
s.deadline AS due_at,
s.created_at,
{case_start_date_sql},
s.deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
{case_type_sql},
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
NULL::text AS priority,
'Over deadline' AS attention_reason
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.status <> 'lukket'
AND s.deadline IS NOT NULL
@ -471,10 +565,17 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
'ticket' AS item_type,
t.id AS item_id,
t.subject AS title,
NULL::text AS beskrivelse,
t.status,
NULL::date AS due_at,
t.created_at,
NULL::date AS start_date,
NULL::date AS deferred_until,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
'ticket' AS case_type,
NULL::text AS kontakt_navn,
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
COALESCE(t.priority, 'normal') AS priority,
CASE
WHEN t.priority = 'urgent' THEN 'Urgent prioritet'
@ -482,6 +583,7 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
END AS attention_reason
FROM tticket_tickets t
LEFT JOIN customers c ON c.id = t.customer_id
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
WHERE t.status IN ('open', 'in_progress', 'pending_customer')
AND COALESCE(t.priority, '') IN ('urgent', 'high')
AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL)
@ -542,19 +644,36 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
# Get group cases (cases assigned to user's groups)
group_cases = []
if user_group_ids:
group_cases_query = """
group_cases_query = f"""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.priority,
s.status,
s.created_at,
{case_start_date_sql},
s.deferred_until,
s.deadline,
{case_type_sql},
s.assigned_group_id,
g.name AS group_name,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
WHERE s.deleted_at IS NULL
AND s.assigned_group_id = ANY(%s)
AND s.status <> 'lukket'

12
apply_layout.py Normal file
View 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.")

View File

@ -0,0 +1,37 @@
# Email Feature Backup
Backup artifact for current email handling implementation.
## Artifact
- `email_feature_backup_20260317_214413.zip`
## Contents
- `app/emails/`
- `app/services/email_service.py`
- `app/services/email_processor_service.py`
- `app/services/email_analysis_service.py`
- `app/services/email_workflow_service.py`
- `app/services/email_activity_logger.py`
- `app/modules/sag/templates/detail.html`
- `migrations/013_email_system.sql`
- `migrations/014_email_workflows.sql`
- `migrations/050_email_activity_log.sql`
- `migrations/056_email_import_method.sql`
- `migrations/084_sag_files_and_emails.sql`
- `migrations/140_email_extracted_vendor_fields.sql`
- `migrations/141_email_threading_headers.sql`
## Restore (code only)
From repository root:
```bash
unzip -o backups/email_feature/email_feature_backup_20260317_214413.zip -d .
```
## Notes
- This restore only replaces code files included in the artifact.
- Database rollback must be handled separately if schema/data has changed.

View 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
View 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
View 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
View 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
View 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, vis den
if (hasContent) {
setVisibility(true);
el.classList.remove('module-empty-compact');
return;
}
// Default logic - ingen content: se 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
View 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
View 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()

56
main.py
View File

@ -16,6 +16,29 @@ from app.core.database import init_db
from app.core.auth_service import AuthService
from app.core.database import execute_query_single
_users_column_cache: dict[str, bool] = {}
def _users_column_exists(column_name: str) -> bool:
if column_name in _users_column_cache:
return _users_column_cache[column_name]
result = execute_query_single(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = %s
LIMIT 1
""",
(column_name,),
)
exists = bool(result)
_users_column_cache[column_name] = exists
return exists
def get_version():
"""Read version from VERSION file"""
try:
@ -206,6 +229,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 +248,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
@ -255,11 +288,16 @@ async def auth_middleware(request: Request, call_next):
content={"detail": "Invalid token"}
)
user_id = int(payload.get("sub"))
user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,)
)
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
if _users_column_exists("is_2fa_enabled"):
user = execute_query_single(
"SELECT COALESCE(is_2fa_enabled, FALSE) AS is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,),
)
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
else:
# Older schemas without 2FA columns should not block authenticated requests.
is_2fa_enabled = False
if not is_2fa_enabled:
allowed_2fa_paths = (
@ -386,5 +424,9 @@ if __name__ == "__main__":
"main:app",
host="0.0.0.0",
port=8000,
reload=False
reload=False,
workers=2,
timeout_keep_alive=65,
access_log=True,
log_level="info"
)

View File

@ -37,7 +37,7 @@ CREATE TABLE email_rules (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_user_id INTEGER,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) ON DELETE SET NULL
);
-- Email Messages Table (main storage)
@ -183,7 +183,7 @@ SELECT
COUNT(ea.id) as attachment_count_actual,
er.name as rule_name,
v.name as supplier_name,
tc.customer_name,
tc.name as customer_name,
tcase.title as case_title
FROM email_messages em
LEFT JOIN email_attachments ea ON em.id = ea.email_id
@ -193,7 +193,7 @@ LEFT JOIN tmodule_customers tc ON em.customer_id = tc.id
LEFT JOIN tmodule_cases tcase ON em.linked_case_id = tcase.id
WHERE em.deleted_at IS NULL
AND em.status IN ('new', 'error')
GROUP BY em.id, er.name, v.name, tc.customer_name, tcase.title
GROUP BY em.id, er.name, v.name, tc.name, tcase.title
ORDER BY em.received_date DESC;
-- View for recent email activity

View File

@ -27,9 +27,9 @@ CREATE TABLE IF NOT EXISTS tticket_relations (
CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id)
);
CREATE INDEX idx_tticket_relations_ticket ON tticket_relations(ticket_id);
CREATE INDEX idx_tticket_relations_related ON tticket_relations(related_ticket_id);
CREATE INDEX idx_tticket_relations_type ON tticket_relations(relation_type);
CREATE INDEX IF NOT EXISTS idx_tticket_relations_ticket ON tticket_relations(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_relations_related ON tticket_relations(related_ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_relations_type ON tticket_relations(relation_type);
-- View for at finde alle relationer for en ticket (begge retninger)
CREATE OR REPLACE VIEW tticket_all_relations AS
@ -90,10 +90,10 @@ CREATE TABLE IF NOT EXISTS tticket_calendar_events (
completed_at TIMESTAMP
);
CREATE INDEX idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
CREATE INDEX idx_tticket_calendar_date ON tticket_calendar_events(event_date);
CREATE INDEX idx_tticket_calendar_type ON tticket_calendar_events(event_type);
CREATE INDEX idx_tticket_calendar_status ON tticket_calendar_events(status);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_date ON tticket_calendar_events(event_date);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_type ON tticket_calendar_events(event_type);
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_status ON tticket_calendar_events(status);
-- ============================================================================
-- TEMPLATES (svarskabeloner, guides, standardbreve)
@ -128,8 +128,8 @@ CREATE TABLE IF NOT EXISTS tticket_templates (
usage_count INTEGER DEFAULT 0
);
CREATE INDEX idx_tticket_templates_category ON tticket_templates(category);
CREATE INDEX idx_tticket_templates_active ON tticket_templates(is_active);
CREATE INDEX IF NOT EXISTS idx_tticket_templates_category ON tticket_templates(category);
CREATE INDEX IF NOT EXISTS idx_tticket_templates_active ON tticket_templates(is_active);
-- ============================================================================
-- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt)
@ -143,8 +143,8 @@ CREATE TABLE IF NOT EXISTS tticket_template_usage (
was_modified BOOLEAN DEFAULT false -- Blev template redigeret før afsendelse?
);
CREATE INDEX idx_tticket_template_usage_template ON tticket_template_usage(template_id);
CREATE INDEX idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_template ON tticket_template_usage(template_id);
CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
-- ============================================================================
-- AI SUGGESTIONS (forslag til actions - aldrig automatisk)
@ -186,10 +186,10 @@ CREATE TABLE IF NOT EXISTS tticket_ai_suggestions (
expires_at TIMESTAMP -- Forslag udløber efter X dage
);
CREATE INDEX idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
CREATE INDEX idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type);
CREATE INDEX idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
CREATE INDEX idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
-- ============================================================================
-- EMAIL METADATA (udvidet til contact identification)
@ -227,9 +227,9 @@ CREATE TABLE IF NOT EXISTS tticket_email_metadata (
updated_at TIMESTAMP
);
CREATE INDEX idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
CREATE INDEX idx_tticket_email_message_id ON tticket_email_metadata(message_id);
CREATE INDEX idx_tticket_email_from ON tticket_email_metadata(from_email);
CREATE INDEX IF NOT EXISTS idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_email_message_id ON tticket_email_metadata(message_id);
CREATE INDEX IF NOT EXISTS idx_tticket_email_from ON tticket_email_metadata(from_email);
-- ============================================================================
-- Tilføj manglende kolonner til existing tticket_tickets
@ -265,9 +265,15 @@ CREATE TABLE IF NOT EXISTS tticket_audit_log (
metadata JSONB -- Additional context
);
CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
CREATE INDEX idx_tticket_audit_action ON tticket_audit_log(action);
CREATE INDEX idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
ALTER TABLE tticket_audit_log
ADD COLUMN IF NOT EXISTS field_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN IF NOT EXISTS reason TEXT,
ADD COLUMN IF NOT EXISTS metadata JSONB;
CREATE INDEX IF NOT EXISTS idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_audit_action ON tticket_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
-- ============================================================================
-- TRIGGERS for audit logging

View File

@ -24,7 +24,17 @@ ADD COLUMN IF NOT EXISTS time_date DATE;
ALTER TABLE tmodule_order_lines
ADD COLUMN IF NOT EXISTS is_travel BOOLEAN DEFAULT false;
-- Log migration
INSERT INTO migration_log (migration_name, applied_at)
VALUES ('031_add_is_travel_column', CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING;
-- Log migration when the legacy tracking table exists
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'migration_log'
) THEN
INSERT INTO migration_log (migration_name, applied_at)
VALUES ('031_add_is_travel_column', CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING;
END IF;
END $$;

View File

@ -4,13 +4,13 @@
-- Add import_method column
ALTER TABLE email_messages
ADD COLUMN import_method VARCHAR(50) DEFAULT 'imap';
ADD COLUMN IF NOT EXISTS import_method VARCHAR(50) DEFAULT 'imap';
-- Add comment
COMMENT ON COLUMN email_messages.import_method IS 'How the email was imported: imap, graph_api, or manual_upload';
-- Create index for filtering by import method
CREATE INDEX idx_email_messages_import_method ON email_messages(import_method);
CREATE INDEX IF NOT EXISTS idx_email_messages_import_method ON email_messages(import_method);
-- Update existing records to reflect their actual source
-- (all existing emails were fetched via IMAP or Graph API)
@ -20,5 +20,8 @@ WHERE import_method IS NULL;
-- Add constraint to ensure valid values
ALTER TABLE email_messages
ADD CONSTRAINT chk_email_import_method
DROP CONSTRAINT IF EXISTS chk_email_import_method;
ALTER TABLE email_messages
ADD CONSTRAINT chk_email_import_method
CHECK (import_method IN ('imap', 'graph_api', 'manual_upload'));

View File

@ -1,5 +1,5 @@
-- 069_conversation_category.sql
-- Add category column for conversation classification
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting';

View File

@ -1,4 +1,4 @@
-- 072_add_category_to_conversations.sql
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)';

View File

@ -11,4 +11,4 @@ CREATE TABLE IF NOT EXISTS sag_kommentarer (
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
);
CREATE INDEX idx_sag_kommentarer_sag_id ON sag_comments(sag_id);
CREATE INDEX IF NOT EXISTS idx_sag_kommentarer_sag_id ON sag_kommentarer(sag_id);

View File

@ -51,7 +51,7 @@ SELECT
s.customer_id,
cust.name as customer_name,
s.sag_id,
sag.title as sag_title,
sag.titel as sag_title,
s.session_link,
s.started_at,
s.ended_at,

View File

@ -0,0 +1,110 @@
-- Migration 143: Repair Mission Control schema on partially-initialized databases
-- Safe to run multiple times.
CREATE TABLE IF NOT EXISTS mission_events (
id BIGSERIAL PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
title VARCHAR(255) NOT NULL,
source VARCHAR(64),
customer_name VARCHAR(255),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS event_type VARCHAR(64);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS severity VARCHAR(16) NOT NULL DEFAULT 'info';
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS title VARCHAR(255);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS source VARCHAR(64);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS customer_name VARCHAR(255);
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE mission_events ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_mission_events_created_at ON mission_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_mission_events_event_type ON mission_events(event_type);
CREATE TABLE IF NOT EXISTS mission_call_state (
call_id VARCHAR(128) PRIMARY KEY,
queue_name VARCHAR(128),
caller_number VARCHAR(64),
contact_name VARCHAR(255),
company_name VARCHAR(255),
customer_tag VARCHAR(64),
state VARCHAR(32) NOT NULL,
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
answered_at TIMESTAMP,
ended_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_payload JSONB NOT NULL DEFAULT '{}'::jsonb
);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS call_id VARCHAR(128);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS queue_name VARCHAR(128);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS caller_number VARCHAR(64);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS company_name VARCHAR(255);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS customer_tag VARCHAR(64);
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS state VARCHAR(32) NOT NULL DEFAULT 'ringing';
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS answered_at TIMESTAMP;
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS ended_at TIMESTAMP;
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE mission_call_state ADD COLUMN IF NOT EXISTS last_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'mission_call_state_pkey'
AND conrelid = 'mission_call_state'::regclass
) THEN
ALTER TABLE mission_call_state ADD PRIMARY KEY (call_id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_mission_call_state_state ON mission_call_state(state);
CREATE INDEX IF NOT EXISTS idx_mission_call_state_updated_at ON mission_call_state(updated_at DESC);
CREATE TABLE IF NOT EXISTS mission_uptime_alerts (
alert_key VARCHAR(255) PRIMARY KEY,
service_name VARCHAR(255) NOT NULL,
customer_name VARCHAR(255),
status VARCHAR(32) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
started_at TIMESTAMP,
resolved_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb
);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS alert_key VARCHAR(255);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS service_name VARCHAR(255);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS customer_name VARCHAR(255);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS status VARCHAR(32);
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS started_at TIMESTAMP;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMP;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE mission_uptime_alerts ADD COLUMN IF NOT EXISTS normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'mission_uptime_alerts_pkey'
AND conrelid = 'mission_uptime_alerts'::regclass
) THEN
ALTER TABLE mission_uptime_alerts ADD PRIMARY KEY (alert_key);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_mission_uptime_active ON mission_uptime_alerts(is_active, updated_at DESC);
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
('mission_call_ring_timeout_seconds', '180', 'mission', 'Seconds before stale ringing calls auto-expire', 'integer', true)
ON CONFLICT (key) DO NOTHING;

View 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);

View File

@ -0,0 +1,39 @@
-- Migration 144: Extend tags with brand/type classes and catch words
-- Add catch words storage for tag suggestion matching
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS catch_words_json JSONB NOT NULL DEFAULT '[]'::jsonb;
-- Extend allowed tag types to include brand and type
DO $$
DECLARE
constraint_name text;
BEGIN
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_type_check;
FOR constraint_name IN
SELECT con.conname
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
WHERE rel.relname = 'tags'
AND con.contype = 'c'
AND pg_get_constraintdef(con.oid) ILIKE '%type IN (%'
LOOP
EXECUTE format('ALTER TABLE tags DROP CONSTRAINT %I', constraint_name);
END LOOP;
END $$;
ALTER TABLE tags
ADD CONSTRAINT tags_type_check
CHECK (type IN ('workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type'));
-- Seed a couple of starter tags for the new classes
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('Microsoft', 'brand', 'Brand tag for Microsoft related cases', '#006d77', 'bi-microsoft', true, '["microsoft","ms 365","office 365","azure"]'::jsonb),
('Adobe', 'brand', 'Brand tag for Adobe related cases', '#006d77', 'bi-box', true, '["adobe","acrobat","creative cloud"]'::jsonb),
('Printer', 'type', 'Type tag for printer related work', '#5c677d', 'bi-printer', true, '["printer","toner","print","scanner"]'::jsonb),
('Email', 'type', 'Type tag for mail related work', '#5c677d', 'bi-envelope', true, '["mail","email","outlook","smtp","imap"]'::jsonb)
ON CONFLICT (name, type) DO NOTHING;
COMMENT ON COLUMN tags.catch_words_json IS 'JSON array of catch words used for automated tag suggestions';

View File

@ -0,0 +1,10 @@
-- Migration 145: Add start date to SAG for email-driven case creation flow
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS start_date DATE;
CREATE INDEX IF NOT EXISTS idx_sag_sager_start_date
ON sag_sager(start_date)
WHERE deleted_at IS NULL;
COMMENT ON COLUMN sag_sager.start_date IS 'Planned start date for case execution (used by email-to-case workflow).';

View File

@ -0,0 +1,51 @@
-- Migration 145: Seed brand tags (A-Z starter set)
-- DEPRECATED: Superseded by migration 147 (master brand + type seed).
-- Keep for historical traceability; do not run together with 147.
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('3 Mobil', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["3 mobil", "3"]'::jsonb),
('ABA', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["aba"]'::jsonb),
('Android', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["android"]'::jsonb),
('Anydesk', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["anydesk"]'::jsonb),
('Apple', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["apple", "iphone", "ipad", "macbook"]'::jsonb),
('Bitwarden', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bitwarden"]'::jsonb),
('BMC Networks', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc networks", "bmc"]'::jsonb),
('BMC Webhosting', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc webhosting"]'::jsonb),
('Brother', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["brother"]'::jsonb),
('Canon', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["canon"]'::jsonb),
('Cisco', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cisco"]'::jsonb),
('Clickshare', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["clickshare", "barco"]'::jsonb),
('CTS', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cts"]'::jsonb),
('Dropbox', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["dropbox"]'::jsonb),
('Epson', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["epson"]'::jsonb),
('ESET', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["eset"]'::jsonb),
('GlobalConnect', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["globalconnect"]'::jsonb),
('Google', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["google", "gmail", "workspace"]'::jsonb),
('HP', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["hp", "hewlett packard"]'::jsonb),
('IBAK', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ibak"]'::jsonb),
('IP Nordic', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ip nordic"]'::jsonb),
('Lenovo', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["lenovo", "thinkpad"]'::jsonb),
('Microsoft', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["microsoft", "windows", "azure", "teams"]'::jsonb),
('Nextcloud', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NFTV', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nftv"]'::jsonb),
('Office 365', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["office 365", "o365", "m365", "microsoft 365"]'::jsonb),
('Philips', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["philips"]'::jsonb),
('Pronestor/Planner', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["pronestor", "planner"]'::jsonb),
('Refurb', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["refurb"]'::jsonb),
('Samsung', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["samsung"]'::jsonb),
('Sentia', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["sentia"]'::jsonb),
('Simply-CRM', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["simply-crm", "simply crm"]'::jsonb),
('Syncplify', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["syncplify"]'::jsonb),
('TDC', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["tdc"]'::jsonb),
('Teltonika', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["teltonika"]'::jsonb),
('The Union', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["the union"]'::jsonb),
('Ubiquiti', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ubiquiti", "unifi"]'::jsonb),
('Vincentz', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["vincentz"]'::jsonb),
('VisionLine', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["visionline"]'::jsonb),
('Yealink', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["yealink"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;

View File

@ -0,0 +1,101 @@
-- Migration 146: Seed type tags (case type starter set)
-- DEPRECATED: Superseded by migration 147 (master brand + type seed).
-- Keep for historical traceability; do not run together with 147.
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('4g / 5g modem', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["4g", "5g", "modem"]'::jsonb),
('Accounting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["accounting", "bogholderi"]'::jsonb),
('Adgangskode', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["adgangskode", "password"]'::jsonb),
('Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["andet"]'::jsonb),
('Antivirus', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["antivirus", "virus"]'::jsonb),
('Arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["arkiv"]'::jsonb),
('AV udstyr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["av udstyr", "av"]'::jsonb),
('Backup', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["backup"]'::jsonb),
('BMC Mobil recorder', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["bmc mobil recorder", "mobil recorder"]'::jsonb),
('Booking system', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["booking system", "booking"]'::jsonb),
('DHCP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dhcp"]'::jsonb),
('DNS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dns"]'::jsonb),
('Domæne/Web', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["domæne", "domain", "web"]'::jsonb),
('Drift', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["drift", "operations"]'::jsonb),
('Dropbox', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dropbox"]'::jsonb),
('Email', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["email", "e-mail"]'::jsonb),
('Faktura spørgsmål', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura spørgsmål", "invoice question"]'::jsonb),
('Faktura til betaling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura til betaling", "invoice payment"]'::jsonb),
('Hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware"]'::jsonb),
('Hardware order', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware order", "hardware ordre"]'::jsonb),
('Headset', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["headset"]'::jsonb),
('Hosting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hosting"]'::jsonb),
('IBAK', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ibak"]'::jsonb),
('Info/møde skærm', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["infoskærm", "møde skærm", "info skærm"]'::jsonb),
('Installation af hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["installation af hardware", "hardware installation"]'::jsonb),
('Internet forbindelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["internet forbindelse", "internet"]'::jsonb),
('Invoice', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["invoice", "faktura"]'::jsonb),
('IP telefon', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ip telefon", "voip telefon"]'::jsonb),
('Kalender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalender", "calendar"]'::jsonb),
('Kalenderopsætning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalenderopsætning", "calendar setup"]'::jsonb),
('Kreditering', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kreditering", "credit note"]'::jsonb),
('Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["licenser", "licenses"]'::jsonb),
('M365 - Defender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 defender", "defender"]'::jsonb),
('M365 - Entra/Azure', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 entra", "entra", "azure"]'::jsonb),
('M365 - Intune', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 intune", "intune"]'::jsonb),
('M365 - Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 licenser", "m365 licenses"]'::jsonb),
('M365 - Office', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 office", "office"]'::jsonb),
('M365 - Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 sharepoint", "sharepoint"]'::jsonb),
('M365 - Users', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 users", "users"]'::jsonb),
('MacOS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["macos", "mac"]'::jsonb),
('Mail', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail", "email"]'::jsonb),
('Mail-arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail-arkiv", "mail arkiv"]'::jsonb),
('MFA / 2FA', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mfa", "2fa"]'::jsonb),
('Mobil', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mobil", "mobile"]'::jsonb),
('NAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nas"]'::jsonb),
('Nedbrud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nedbrud", "outage"]'::jsonb),
('Netværk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["netværk", "network"]'::jsonb),
('Nextcloud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NP ind/ud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["np ind/ud", "nummerportering"]'::jsonb),
('Ny fiber kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny fiber kunde", "fiber kunde"]'::jsonb),
('Ny hosting kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny hosting kunde", "hosting kunde"]'::jsonb),
('Ny IT kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny it kunde", "it kunde"]'::jsonb),
('Ny kontorhotel kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny kontorhotel kunde", "kontorhotel"]'::jsonb),
('Ny telefoni kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny telefoni kunde", "telefoni kunde"]'::jsonb),
('Offboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["offboarding"]'::jsonb),
('Onboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["onboarding"]'::jsonb),
('Oprettelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprettelse", "create"]'::jsonb),
('Oprydning / Geninstallation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprydning", "geninstallation"]'::jsonb),
('Opsætning / Installation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsætning", "installation", "setup"]'::jsonb),
('Opsigelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsigelse", "termination"]'::jsonb),
('Printer', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["printer"]'::jsonb),
('RDP/Fjernskrivebord', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["rdp", "fjernskrivebord", "remote desktop"]'::jsonb),
('Router / Firewall', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["router", "firewall"]'::jsonb),
('Send faktura', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["send faktura", "send invoice"]'::jsonb),
('Server - Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server andet", "server"]'::jsonb),
('Server - SFTP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server sftp", "sftp"]'::jsonb),
('Server - TrueNAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["truenas", "server truenas"]'::jsonb),
('Server - Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server windows", "windows server"]'::jsonb),
('Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sharepoint"]'::jsonb),
('Sikkerhed', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sikkerhed", "security"]'::jsonb),
('Simkort', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["simkort", "sim card"]'::jsonb),
('Små ændringer!!!!!!!', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["små ændringer", "small changes"]'::jsonb),
('Software', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["software"]'::jsonb),
('Spærring', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["spærring", "block"]'::jsonb),
('Switch', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["switch"]'::jsonb),
('Teams', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["teams", "microsoft teams"]'::jsonb),
('Telefonnr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["telefonnr", "telefonnummer"]'::jsonb),
('Udlejning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udlejning", "rental"]'::jsonb),
('Udvikling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udvikling", "development"]'::jsonb),
('Uisp', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["uisp"]'::jsonb),
('Unifi', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["unifi"]'::jsonb),
('Vagtkald', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vagtkald"]'::jsonb),
('Voip', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["voip"]'::jsonb),
('VPN', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vpn"]'::jsonb),
('WEB', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["web", "website"]'::jsonb),
('WIFI', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["wifi", "wi-fi"]'::jsonb),
('Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows"]'::jsonb),
('Windows AD', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows ad", "active directory", "ad"]'::jsonb),
('Workspace / Office365', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["workspace", "office365", "office 365"]'::jsonb),
('Anydesk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["anydesk"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;

View File

@ -0,0 +1,148 @@
-- Migration 147: Master seed for brand + type tags
-- Depends on migration 144 (brand/type + catch_words_json)
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('3 Mobil', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["3 mobil", "3"]'::jsonb),
('ABA', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["aba"]'::jsonb),
('Android', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["android"]'::jsonb),
('Anydesk', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["anydesk"]'::jsonb),
('Apple', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["apple", "iphone", "ipad", "macbook"]'::jsonb),
('Bitwarden', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bitwarden"]'::jsonb),
('BMC Networks', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc networks", "bmc"]'::jsonb),
('BMC Webhosting', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc webhosting"]'::jsonb),
('Brother', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["brother"]'::jsonb),
('Canon', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["canon"]'::jsonb),
('Cisco', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cisco"]'::jsonb),
('Clickshare', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["clickshare", "barco"]'::jsonb),
('CTS', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cts"]'::jsonb),
('Dropbox', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["dropbox"]'::jsonb),
('Epson', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["epson"]'::jsonb),
('ESET', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["eset"]'::jsonb),
('GlobalConnect', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["globalconnect"]'::jsonb),
('Google', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["google", "gmail", "workspace"]'::jsonb),
('HP', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["hp", "hewlett packard"]'::jsonb),
('IBAK', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ibak"]'::jsonb),
('IP Nordic', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ip nordic"]'::jsonb),
('Lenovo', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["lenovo", "thinkpad"]'::jsonb),
('Microsoft', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["microsoft", "windows", "azure", "teams"]'::jsonb),
('Nextcloud', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NFTV', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nftv"]'::jsonb),
('Office 365', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["office 365", "o365", "m365", "microsoft 365"]'::jsonb),
('Philips', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["philips"]'::jsonb),
('Pronestor/Planner', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["pronestor", "planner"]'::jsonb),
('Refurb', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["refurb"]'::jsonb),
('Samsung', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["samsung"]'::jsonb),
('Sentia', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["sentia"]'::jsonb),
('Simply-CRM', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["simply-crm", "simply crm"]'::jsonb),
('Syncplify', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["syncplify"]'::jsonb),
('TDC', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["tdc"]'::jsonb),
('Teltonika', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["teltonika"]'::jsonb),
('The Union', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["the union"]'::jsonb),
('Ubiquiti', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ubiquiti", "unifi"]'::jsonb),
('Vincentz', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["vincentz"]'::jsonb),
('VisionLine', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["visionline"]'::jsonb),
('Yealink', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["yealink"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
VALUES
('4g / 5g modem', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["4g", "5g", "modem"]'::jsonb),
('Accounting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["accounting", "bogholderi"]'::jsonb),
('Adgangskode', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["adgangskode", "password"]'::jsonb),
('Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["andet"]'::jsonb),
('Antivirus', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["antivirus", "virus"]'::jsonb),
('Arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["arkiv"]'::jsonb),
('AV udstyr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["av udstyr", "av"]'::jsonb),
('Backup', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["backup"]'::jsonb),
('BMC Mobil recorder', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["bmc mobil recorder", "mobil recorder"]'::jsonb),
('Booking system', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["booking system", "booking"]'::jsonb),
('DHCP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dhcp"]'::jsonb),
('DNS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dns"]'::jsonb),
('Domæne/Web', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["domæne", "domain", "web"]'::jsonb),
('Drift', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["drift", "operations"]'::jsonb),
('Dropbox', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dropbox"]'::jsonb),
('Email', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["email", "e-mail"]'::jsonb),
('Faktura spørgsmål', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura spørgsmål", "invoice question"]'::jsonb),
('Faktura til betaling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura til betaling", "invoice payment"]'::jsonb),
('Hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware"]'::jsonb),
('Hardware order', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware order", "hardware ordre"]'::jsonb),
('Headset', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["headset"]'::jsonb),
('Hosting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hosting"]'::jsonb),
('IBAK', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ibak"]'::jsonb),
('Info/møde skærm', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["infoskærm", "møde skærm", "info skærm"]'::jsonb),
('Installation af hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["installation af hardware", "hardware installation"]'::jsonb),
('Internet forbindelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["internet forbindelse", "internet"]'::jsonb),
('Invoice', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["invoice", "faktura"]'::jsonb),
('IP telefon', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ip telefon", "voip telefon"]'::jsonb),
('Kalender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalender", "calendar"]'::jsonb),
('Kalenderopsætning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalenderopsætning", "calendar setup"]'::jsonb),
('Kreditering', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kreditering", "credit note"]'::jsonb),
('Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["licenser", "licenses"]'::jsonb),
('M365 - Defender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 defender", "defender"]'::jsonb),
('M365 - Entra/Azure', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 entra", "entra", "azure"]'::jsonb),
('M365 - Intune', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 intune", "intune"]'::jsonb),
('M365 - Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 licenser", "m365 licenses"]'::jsonb),
('M365 - Office', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 office", "office"]'::jsonb),
('M365 - Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 sharepoint", "sharepoint"]'::jsonb),
('M365 - Users', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 users", "users"]'::jsonb),
('MacOS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["macos", "mac"]'::jsonb),
('Mail', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail", "email"]'::jsonb),
('Mail-arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail-arkiv", "mail arkiv"]'::jsonb),
('MFA / 2FA', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mfa", "2fa"]'::jsonb),
('Mobil', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mobil", "mobile"]'::jsonb),
('NAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nas"]'::jsonb),
('Nedbrud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nedbrud", "outage"]'::jsonb),
('Netværk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["netværk", "network"]'::jsonb),
('Nextcloud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nextcloud"]'::jsonb),
('NP ind/ud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["np ind/ud", "nummerportering"]'::jsonb),
('Ny fiber kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny fiber kunde", "fiber kunde"]'::jsonb),
('Ny hosting kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny hosting kunde", "hosting kunde"]'::jsonb),
('Ny IT kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny it kunde", "it kunde"]'::jsonb),
('Ny kontorhotel kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny kontorhotel kunde", "kontorhotel"]'::jsonb),
('Ny telefoni kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny telefoni kunde", "telefoni kunde"]'::jsonb),
('Offboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["offboarding"]'::jsonb),
('Onboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["onboarding"]'::jsonb),
('Oprettelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprettelse", "create"]'::jsonb),
('Oprydning / Geninstallation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprydning", "geninstallation"]'::jsonb),
('Opsætning / Installation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsætning", "installation", "setup"]'::jsonb),
('Opsigelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsigelse", "termination"]'::jsonb),
('Printer', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["printer"]'::jsonb),
('RDP/Fjernskrivebord', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["rdp", "fjernskrivebord", "remote desktop"]'::jsonb),
('Router / Firewall', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["router", "firewall"]'::jsonb),
('Send faktura', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["send faktura", "send invoice"]'::jsonb),
('Server - Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server andet", "server"]'::jsonb),
('Server - SFTP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server sftp", "sftp"]'::jsonb),
('Server - TrueNAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["truenas", "server truenas"]'::jsonb),
('Server - Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server windows", "windows server"]'::jsonb),
('Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sharepoint"]'::jsonb),
('Sikkerhed', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sikkerhed", "security"]'::jsonb),
('Simkort', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["simkort", "sim card"]'::jsonb),
('Små ændringer!!!!!!!', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["små ændringer", "small changes"]'::jsonb),
('Software', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["software"]'::jsonb),
('Spærring', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["spærring", "block"]'::jsonb),
('Switch', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["switch"]'::jsonb),
('Teams', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["teams", "microsoft teams"]'::jsonb),
('Telefonnr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["telefonnr", "telefonnummer"]'::jsonb),
('Udlejning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udlejning", "rental"]'::jsonb),
('Udvikling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udvikling", "development"]'::jsonb),
('Uisp', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["uisp"]'::jsonb),
('Unifi', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["unifi"]'::jsonb),
('Vagtkald', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vagtkald"]'::jsonb),
('Voip', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["voip"]'::jsonb),
('VPN', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vpn"]'::jsonb),
('WEB', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["web", "website"]'::jsonb),
('WIFI', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["wifi", "wi-fi"]'::jsonb),
('Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows"]'::jsonb),
('Windows AD', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows ad", "active directory", "ad"]'::jsonb),
('Workspace / Office365', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["workspace", "office365", "office 365"]'::jsonb),
('Anydesk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["anydesk"]'::jsonb)
ON CONFLICT (name, type) DO UPDATE
SET
is_active = EXCLUDED.is_active,
catch_words_json = EXCLUDED.catch_words_json,
updated_at = CURRENT_TIMESTAMP;

View File

@ -0,0 +1,5 @@
-- Add persistent next-task selection for case todo steps
ALTER TABLE sag_todo_steps
ADD COLUMN IF NOT EXISTS is_next BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_sag_todo_steps_is_next ON sag_todo_steps (sag_id, is_next);

24
parse_html.py Normal file
View 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
View 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
View 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
View 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
View 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.")

159
scripts/run_migrations.py Normal file
View File

@ -0,0 +1,159 @@
#!/usr/bin/env python3
import argparse
import logging
import os
import re
import sys
from pathlib import Path
import psycopg2
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.core.config import settings
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
NUMBERED_SQL_RE = re.compile(r"^\d+.*\.sql$")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run BMC Hub SQL migrations against the configured PostgreSQL database."
)
parser.add_argument(
"files",
nargs="*",
help="Specific SQL files to run, relative to repo root (for example migrations/145_sag_start_date.sql).",
)
parser.add_argument(
"--all",
action="store_true",
help="Run all numbered SQL files from ./migrations in numeric order. Default when no files are provided.",
)
parser.add_argument(
"--module",
action="append",
default=[],
help="Also run numbered SQL files from a module migration directory, relative to repo root.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the files that would run without executing them.",
)
parser.add_argument(
"--stop-on-error",
action="store_true",
help="Stop immediately on the first migration failure.",
)
return parser.parse_args()
def patch_database_url_for_local_dev() -> None:
if "@postgres" in settings.DATABASE_URL:
logger.info("Patching DATABASE_URL for local run")
settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433")
def collect_numbered_sql(directory: Path) -> list[Path]:
files = [p for p in directory.glob("*.sql") if NUMBERED_SQL_RE.match(p.name)]
files.sort(key=lambda p: (int(re.match(r"^(\d+)", p.name).group(1)), p.name))
return files
def resolve_explicit_files(file_args: list[str]) -> list[Path]:
resolved = []
for raw in file_args:
path = (ROOT / raw).resolve()
if not path.exists():
raise FileNotFoundError(f"Migration file not found: {raw}")
resolved.append(path)
return resolved
def build_file_list(args: argparse.Namespace) -> list[Path]:
files: list[Path] = []
if args.files:
files.extend(resolve_explicit_files(args.files))
else:
files.extend(collect_numbered_sql(ROOT / "migrations"))
for module_dir in args.module:
path = (ROOT / module_dir).resolve()
if not path.exists() or not path.is_dir():
raise FileNotFoundError(f"Module migration directory not found: {module_dir}")
files.extend(collect_numbered_sql(path))
# Preserve order but remove duplicates.
unique_files: list[Path] = []
seen: set[Path] = set()
for path in files:
if path not in seen:
unique_files.append(path)
seen.add(path)
return unique_files
def run_files(files: list[Path], dry_run: bool, stop_on_error: bool) -> int:
if not files:
logger.info("No migration files selected.")
return 0
if dry_run:
for path in files:
logger.info("DRY %s", path.relative_to(ROOT))
return 0
conn = psycopg2.connect(settings.DATABASE_URL)
conn.autocommit = False
cur = conn.cursor()
failures: list[tuple[Path, str]] = []
try:
for path in files:
rel = path.relative_to(ROOT)
sql = path.read_text(encoding="utf-8")
try:
cur.execute(sql)
conn.commit()
logger.info("OK %s", rel)
except Exception as exc:
conn.rollback()
message = str(exc).strip().splitlines()[0]
failures.append((path, message))
logger.error("FAIL %s: %s", rel, message)
if stop_on_error:
break
finally:
cur.close()
conn.close()
if failures:
logger.error("")
logger.error("Failed migrations:")
for path, message in failures:
logger.error("- %s: %s", path.relative_to(ROOT), message)
return 1
logger.info("")
logger.info("All selected migrations completed successfully.")
return 0
def main() -> int:
args = parse_args()
patch_database_url_for_local_dev()
files = build_file_list(args)
return run_files(files, dry_run=args.dry_run, stop_on_error=args.stop_on_error)
if __name__ == "__main__":
raise SystemExit(main())

15
split_cols.py Normal file
View 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()

View File

@ -212,10 +212,12 @@ class TagPicker {
'status': '📊 Status - Tilstand',
'category': '📁 Kategori - Emne',
'priority': '🔥 Prioritet - Hastighed',
'billing': '💰 Fakturering - Økonomi'
'billing': '💰 Fakturering - Økonomi',
'brand': '🏷️ Brand - Leverandør/produkt',
'type': '🧩 Type - Sagstype'
};
const typeOrder = ['workflow', 'status', 'category', 'priority', 'billing'];
const typeOrder = ['workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type'];
let html = '';
typeOrder.forEach(type => {

8
test_wrapper.py Normal file
View 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))

Some files were not shown because too many files have changed in this diff Show More