Compare commits

..

No commits in common. "main" and "v2.2.52" have entirely different histories.

64 changed files with 939 additions and 5874 deletions

View File

@ -1,38 +0,0 @@
---
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.

View File

@ -1,42 +0,0 @@
# 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/`.

View File

@ -1,28 +0,0 @@
# 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.

View File

@ -1,22 +0,0 @@
# 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

View File

@ -1,18 +0,0 @@
# 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

View File

@ -1,15 +0,0 @@
# 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

View File

@ -1,16 +0,0 @@
# 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

View File

@ -1,17 +0,0 @@
# 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

View File

@ -1,15 +0,0 @@
# 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`

View File

@ -1,14 +0,0 @@
# 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`

View File

@ -1,14 +0,0 @@
# 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`

View File

@ -1,18 +0,0 @@
# 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

@ -74,8 +74,6 @@ 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)
)
@ -141,18 +139,10 @@ async def setup_2fa(current_user: dict = Depends(get_current_user)):
detail="Shadow admin cannot configure 2FA",
)
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
result = AuthService.setup_user_2fa(
user_id=current_user["id"],
username=current_user["username"]
)
return result

View File

@ -25,26 +25,8 @@ class BackupService:
"""Service for managing backup operations"""
def __init__(self):
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
self.backup_dir = Path(settings.BACKUP_STORAGE_PATH)
self.backup_dir.mkdir(parents=True, exist_ok=True)
# Subdirectories for different backup types
self.db_dir = self.backup_dir / "database"

View File

@ -1703,10 +1703,6 @@ 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"""
@ -1739,58 +1735,6 @@ 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):
"""
@ -2260,7 +2204,7 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
@router.post("/supplier-invoices/{invoice_id}/send-to-economic-legacy-unimplemented")
@router.post("/supplier-invoices/{invoice_id}/send-to-economic")
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,11 +173,6 @@
<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
@ -253,7 +248,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="bulkSendToEconomicKassekladde()" title="Send til e-conomic kassekladde">
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomic()" title="Send til e-conomic kassekladde">
<i class="bi bi-send me-1"></i>Send til e-conomic
</button>
</div>
@ -1397,7 +1392,7 @@ async function markSingleAsPaid(invoiceId) {
}
// Helper function to send single invoice to e-conomic
async function sendToEconomicById(invoiceId) {
async function sendToEconomic(invoiceId) {
if (!confirm('Send denne faktura til e-conomic?')) return;
try {
@ -1685,7 +1680,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="sendToEconomicById(${invoice.id})" title="Send til e-conomic">
<button class="btn btn-sm btn-primary" onclick="sendToEconomic(${invoice.id})" title="Send til e-conomic">
<i class="bi bi-send"></i>
</button>
</td>
@ -4056,11 +4051,12 @@ async function bulkMarkAsPaid() {
for (const invoiceId of invoiceIds) {
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
method: 'POST',
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
paid_date: new Date().toISOString().split('T')[0]
status: 'paid',
payment_date: new Date().toISOString().split('T')[0]
})
});
@ -4091,11 +4087,12 @@ async function markInvoiceAsPaid(invoiceId) {
if (!confirm('Marker denne faktura som betalt?')) return;
try {
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
method: 'POST',
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
paid_date: new Date().toISOString().split('T')[0]
status: 'paid',
payment_date: new Date().toISOString().split('T')[0]
})
});
@ -4560,7 +4557,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: getApprovalUser() })
body: JSON.stringify({ approved_by: 'CurrentUser' }) // TODO: Get from auth
});
if (response.ok) {
@ -4613,7 +4610,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: getApprovalUser() })
body: JSON.stringify({ approved_by: 'CurrentUser' })
});
if (response.ok) {
@ -4958,7 +4955,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({
@ -5120,7 +5117,7 @@ async function sendSingleToEconomic(invoiceId) {
}
// Bulk send selected invoices to e-conomic
async function bulkSendToEconomicKassekladde() {
async function bulkSendToEconomic() {
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
const invoiceIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.invoiceId));
@ -5168,16 +5165,6 @@ async function bulkSendToEconomicKassekladde() {
}
}
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,21 +15,6 @@ 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)
@ -85,11 +70,9 @@ 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(
f"SELECT email, full_name, {is_2fa_expr} FROM users WHERE user_id = %s",
(user_id,),
)
"SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,))
return {
"id": user_id,

View File

@ -15,28 +15,6 @@ 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"
@ -48,11 +26,6 @@ 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:
"""
@ -116,9 +89,6 @@ 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",
@ -133,9 +103,6 @@ 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,)
@ -156,9 +123,6 @@ 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,)
@ -187,11 +151,10 @@ class AuthService:
if not user:
return False
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,)
)
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
@ -293,18 +256,13 @@ 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(
f"""SELECT user_id, username, email, password_hash, full_name,
"""SELECT user_id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until,
{is_2fa_expr}, {totp_expr}, {last_2fa_expr}
is_2fa_enabled, totp_secret, last_2fa_at
FROM users
WHERE username = %s OR email = %s""",
(username, username),
)
(username, username))
if not user:
# Shadow Admin fallback (only when no regular user matches)
@ -409,11 +367,10 @@ class AuthService:
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
return None, "Invalid 2FA code"
if _users_column_exists("last_2fa_at"):
execute_update(
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user['user_id'],)
)
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(
@ -459,9 +416,6 @@ 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,7 +105,6 @@ 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,7 +6,6 @@ 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
@ -129,34 +128,3 @@ 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

@ -125,24 +125,10 @@ async def dashboard(request: Request):
from app.core.database import execute_query
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
result = execute_query_single(unknown_query)
unknown_count = result['count'] if result else 0
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
raw_alerts = execute_query(bankruptcy_query) or []
bankruptcy_alerts = []
for alert in raw_alerts:

View File

@ -142,131 +142,10 @@ 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(
@ -496,202 +375,6 @@ 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):
"""
@ -1523,7 +1206,6 @@ 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,
@ -1543,7 +1225,6 @@ 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,
@ -1813,7 +1494,6 @@ 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

@ -12,10 +12,6 @@
overflow: hidden;
}
.email-container > * {
min-width: 0;
}
/* Left Sidebar - Email List (25%) */
.email-list-sidebar {
flex: 0 0 400px;
@ -214,7 +210,6 @@
/* Center Pane - Email Content (50%) */
.email-content-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-card);
@ -225,7 +220,6 @@
.email-content-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0,0,0,0.1);
min-width: 0;
}
.email-content-subject {
@ -233,8 +227,6 @@
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
overflow-wrap: anywhere;
word-break: break-word;
}
.email-content-meta {
@ -242,20 +234,17 @@
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 {
@ -266,8 +255,6 @@
.sender-email {
font-size: 0.8rem;
color: var(--text-secondary);
overflow-wrap: anywhere;
word-break: break-word;
}
.email-timestamp {
@ -280,14 +267,6 @@
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 {
@ -305,56 +284,7 @@
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 {
@ -377,14 +307,6 @@
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 {
@ -420,7 +342,6 @@
flex-direction: column;
gap: 1rem;
overflow-y: auto;
min-width: 0;
}
.analysis-card {
@ -516,87 +437,6 @@
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) {
.email-list-sidebar {
@ -1031,9 +871,6 @@
<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>
@ -1475,8 +1312,6 @@ let emails = [];
let selectedEmails = new Set();
let emailSearchTimeout = null;
let autoRefreshInterval = null;
let sagAssignmentOptions = { users: [], groups: [] };
let customerSearchHideTimeout = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
@ -1487,7 +1322,6 @@ document.addEventListener('DOMContentLoaded', () => {
loadStats();
setupEventListeners();
setupKeyboardShortcuts();
preloadSagAssignmentOptions();
startAutoRefresh();
});
@ -1597,12 +1431,13 @@ async function loadEmails(searchQuery = '') {
// Handle special filters
if (currentFilter === 'active') {
// Active queue includes both new and awaiting manual handling.
// We fetch list data and filter client-side because API status filter is single-value.
// Show only new, error, or flagged (pending review) emails
// If searching, ignore status filter to allow global search
if (!searchQuery) {
url += '&status=new';
}
} 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}`;
@ -1620,11 +1455,6 @@ 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);
@ -1892,12 +1722,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 attachment-chip" title="Se ${att.filename}">
class="btn btn-sm btn-outline-primary" 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 attachment-chip"
class="btn btn-sm btn-outline-secondary"
download="${att.filename}"
title="Download ${att.filename}">
<i class="bi bi-download me-1"></i>${att.filename}
@ -1917,40 +1747,10 @@ 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;
normalizeEmailHtmlLayout(htmlDiv);
}
if (htmlDiv) htmlDiv.innerHTML = email.body_html;
}
}
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) {
@ -1960,135 +1760,49 @@ function renderEmailAnalysis(email) {
const classification = email.classification || 'general';
const confidence = email.confidence_score || 0;
const primaryType = suggestPrimaryType(email);
const secondaryLabel = suggestSecondaryLabel(email);
const selectedCustomerName = email.customer_name || '';
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('');
// Opdater kun AI Analysis tab indholdet - ikke hele sidebar
aiAnalysisTab.innerHTML = `
<div class="analysis-card">
<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>
${!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="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
<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-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 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>
`}
${getClassificationActions(email) ? `
<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)}
</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>
@ -2115,237 +1829,47 @@ function renderEmailAnalysis(email) {
<i class="bi bi-check-lg me-2"></i>Gem Klassificering
</button>
</div>
`;
const statusBadge = document.getElementById('emailActionStatus');
if (statusBadge) {
statusBadge.textContent = email.status || 'new';
}
}
<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>
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>
${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>
`;
}).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');
}
</div>
` : ''}
`;
}
function showEmptyState() {
@ -2390,14 +1914,12 @@ async function loadStats() {
const response = await fetch('/api/v1/emails/stats/summary');
const stats = await response.json();
const newCount = stats.new_emails || 0;
const awaitingCount = stats.awaiting_user_action || 0;
const activeCount = newCount + awaitingCount;
// Calculate active emails (new + error + flagged)
const activeCount = stats.new_emails || 0;
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;
@ -2750,20 +2272,11 @@ async function createTimeEntry(emailId) {
}
async function createCase(emailId) {
if (currentEmailId !== emailId) {
await selectEmail(emailId);
}
setPrimaryType('support');
focusTypeEditor();
showInfo('Sagsforslag klar. Udfyld felter og klik Opret Ny Sag.');
showError('Sags-modul er ikke implementeret endnu');
}
async function linkToCustomer(emailId) {
if (currentEmailId !== emailId) {
await selectEmail(emailId);
}
document.getElementById('caseCustomerSearch')?.focus();
showInfo('Søg og vælg kunde i forslagspanelet.');
showError('Kunde-linking er ikke implementeret endnu');
}
// ─── Quick Create Customer ────────────────────────────────────────────────
@ -3176,10 +2689,6 @@ 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,7 +280,6 @@ 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):
@ -288,7 +287,6 @@ 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,7 +1,6 @@
import logging
import os
import shutil
import json
from pathlib import Path
from datetime import datetime
from typing import List, Optional
@ -51,64 +50,15 @@ 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 allowed_map.get("åben", allowed_statuses[0])
return "åben"
normalized = str(status_value).strip().lower()
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])
if normalized == "afventer":
return "åben"
if normalized in {"åben", "lukket"}:
return normalized
return "åben"
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
@ -172,8 +122,6 @@ class SagSendEmailRequest(BaseModel):
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]:
@ -461,7 +409,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.is_next DESC, t.due_date NULLS LAST, t.created_at DESC
ORDER BY t.is_done ASC, t.due_date NULLS LAST, t.created_at DESC
"""
return execute_query(query, (sag_id,)) or []
except Exception as e:
@ -516,62 +464,33 @@ 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 and data.is_next is None:
raise HTTPException(status_code=400, detail="Provide is_done or is_next")
if data.is_done is None:
raise HTTPException(status_code=400, detail="is_done is required")
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(
"""
user_id = _get_user_id_from_request(request)
if data.is_done:
update_query = """
UPDATE sag_todo_steps
SET is_next = %s
SET is_done = TRUE,
completed_by_user_id = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
""",
(bool(data.is_next), step_id)
)
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:
raise HTTPException(status_code=404, detail="Todo step not found")
return execute_query(
"""
@ -631,12 +550,8 @@ 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"])
@ -652,8 +567,6 @@ 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",
@ -2286,42 +2199,6 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
"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,
@ -2330,8 +2207,6 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
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,
)
@ -2343,72 +2218,36 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
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,
),
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)
@ -2447,11 +2286,9 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
)
logger.info(
"✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)",
"✅ Outbound case email sent and linked (case=%s, email_id=%s, recipients=%s)",
sag_id,
email_id,
payload.thread_email_id,
payload.thread_key,
", ".join(to_addresses),
)
return {

View File

@ -1,5 +1,4 @@
import logging
import json
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
@ -57,50 +56,6 @@ 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,
@ -122,9 +77,7 @@ 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,
nt.title AS next_todo_title,
nt.due_date AS next_todo_due_date
g.name AS assigned_group_name
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
@ -137,22 +90,6 @@ 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
"""
@ -225,11 +162,7 @@ async def sager_liste(
sager = [s for s in sager if s['id'] in tagged_ids]
# Fetch all distinct statuses and tags for filters
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)
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY 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(
@ -241,7 +174,7 @@ async def sager_liste(
"sager": sager,
"relations_map": relations_map,
"child_ids": list(child_ids),
"statuses": status_options,
"statuses": [s['status'] for s in statuses],
"all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status,
"current_tag": tag,
@ -518,10 +451,7 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = []
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)
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail.html", {
@ -545,7 +475,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": status_options,
"status_options": [s["status"] for s in statuses],
"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, type, template_key FROM sag_sager WHERE id IN ({placeholders})"
tree_cases_query = f"SELECT id, titel, status 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

View File

@ -17,14 +17,12 @@
.table-wrapper {
background: var(--bg-card);
border-radius: 12px;
overflow-x: auto;
overflow-y: hidden;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.sag-table {
width: 100%;
min-width: 1760px;
margin: 0;
}
@ -34,13 +32,12 @@
}
.sag-table thead th {
padding: 0.6rem 0.75rem;
padding: 0.8rem 1rem;
font-weight: 600;
font-size: 0.78rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.3px;
letter-spacing: 0.5px;
border: none;
white-space: nowrap;
}
.sag-table tbody tr {
@ -54,30 +51,9 @@
}
.sag-table tbody td {
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;
padding: 0.6rem 1rem;
vertical-align: middle;
font-size: 0.9rem;
}
.sag-id {
@ -270,7 +246,7 @@
{% endblock %}
{% block content %}
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
<div class="container-fluid" style="max-width: 1400px; padding-top: 2rem;">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="margin: 0; color: var(--accent);">
@ -354,19 +330,17 @@
<table class="sag-table">
<thead>
<tr>
<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: 90px;">ID</th>
<th>Titel & Beskrivelse</th>
<th style="width: 120px;">Type</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>
<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>
</tr>
</thead>
<tbody id="sagTableBody">
@ -383,13 +357,7 @@
{% endif %}
<span class="sag-id">#{{ sag.id }}</span>
</td>
<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 }}'">
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<div class="sag-titel">{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
@ -398,36 +366,29 @@
<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; text-transform: capitalize;">
{{ sag.priority if sag.priority else 'normal' }}
<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>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<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;">
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td 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 }}'" 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.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
<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>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }}
{{ 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 '-' }}
</td>
</tr>
{% if has_relations %}
@ -441,13 +402,7 @@
<td>
<span class="sag-id">#{{ related_sag.id }}</span>
</td>
<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 }}'">
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
{% for rt in all_rel_types %}
<span class="relation-badge">{{ rt }}</span>
{% endfor %}
@ -459,36 +414,29 @@
<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; text-transform: capitalize;">
{{ related_sag.priority if related_sag.priority else 'normal' }}
<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>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<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;">
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td 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 }}'" 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.start_date.strftime('%d/%m-%Y') if related_sag.start_date else '-' }}
<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>
<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 '-' }}
{{ 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 '-' }}
</td>
</tr>
{% endif %}

View File

@ -12,31 +12,15 @@ 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 (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
# Upload directory for logos
LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
# ============================================================================

View File

@ -67,12 +67,12 @@ class CaseAnalysisService:
return analysis
else:
logger.warning("⚠️ Ollama returned no result, using heuristic fallback analysis")
return await self._heuristic_fallback_analysis(text)
logger.warning("⚠️ Ollama returned no result, using empty analysis")
return self._empty_analysis(text)
except Exception as e:
logger.error(f"❌ Case analysis failed: {e}", exc_info=True)
return await self._heuristic_fallback_analysis(text)
return self._empty_analysis(text)
def _build_analysis_prompt(self) -> str:
"""Build Danish system prompt for case analysis"""
@ -471,73 +471,6 @@ Returner JSON med suggested_title, suggested_description, priority, customer_hin
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"""
text_hash = hashlib.md5(text.encode()).hexdigest()

View File

@ -6,7 +6,6 @@ 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,7 +49,6 @@ class EmailProcessorService:
'fetched': 0,
'saved': 0,
'classified': 0,
'awaiting_user_action': 0,
'rules_matched': 0,
'errors': 0
}
@ -87,8 +86,6 @@ 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
@ -112,7 +109,6 @@ class EmailProcessorService:
email_id = email_data.get('id')
stats = {
'classified': False,
'awaiting_user_action': False,
'workflows_executed': 0,
'rules_matched': False
}
@ -128,22 +124,6 @@ class EmailProcessorService:
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
if hasattr(settings, 'EMAIL_WORKFLOWS_ENABLED') and settings.EMAIL_WORKFLOWS_ENABLED:
@ -193,25 +173,6 @@ class EmailProcessorService:
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"""
try:

View File

@ -1026,8 +1026,6 @@ class EmailService:
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]:
@ -1062,10 +1060,6 @@ class EmailService:
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'))

View File

@ -10,7 +10,6 @@ from app.core.config import settings
import httpx
import time
import logging
import json
logger = logging.getLogger(__name__)
router = APIRouter()
@ -76,7 +75,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", "case_statuses"}:
if not result and key in {"case_types", "case_type_module_defaults"}:
seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
@ -109,25 +108,6 @@ 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:
@ -598,12 +578,9 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
start = time.perf_counter()
try:
model_normalized = (model or "").strip().lower()
# qwen models are more reliable with /api/chat than /api/generate.
use_chat_api = model_normalized.startswith("qwen")
use_chat_api = model.startswith("qwen3")
timeout = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
async with httpx.AsyncClient(timeout=60.0) as client:
if use_chat_api:
response = await client.post(
f"{endpoint}/api/chat",
@ -634,14 +611,7 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
detail=f"AI endpoint fejl: {response.status_code} - {response.text[:300]}",
)
try:
data = response.json()
except Exception as parse_error:
raise HTTPException(
status_code=502,
detail=f"AI endpoint returnerede ugyldig JSON: {str(parse_error)}",
)
data = response.json()
if use_chat_api:
message_data = data.get("message", {})
ai_response = (message_data.get("content") or message_data.get("thinking") or "").strip()
@ -664,12 +634,8 @@ 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}: {repr(e)}")
err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
logger.error(f"❌ AI prompt test failed for {key}: {e}")
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {str(e)}")

View File

@ -1143,33 +1143,6 @@ 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>
@ -1689,8 +1662,6 @@ async function loadSettings() {
displaySettingsByCategory();
renderTelefoniSettings();
await loadCaseTypesSetting();
await loadCaseStatusesSetting();
await loadTagsManagement();
await loadNextcloudInstances();
} catch (error) {
console.error('Error loading settings:', error);
@ -2060,132 +2031,6 @@ 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 = {};
@ -3240,8 +3085,6 @@ 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') {
@ -3316,19 +3159,13 @@ let showInactive = false;
async function loadTagsManagement() {
try {
const response = await fetch('/api/v1/tags');
if (!response.ok) {
const msg = await getErrorMessage(response, 'Kunne ikke indlæse tags');
throw new Error(msg);
}
if (!response.ok) throw new Error('Failed to load tags');
allTagsData = await response.json();
updateTagsStats();
renderTagsGrid();
} catch (error) {
console.error('Error loading tags:', error);
allTagsData = [];
updateTagsStats();
renderTagsGrid();
showNotification('Fejl ved indlæsning af tags: ' + (error.message || 'ukendt fejl'), 'error');
showNotification('Fejl ved indlæsning af tags', 'error');
}
}

View File

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

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', 'brand', 'type']
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing']
TagGroupBehavior = Literal['multi', 'single', 'toggle']
@ -37,7 +37,6 @@ 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"""
@ -60,7 +59,6 @@ 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,10 +1,8 @@
"""
Tag system API endpoints
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException
from typing import List, Optional
import json
import re
from app.tags.backend.models import (
Tag, TagCreate, TagUpdate,
EntityTag, EntityTagCreate,
@ -16,197 +14,6 @@ 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])
@ -227,131 +34,13 @@ 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 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
"""
query = "SELECT * FROM tags WHERE 1=1"
params = []
if type:
@ -365,52 +54,32 @@ async def list_tags(
query += " ORDER BY type, name"
results = execute_query(query, tuple(params) if params else ())
return [_tag_row_to_response(row) for row in (results or [])]
return results
@router.get("/{tag_id}", response_model=Tag)
async def get_tag(tag_id: int):
"""Get single tag by ID"""
result = execute_query_single(
"""
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
""",
"SELECT * FROM tags WHERE id = %s",
(tag_id,)
)
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
return _tag_row_to_response(result)
return 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, 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
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
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,
json.dumps(catch_words),
)
(tag.name, tag.type, tag.description, tag.color, tag.icon, tag.is_active, tag.tag_group_id)
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create tag")
return _tag_row_to_response(result)
return result
@router.put("/{tag_id}", response_model=Tag)
async def update_tag(tag_id: int, tag: TagUpdate):
@ -437,9 +106,6 @@ 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")
@ -451,15 +117,13 @@ async def update_tag(tag_id: int, tag: TagUpdate):
UPDATE tags
SET {', '.join(updates)}
WHERE id = %s
RETURNING id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
RETURNING *
"""
result = execute_query_single(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
return _tag_row_to_response(result)
return result
@router.delete("/{tag_id}")
async def delete_tag(tag_id: int):
@ -550,91 +214,20 @@ 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.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
SELECT t.*
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 [_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
return results
@router.get("/search")
async def search_tags(q: str, type: Optional[TagType] = None):
"""Search tags by name (fuzzy search)"""
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
SELECT * FROM tags
WHERE is_active = true
AND LOWER(name) LIKE LOWER(%s)
"""
@ -647,7 +240,7 @@ async def search_tags(q: str, type: Optional[TagType] = None):
query += " ORDER BY name LIMIT 20"
results = execute_query(query, tuple(params))
return [_tag_row_to_response(row) for row in (results or [])]
return results
# ============= WORKFLOW MANAGEMENT =============

View File

@ -1,8 +1,11 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Tag Administration - BMC Hub{% endblock %}
{% block extra_css %}
<!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">
<style>
:root {
--primary-color: #0f4c75;
@ -11,8 +14,6 @@
--category-color: #0f4c75;
--priority-color: #dc3545;
--billing-color: #2d6a4f;
--brand-color: #006d77;
--type-color: #5c677d;
}
.tag-badge {
@ -36,8 +37,6 @@
.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;
@ -54,8 +53,6 @@
.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;
@ -63,68 +60,9 @@
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>
{% endblock %}
{% block content %}
</head>
<body>
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col">
@ -138,17 +76,6 @@
</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">
@ -179,16 +106,6 @@
<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 -->
@ -203,98 +120,6 @@
</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 -->
@ -323,17 +148,9 @@
<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>
@ -369,59 +186,19 @@
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<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) => {
@ -452,9 +229,7 @@
'status': '#ffd700',
'category': '#0f4c75',
'priority': '#dc3545',
'billing': '#2d6a4f',
'brand': '#006d77',
'type': '#5c677d'
'billing': '#2d6a4f'
};
if (colorMap[type]) {
document.getElementById('tagColor').value = colorMap[type];
@ -465,61 +240,6 @@
// 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();
@ -544,131 +264,6 @@
}
}
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'
@ -698,7 +293,6 @@
${!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})">
@ -721,11 +315,7 @@
description: document.getElementById('tagDescription').value || null,
color: document.getElementById('tagColorHex').value,
icon: document.getElementById('tagIcon').value || null,
is_active: document.getElementById('tagActive').checked,
catch_words: document.getElementById('tagCatchWords').value
.split(/[\n,]+/)
.map(v => v.trim().toLowerCase())
.filter(v => v.length > 1)
is_active: document.getElementById('tagActive').checked
};
try {
@ -762,7 +352,6 @@
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();
@ -785,4 +374,5 @@
}
}
</script>
{% endblock %}
</body>
</html>

View File

@ -2,21 +2,6 @@
{% 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">
@ -80,22 +65,15 @@
<table class="table table-sm table-hover mb-0" id="caseTable">
<thead class="table-light" id="tableHead">
<tr>
<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>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Status</th>
<th>Dato</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
</tbody>
</table>
</div>
@ -189,16 +167,8 @@ 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 %}
@ -209,16 +179,7 @@ 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 %}
@ -230,16 +191,9 @@ 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 %}
@ -251,16 +205,7 @@ 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 %}
@ -280,32 +225,6 @@ 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');
@ -323,7 +242,7 @@ function toggleSection(filterName) {
if (currentFilter === filterName) {
currentFilter = null;
listTitle.textContent = 'Alle sager';
tableBody.innerHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
tableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
return;
}
@ -347,43 +266,70 @@ 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="12" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
} else {
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
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('');
}
} 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="12" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
} else {
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
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('');
}
} 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="12" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
} else {
bodyHTML = data.map(item => {
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');
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>
`;
}).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="12" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
} else {
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
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('');
}
}

View File

@ -86,38 +86,14 @@
<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>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>
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Kunde</th><th>Oprettet</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.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>#{{ 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>
</tr>
{% else %}
<tr><td colspan="11" class="text-center text-muted py-3">Ingen nye sager</td></tr>
<tr><td colspan="4" class="text-center text-muted py-3">Ingen nye sager</td></tr>
{% endfor %}
</tbody>
</table>

View File

@ -32,71 +32,59 @@
<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>Ansvarl.</th>
<th>Gruppe/Level</th>
<th>Opret.</th>
<th>Start arbejde</th>
<th>Start inden</th>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Status</th>
<th>Prioritet/Reason</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.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.title }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>{{ item.attention_reason }}</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.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.title }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>{{ item.task_reason }}</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.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.titel }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.status }}</td>
<td>-</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="11" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td>
<td colspan="8" 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, table_has_column
from app.core.database import execute_query, execute_update, execute_query_single
logger = logging.getLogger(__name__)
@ -18,20 +18,6 @@ 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)
@ -376,8 +362,6 @@ 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
@ -387,34 +371,16 @@ 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 = f"""
new_cases_query = """
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},
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
COALESCE(c.name, 'Ukendt kunde') AS customer_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
@ -422,34 +388,16 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"""
new_cases = execute_query(new_cases_query)
my_cases_query = f"""
my_cases_query = """
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},
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
COALESCE(c.name, 'Ukendt kunde') AS customer_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'
@ -458,36 +406,19 @@ 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 = f"""
today_tasks_query = """
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,
COALESCE(s.priority::text, 'normal') AS priority,
NULL::text 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'
@ -499,22 +430,14 @@ 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
@ -524,36 +447,19 @@ 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 = f"""
urgent_overdue_query = """
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
@ -565,17 +471,10 @@ 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'
@ -583,7 +482,6 @@ 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)
@ -644,36 +542,19 @@ 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 = f"""
group_cases_query = """
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,
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(c.name, 'Ukendt kunde') AS customer_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'

View File

@ -1,37 +0,0 @@
# 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.

38
main.py
View File

@ -16,29 +16,6 @@ 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:
@ -288,16 +265,11 @@ async def auth_middleware(request: Request, call_next):
content={"detail": "Invalid token"}
)
user_id = int(payload.get("sub"))
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
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 not is_2fa_enabled:
allowed_2fa_paths = (

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(user_id) ON DELETE SET NULL
FOREIGN KEY (created_by_user_id) REFERENCES users(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.name as customer_name,
tc.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.name, tcase.title
GROUP BY em.id, er.name, v.name, tc.customer_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 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);
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);
-- 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 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);
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);
-- ============================================================================
-- TEMPLATES (svarskabeloner, guides, standardbreve)
@ -128,8 +128,8 @@ CREATE TABLE IF NOT EXISTS tticket_templates (
usage_count INTEGER DEFAULT 0
);
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);
CREATE INDEX idx_tticket_templates_category ON tticket_templates(category);
CREATE INDEX 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 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);
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);
-- ============================================================================
-- 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 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);
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);
-- ============================================================================
-- EMAIL METADATA (udvidet til contact identification)
@ -227,9 +227,9 @@ CREATE TABLE IF NOT EXISTS tticket_email_metadata (
updated_at TIMESTAMP
);
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);
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);
-- ============================================================================
-- Tilføj manglende kolonner til existing tticket_tickets
@ -265,15 +265,9 @@ CREATE TABLE IF NOT EXISTS tticket_audit_log (
metadata JSONB -- Additional context
);
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);
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);
-- ============================================================================
-- TRIGGERS for audit logging

View File

@ -24,17 +24,7 @@ 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 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 $$;
-- Log migration
INSERT INTO migration_log (migration_name, applied_at)
VALUES ('031_add_is_travel_column', CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING;

View File

@ -4,13 +4,13 @@
-- Add import_method column
ALTER TABLE email_messages
ADD COLUMN IF NOT EXISTS import_method VARCHAR(50) DEFAULT 'imap';
ADD COLUMN 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 IF NOT EXISTS idx_email_messages_import_method ON email_messages(import_method);
CREATE INDEX 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)
@ -19,9 +19,6 @@ SET import_method = 'imap'
WHERE import_method IS NULL;
-- Add constraint to ensure valid values
ALTER TABLE email_messages
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 IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
ALTER TABLE conversations ADD COLUMN 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 IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
ALTER TABLE conversations ADD COLUMN 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 IF NOT EXISTS idx_sag_kommentarer_sag_id ON sag_kommentarer(sag_id);
CREATE INDEX idx_sag_kommentarer_sag_id ON sag_comments(sag_id);

View File

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

View File

@ -1,39 +0,0 @@
-- 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

@ -1,10 +0,0 @@
-- 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

@ -1,51 +0,0 @@
-- 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

@ -1,101 +0,0 @@
-- 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

@ -1,148 +0,0 @@
-- 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

@ -1,5 +0,0 @@
-- 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);

View File

@ -1,159 +0,0 @@
#!/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())

View File

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