Compare commits

...

12 Commits

Author SHA1 Message Date
Christian
a8eaf6e2a9 feat: enhance tag management and search functionality
- Updated the index.html template to include a new column for "Næste todo" in the sag table.
- Added new JavaScript functions to load and manage case statuses in settings.html, including normalization and rendering of statuses.
- Introduced a new tag search feature in tags_admin.html, allowing users to filter tags by name, type, and module with pagination support.
- Enhanced the backend router.py to include a new endpoint for listing tag usage across modules with server-side filtering and pagination.
- Improved the overall UI and UX of the tag administration page, including responsive design adjustments and better error handling.
2026-03-20 18:43:45 +01:00
Christian
92b888b78f Add migrations for seeding tags and enhancing todo steps
- Created migration 146 to seed case type tags with various categories and keywords.
- Created migration 147 to seed brand and type tags, including a comprehensive list of brands and case types.
- Added migration 148 to introduce a new column `is_next` in `sag_todo_steps` for persistent next-task selection.
- Implemented a new script `run_migrations.py` to facilitate running SQL migrations against the PostgreSQL database with options for dry runs and error handling.
2026-03-20 00:24:58 +01:00
Christian
dcae962481 release: v2.2.65 fix AI prompt tests and case email threading 2026-03-18 13:49:33 +01:00
Christian
243e4375e0 Add QuickCreate heuristic fallback when AI unavailable 2026-03-18 10:29:45 +01:00
Christian
153eb728e2 Fix QuickCreate AI request payload 2026-03-18 10:25:47 +01:00
Christian
73803f894b Fix SAG detail right column nesting 2026-03-18 09:58:31 +01:00
Christian
60d692c085 Fix SAG tab pane top rendering fallback 2026-03-18 09:46:33 +01:00
Christian
beaea0288c release: v2.2.60 enforce active sag tab top view 2026-03-18 09:29:57 +01:00
Christian
e07932f2cc release: v2.2.59 robust sag tab content scrolling 2026-03-18 08:57:29 +01:00
Christian
7a95623094 release: v2.2.58 sag tab top-position UX 2026-03-18 08:36:54 +01:00
Christian
9a3ada380f release: v2.2.57 email+sag tab stability 2026-03-18 07:33:32 +01:00
Christian
eb5e14e2a1 release: v2.2.56 email layout + supplier invoice stabilization 2026-03-18 07:14:28 +01:00
55 changed files with 4893 additions and 855 deletions

22
RELEASE_NOTES_v2.2.56.md Normal file
View File

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

18
RELEASE_NOTES_v2.2.57.md Normal file
View File

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

15
RELEASE_NOTES_v2.2.58.md Normal file
View File

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

16
RELEASE_NOTES_v2.2.59.md Normal file
View File

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

17
RELEASE_NOTES_v2.2.60.md Normal file
View File

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

15
RELEASE_NOTES_v2.2.61.md Normal file
View File

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

14
RELEASE_NOTES_v2.2.62.md Normal file
View File

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

14
RELEASE_NOTES_v2.2.63.md Normal file
View File

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

18
RELEASE_NOTES_v2.2.64.md Normal file
View File

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

View File

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

View File

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

View File

@ -1703,6 +1703,10 @@ async def delete_supplier_invoice(invoice_id: int):
class ApproveRequest(BaseModel): class ApproveRequest(BaseModel):
approved_by: str approved_by: str
class MarkPaidRequest(BaseModel):
paid_date: Optional[date] = None
@router.post("/supplier-invoices/{invoice_id}/approve") @router.post("/supplier-invoices/{invoice_id}/approve")
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest): async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
"""Approve supplier invoice for payment""" """Approve supplier invoice for payment"""
@ -1735,6 +1739,58 @@ async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
raise HTTPException(status_code=500, detail=str(e)) 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") @router.post("/supplier-invoices/{invoice_id}/send-to-economic")
async def send_to_economic(invoice_id: int): async def send_to_economic(invoice_id: int):
""" """
@ -2204,7 +2260,7 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
@router.post("/supplier-invoices/{invoice_id}/send-to-economic") @router.post("/supplier-invoices/{invoice_id}/send-to-economic-legacy-unimplemented")
async def send_invoice_to_economic(invoice_id: int): async def send_invoice_to_economic(invoice_id: int):
"""Send supplier invoice to e-conomic - requires separate implementation""" """Send supplier invoice to e-conomic - requires separate implementation"""
raise HTTPException(status_code=501, detail="e-conomic integration kommer senere") raise HTTPException(status_code=501, detail="e-conomic integration kommer senere")

View File

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

View File

@ -15,6 +15,21 @@ logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False) 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( async def get_current_user(
request: Request, request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
@ -70,9 +85,11 @@ async def get_current_user(
} }
# Get additional user details from database # 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( user_details = execute_query_single(
"SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s", f"SELECT email, full_name, {is_2fa_expr} FROM users WHERE user_id = %s",
(user_id,)) (user_id,),
)
return { return {
"id": user_id, "id": user_id,

View File

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

View File

@ -6,6 +6,7 @@ PostgreSQL connection and helpers using psycopg2
import psycopg2 import psycopg2
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from psycopg2.pool import SimpleConnectionPool from psycopg2.pool import SimpleConnectionPool
from functools import lru_cache
from typing import Optional from typing import Optional
import logging import logging
@ -128,3 +129,34 @@ def execute_query_single(query: str, params: tuple = None):
"""Execute query and return single row (backwards compatibility for fetchone=True)""" """Execute query and return single row (backwards compatibility for fetchone=True)"""
result = execute_query(query, params) result = execute_query(query, params)
return result[0] if result and len(result) > 0 else None 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,10 +125,24 @@ async def dashboard(request: Request):
from app.core.database import execute_query from app.core.database import execute_query
result = execute_query_single(unknown_query) try:
unknown_count = result['count'] if result else 0 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
raw_alerts = execute_query(bankruptcy_query) or [] try:
raw_alerts = execute_query(bankruptcy_query) or []
except Exception as exc:
if "email_messages" in str(exc):
logger.warning("⚠️ email_messages table not found; skipping bankruptcy alerts")
raw_alerts = []
else:
raise
bankruptcy_alerts = [] bankruptcy_alerts = []
for alert in raw_alerts: for alert in raw_alerts:

View File

@ -12,6 +12,10 @@
overflow: hidden; overflow: hidden;
} }
.email-container > * {
min-width: 0;
}
/* Left Sidebar - Email List (25%) */ /* Left Sidebar - Email List (25%) */
.email-list-sidebar { .email-list-sidebar {
flex: 0 0 400px; flex: 0 0 400px;
@ -210,6 +214,7 @@
/* Center Pane - Email Content (50%) */ /* Center Pane - Email Content (50%) */
.email-content-pane { .email-content-pane {
flex: 1; flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-card); background: var(--bg-card);
@ -220,6 +225,7 @@
.email-content-header { .email-content-header {
padding: 1.5rem; padding: 1.5rem;
border-bottom: 1px solid rgba(0,0,0,0.1); border-bottom: 1px solid rgba(0,0,0,0.1);
min-width: 0;
} }
.email-content-subject { .email-content-subject {
@ -227,6 +233,8 @@
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 1rem; margin-bottom: 1rem;
overflow-wrap: anywhere;
word-break: break-word;
} }
.email-content-meta { .email-content-meta {
@ -234,17 +242,20 @@
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
min-width: 0;
} }
.sender-info { .sender-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
min-width: 0;
} }
.sender-details { .sender-details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
} }
.sender-name { .sender-name {
@ -255,6 +266,8 @@
.sender-email { .sender-email {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-secondary); color: var(--text-secondary);
overflow-wrap: anywhere;
word-break: break-word;
} }
.email-timestamp { .email-timestamp {
@ -267,6 +280,14 @@
border-bottom: 1px solid rgba(0,0,0,0.05); border-bottom: 1px solid rgba(0,0,0,0.05);
background: var(--bg-body); background: var(--bg-body);
flex-wrap: wrap; flex-wrap: wrap;
overflow-x: hidden;
}
.attachment-chip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.email-actions .btn-primary { .email-actions .btn-primary {
@ -284,7 +305,56 @@
flex: 1; flex: 1;
padding: 1.5rem; padding: 1.5rem;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
line-height: 1.6; 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 { .email-body iframe {
@ -307,6 +377,14 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
transition: all 0.2s; transition: all 0.2s;
color: var(--text-primary); 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 { .attachment-item:hover {
@ -342,6 +420,7 @@
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
overflow-y: auto; overflow-y: auto;
min-width: 0;
} }
.analysis-card { .analysis-card {
@ -1813,12 +1892,12 @@ function renderEmailDetail(email) {
return ` return `
${canPreview ? ` ${canPreview ? `
<button onclick="previewAttachment(${email.id}, ${att.id}, '${escapeHtml(att.filename)}', '${att.content_type}')" <button onclick="previewAttachment(${email.id}, ${att.id}, '${escapeHtml(att.filename)}', '${att.content_type}')"
class="btn btn-sm btn-outline-primary" title="Se ${att.filename}"> class="btn btn-sm btn-outline-primary attachment-chip" title="Se ${att.filename}">
<i class="bi bi-eye me-1"></i>${att.filename} <i class="bi bi-eye me-1"></i>${att.filename}
</button> </button>
` : ` ` : `
<a href="/api/v1/emails/${email.id}/attachments/${att.id}" <a href="/api/v1/emails/${email.id}/attachments/${att.id}"
class="btn btn-sm btn-outline-secondary" class="btn btn-sm btn-outline-secondary attachment-chip"
download="${att.filename}" download="${att.filename}"
title="Download ${att.filename}"> title="Download ${att.filename}">
<i class="bi bi-download me-1"></i>${att.filename} <i class="bi bi-download me-1"></i>${att.filename}
@ -1838,10 +1917,40 @@ function renderEmailDetail(email) {
// If HTML, inject it as innerHTML after rendering // If HTML, inject it as innerHTML after rendering
if (email.body_html) { if (email.body_html) {
const htmlDiv = pane.querySelector('.email-html-body'); const htmlDiv = pane.querySelector('.email-html-body');
if (htmlDiv) htmlDiv.innerHTML = email.body_html; if (htmlDiv) {
htmlDiv.innerHTML = email.body_html;
normalizeEmailHtmlLayout(htmlDiv);
}
} }
} }
function normalizeEmailHtmlLayout(container) {
if (!container) return;
const tables = container.querySelectorAll('table');
tables.forEach((table) => {
table.style.maxWidth = '100%';
table.style.width = '100%';
table.style.tableLayout = 'auto';
table.removeAttribute('width');
});
const cells = container.querySelectorAll('td, th');
cells.forEach((cell) => {
cell.style.whiteSpace = 'normal';
cell.style.wordBreak = 'break-word';
cell.style.overflowWrap = 'anywhere';
});
const images = container.querySelectorAll('img, iframe, video');
images.forEach((el) => {
el.style.maxWidth = '100%';
if (el.tagName === 'IMG' || el.tagName === 'VIDEO') {
el.style.height = 'auto';
}
});
}
function renderEmailAnalysis(email) { function renderEmailAnalysis(email) {
const aiAnalysisTab = document.getElementById('aiAnalysisTab'); const aiAnalysisTab = document.getElementById('aiAnalysisTab');
if (!aiAnalysisTab) { if (!aiAnalysisTab) {

View File

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

View File

@ -1,6 +1,7 @@
import logging import logging
import os import os
import shutil import shutil
import json
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
@ -50,15 +51,64 @@ def _get_user_id_from_request(request: Request) -> int:
def _normalize_case_status(status_value: Optional[str]) -> str: 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: if not status_value:
return "åben" return allowed_map.get("åben", allowed_statuses[0])
normalized = str(status_value).strip().lower() normalized = str(status_value).strip().lower()
if normalized == "afventer": if normalized in allowed_map:
return "åben" return allowed_map[normalized]
if normalized in {"åben", "lukket"}:
return normalized # Backward compatibility for legacy mapping
return "åben" if normalized == "afventer" and "åben" in allowed_map:
return allowed_map["åben"]
# Do not force unknown values back to default; preserve user-entered/custom DB values
raw_value = str(status_value).strip()
if raw_value:
return raw_value
return allowed_map.get("åben", allowed_statuses[0])
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]: def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
@ -122,6 +172,8 @@ class SagSendEmailRequest(BaseModel):
bcc: List[str] = Field(default_factory=list) bcc: List[str] = Field(default_factory=list)
body_html: Optional[str] = None body_html: Optional[str] = None
attachment_file_ids: List[int] = Field(default_factory=list) 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]: def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
@ -409,7 +461,7 @@ async def list_todo_steps(sag_id: int):
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id LEFT JOIN users u_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 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 WHERE t.sag_id = %s AND t.deleted_at IS NULL
ORDER BY t.is_done ASC, t.due_date NULLS LAST, t.created_at DESC ORDER BY t.is_done ASC, t.is_next DESC, t.due_date NULLS LAST, t.created_at DESC
""" """
return execute_query(query, (sag_id,)) or [] return execute_query(query, (sag_id,)) or []
except Exception as e: except Exception as e:
@ -464,34 +516,63 @@ async def create_todo_step(sag_id: int, request: Request, data: TodoStepCreate):
@router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep) @router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep)
async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate): async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate):
try: try:
if data.is_done is None: if data.is_done is None and data.is_next is None:
raise HTTPException(status_code=400, detail="is_done is required") raise HTTPException(status_code=400, detail="Provide is_done or is_next")
user_id = _get_user_id_from_request(request) step_row = execute_query_single(
if data.is_done: "SELECT id, sag_id, is_done FROM sag_todo_steps WHERE id = %s AND deleted_at IS NULL",
update_query = """ (step_id,)
UPDATE sag_todo_steps )
SET is_done = TRUE, if not step_row:
completed_by_user_id = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(update_query, (user_id, step_id))
else:
update_query = """
UPDATE sag_todo_steps
SET is_done = FALSE,
completed_by_user_id = NULL,
completed_at = NULL
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(update_query, (step_id,))
if not result:
raise HTTPException(status_code=404, detail="Todo step not found") raise HTTPException(status_code=404, detail="Todo step not found")
if data.is_done is not None:
user_id = _get_user_id_from_request(request)
if data.is_done:
update_query = """
UPDATE sag_todo_steps
SET is_done = TRUE,
is_next = FALSE,
completed_by_user_id = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
execute_query(update_query, (user_id, step_id))
else:
update_query = """
UPDATE sag_todo_steps
SET is_done = FALSE,
completed_by_user_id = NULL,
completed_at = NULL
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
execute_query(update_query, (step_id,))
if data.is_next is not None:
if step_row.get("is_done") and data.is_next:
raise HTTPException(status_code=400, detail="Completed todo cannot be marked as next")
if data.is_next:
execute_query(
"""
UPDATE sag_todo_steps
SET is_next = FALSE
WHERE sag_id = %s AND deleted_at IS NULL
""",
(step_row["sag_id"],)
)
execute_query(
"""
UPDATE sag_todo_steps
SET is_next = %s
WHERE id = %s AND deleted_at IS NULL
""",
(bool(data.is_next), step_id)
)
return execute_query( return execute_query(
""" """
SELECT SELECT
@ -550,8 +631,12 @@ async def update_sag(sag_id: int, updates: dict):
updates["status"] = _normalize_case_status(updates.get("status")) updates["status"] = _normalize_case_status(updates.get("status"))
if "deadline" in updates: if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline") 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: if "deferred_until" in updates:
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until") 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: if "ansvarlig_bruger_id" in updates:
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id") updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
_validate_user_id(updates["ansvarlig_bruger_id"]) _validate_user_id(updates["ansvarlig_bruger_id"])
@ -567,6 +652,8 @@ async def update_sag(sag_id: int, updates: dict):
"status", "status",
"ansvarlig_bruger_id", "ansvarlig_bruger_id",
"assigned_group_id", "assigned_group_id",
"priority",
"start_date",
"deadline", "deadline",
"deferred_until", "deferred_until",
"deferred_until_case_id", "deferred_until_case_id",
@ -2199,6 +2286,42 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
"file_path": str(path), "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() email_service = EmailService()
success, send_message, generated_message_id = await email_service.send_email_with_attachments( success, send_message, generated_message_id = await email_service.send_email_with_attachments(
to_addresses=to_addresses, to_addresses=to_addresses,
@ -2207,6 +2330,8 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
body_html=payload.body_html, body_html=payload.body_html,
cc=cc_addresses, cc=cc_addresses,
bcc=bcc_addresses, bcc=bcc_addresses,
in_reply_to=in_reply_to_header,
references=references_header,
attachments=smtp_attachments, attachments=smtp_attachments,
respect_dry_run=False, respect_dry_run=False,
) )
@ -2218,36 +2343,72 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub" sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or "" sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
insert_email_query = """ insert_result = None
INSERT INTO email_messages ( try:
message_id, subject, sender_email, sender_name, insert_email_query = """
recipient_email, cc, body_text, body_html, INSERT INTO email_messages (
received_date, folder, has_attachments, attachment_count, message_id, subject, sender_email, sender_name,
status, import_method, linked_case_id 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,
),
) )
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: if not insert_result:
logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id) logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id)
@ -2286,9 +2447,11 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
) )
logger.info( logger.info(
"✅ Outbound case email sent and linked (case=%s, email_id=%s, recipients=%s)", "✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)",
sag_id, sag_id,
email_id, email_id,
payload.thread_email_id,
payload.thread_key,
", ".join(to_addresses), ", ".join(to_addresses),
) )
return { return {

View File

@ -1,4 +1,5 @@
import logging import logging
import json
from datetime import date, datetime from datetime import date, datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
@ -56,6 +57,50 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
return None 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) @router.get("/sag", response_class=HTMLResponse)
async def sager_liste( async def sager_liste(
request: Request, request: Request,
@ -77,7 +122,9 @@ async def sager_liste(
c.name as customer_name, c.name as customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn, CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name g.name AS assigned_group_name,
nt.title AS next_todo_title,
nt.due_date AS next_todo_due_date
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
@ -90,6 +137,22 @@ async def sager_liste(
LIMIT 1 LIMIT 1
) cc_first ON true ) cc_first ON true
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id 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 LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
""" """
@ -162,7 +225,11 @@ async def sager_liste(
sager = [s for s in sager if s['id'] in tagged_ids] sager = [s for s in sager if s['id'] in tagged_ids]
# Fetch all distinct statuses and tags for filters # Fetch all distinct statuses and tags for filters
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) status_options = _fetch_case_status_options()
current_status = str(status or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ()) 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( toggle_include_deferred_url = str(
@ -174,7 +241,7 @@ async def sager_liste(
"sager": sager, "sager": sager,
"relations_map": relations_map, "relations_map": relations_map,
"child_ids": list(child_ids), "child_ids": list(child_ids),
"statuses": [s['status'] for s in statuses], "statuses": status_options,
"all_tags": [t['tag_navn'] for t in all_tags], "all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status, "current_status": status,
"current_tag": tag, "current_tag": tag,
@ -451,7 +518,10 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.warning("⚠️ Could not load pipeline stages: %s", e) logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = [] pipeline_stages = []
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) status_options = _fetch_case_status_options()
current_status = str(sag.get("status") or "").strip()
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail.html", { return templates.TemplateResponse("modules/sag/templates/detail.html", {
@ -475,7 +545,7 @@ async def sag_detaljer(request: Request, sag_id: int):
"nextcloud_instance": nextcloud_instance, "nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options, "related_case_options": related_case_options,
"pipeline_stages": pipeline_stages, "pipeline_stages": pipeline_stages,
"status_options": [s["status"] for s in statuses], "status_options": status_options,
"is_deadline_overdue": is_deadline_overdue, "is_deadline_overdue": is_deadline_overdue,
"assignment_users": _fetch_assignment_users(), "assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(), "assignment_groups": _fetch_assignment_groups(),

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -1026,6 +1026,8 @@ class EmailService:
cc: Optional[List[str]] = None, cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None, bcc: Optional[List[str]] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
in_reply_to: Optional[str] = None,
references: Optional[str] = None,
attachments: Optional[List[Dict]] = None, attachments: Optional[List[Dict]] = None,
respect_dry_run: bool = True, respect_dry_run: bool = True,
) -> Tuple[bool, str, str]: ) -> Tuple[bool, str, str]:
@ -1060,6 +1062,10 @@ class EmailService:
msg['Cc'] = ', '.join(cc) msg['Cc'] = ', '.join(cc)
if reply_to: if reply_to:
msg['Reply-To'] = 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 = MIMEMultipart('alternative')
content_part.attach(MIMEText(body_text, 'plain')) content_part.attach(MIMEText(body_text, 'plain'))

View File

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

View File

@ -1143,6 +1143,33 @@ async def scan_document(file_path: str):
</div> </div>
</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="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
@ -1662,6 +1689,8 @@ async function loadSettings() {
displaySettingsByCategory(); displaySettingsByCategory();
renderTelefoniSettings(); renderTelefoniSettings();
await loadCaseTypesSetting(); await loadCaseTypesSetting();
await loadCaseStatusesSetting();
await loadTagsManagement();
await loadNextcloudInstances(); await loadNextcloudInstances();
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
@ -2031,6 +2060,132 @@ const CASE_MODULE_LABELS = {
}; };
let caseTypeModuleDefaultsCache = {}; 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) { function normalizeCaseTypeModuleDefaults(raw, caseTypes) {
const normalized = {}; const normalized = {};
@ -3085,6 +3240,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
// Load data for tab // Load data for tab
if (tab === 'users') { if (tab === 'users') {
loadUsers(); loadUsers();
} else if (tab === 'tags') {
loadTagsManagement();
} else if (tab === 'telefoni') { } else if (tab === 'telefoni') {
renderTelefoniSettings(); renderTelefoniSettings();
} else if (tab === 'ai-prompts') { } else if (tab === 'ai-prompts') {
@ -3159,13 +3316,19 @@ let showInactive = false;
async function loadTagsManagement() { async function loadTagsManagement() {
try { try {
const response = await fetch('/api/v1/tags'); const response = await fetch('/api/v1/tags');
if (!response.ok) throw new Error('Failed to load tags'); if (!response.ok) {
const msg = await getErrorMessage(response, 'Kunne ikke indlæse tags');
throw new Error(msg);
}
allTagsData = await response.json(); allTagsData = await response.json();
updateTagsStats(); updateTagsStats();
renderTagsGrid(); renderTagsGrid();
} catch (error) { } catch (error) {
console.error('Error loading tags:', error); console.error('Error loading tags:', error);
showNotification('Fejl ved indlæsning af tags', 'error'); allTagsData = [];
updateTagsStats();
renderTagsGrid();
showNotification('Fejl ved indlæsning af tags: ' + (error.message || 'ukendt fejl'), 'error');
} }
} }

View File

@ -253,6 +253,7 @@
<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="/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><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><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> <li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
</ul> </ul>
</li> </li>
@ -281,21 +282,6 @@
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li> <li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li> <li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
<li><hr class="dropdown-divider"></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> <li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
</ul> </ul>
</li> </li>
@ -306,6 +292,19 @@
</li> </li>
</ul> </ul>
<div class="d-flex align-items-center gap-3"> <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)"> <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> <i class="bi bi-plus-circle-fill fs-5"></i>
</button> </button>
@ -321,6 +320,7 @@
<ul class="dropdown-menu dropdown-menu-end mt-2"> <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="#" 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="/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="/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><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> <li><hr class="dropdown-divider"></li>

View File

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

View File

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

View File

@ -1,8 +1,10 @@
""" """
Tag system API endpoints Tag system API endpoints
""" """
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional from typing import List, Optional
import json
import re
from app.tags.backend.models import ( from app.tags.backend.models import (
Tag, TagCreate, TagUpdate, Tag, TagCreate, TagUpdate,
EntityTag, EntityTagCreate, EntityTag, EntityTagCreate,
@ -14,6 +16,197 @@ from app.core.database import execute_query, execute_query_single, execute_updat
router = APIRouter(prefix="/tags") 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 ============= # ============= TAG GROUPS =============
@router.get("/groups", response_model=List[TagGroup]) @router.get("/groups", response_model=List[TagGroup])
@ -34,13 +227,131 @@ async def create_tag_group(group: TagGroupCreate):
# ============= TAG CRUD ============= # ============= 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]) @router.get("", response_model=List[Tag])
async def list_tags( async def list_tags(
type: Optional[TagType] = None, type: Optional[TagType] = None,
is_active: Optional[bool] = None is_active: Optional[bool] = None
): ):
"""List all tags with optional filtering""" """List all tags with optional filtering"""
query = "SELECT * FROM tags WHERE 1=1" query = """
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE 1=1
"""
params = [] params = []
if type: if type:
@ -54,32 +365,52 @@ async def list_tags(
query += " ORDER BY type, name" query += " ORDER BY type, name"
results = execute_query(query, tuple(params) if params else ()) results = execute_query(query, tuple(params) if params else ())
return results return [_tag_row_to_response(row) for row in (results or [])]
@router.get("/{tag_id}", response_model=Tag) @router.get("/{tag_id}", response_model=Tag)
async def get_tag(tag_id: int): async def get_tag(tag_id: int):
"""Get single tag by ID""" """Get single tag by ID"""
result = execute_query_single( result = execute_query_single(
"SELECT * FROM tags WHERE id = %s", """
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE id = %s
""",
(tag_id,) (tag_id,)
) )
if not result: if not result:
raise HTTPException(status_code=404, detail="Tag not found") raise HTTPException(status_code=404, detail="Tag not found")
return result return _tag_row_to_response(result)
@router.post("", response_model=Tag) @router.post("", response_model=Tag)
async def create_tag(tag: TagCreate): async def create_tag(tag: TagCreate):
"""Create new tag""" """Create new tag"""
query = """ query = """
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id) INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id, catch_words_json)
VALUES (%s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING * RETURNING id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
""" """
catch_words = _normalize_catch_words(tag.catch_words)
result = execute_query_single( result = execute_query_single(
query, query,
(tag.name, tag.type, tag.description, tag.color, tag.icon, tag.is_active, tag.tag_group_id) (
tag.name,
tag.type,
tag.description,
tag.color,
tag.icon,
tag.is_active,
tag.tag_group_id,
json.dumps(catch_words),
)
) )
return result if not result:
raise HTTPException(status_code=500, detail="Failed to create tag")
return _tag_row_to_response(result)
@router.put("/{tag_id}", response_model=Tag) @router.put("/{tag_id}", response_model=Tag)
async def update_tag(tag_id: int, tag: TagUpdate): async def update_tag(tag_id: int, tag: TagUpdate):
@ -106,6 +437,9 @@ async def update_tag(tag_id: int, tag: TagUpdate):
if tag.tag_group_id is not None: if tag.tag_group_id is not None:
updates.append("tag_group_id = %s") updates.append("tag_group_id = %s")
params.append(tag.tag_group_id) 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: if not updates:
raise HTTPException(status_code=400, detail="No fields to update") raise HTTPException(status_code=400, detail="No fields to update")
@ -117,13 +451,15 @@ async def update_tag(tag_id: int, tag: TagUpdate):
UPDATE tags UPDATE tags
SET {', '.join(updates)} SET {', '.join(updates)}
WHERE id = %s WHERE id = %s
RETURNING * RETURNING id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
""" """
result = execute_query_single(query, tuple(params)) result = execute_query_single(query, tuple(params))
if not result: if not result:
raise HTTPException(status_code=404, detail="Tag not found") raise HTTPException(status_code=404, detail="Tag not found")
return result return _tag_row_to_response(result)
@router.delete("/{tag_id}") @router.delete("/{tag_id}")
async def delete_tag(tag_id: int): async def delete_tag(tag_id: int):
@ -214,20 +550,91 @@ async def remove_tag_from_entity_path(
async def get_entity_tags(entity_type: str, entity_id: int): async def get_entity_tags(entity_type: str, entity_id: int):
"""Get all tags for a specific entity""" """Get all tags for a specific entity"""
query = """ query = """
SELECT t.* SELECT t.id, t.name, t.type, t.description, t.color, t.icon, t.is_active, t.tag_group_id,
COALESCE(t.catch_words_json, '[]'::jsonb) AS catch_words,
t.created_at, t.updated_at
FROM tags t FROM tags t
JOIN entity_tags et ON et.tag_id = t.id JOIN entity_tags et ON et.tag_id = t.id
WHERE et.entity_type = %s AND et.entity_id = %s WHERE et.entity_type = %s AND et.entity_id = %s
ORDER BY t.type, t.name ORDER BY t.type, t.name
""" """
results = execute_query(query, (entity_type, entity_id)) results = execute_query(query, (entity_type, entity_id))
return results return [_tag_row_to_response(row) for row in (results or [])]
@router.get("/entity/{entity_type}/{entity_id}/suggestions")
async def suggest_entity_tags(entity_type: str, entity_id: int):
"""Suggest tags based on catch words for brand/type tags."""
if entity_type != "case":
return []
case_row = execute_query_single(
"SELECT id, titel, beskrivelse, template_key FROM sag_sager WHERE id = %s",
(entity_id,),
)
if not case_row:
raise HTTPException(status_code=404, detail="Entity not found")
existing_rows = execute_query(
"SELECT tag_id FROM entity_tags WHERE entity_type = %s AND entity_id = %s",
(entity_type, entity_id),
) or []
existing_tag_ids = {int(row.get("tag_id")) for row in existing_rows if row.get("tag_id") is not None}
candidate_rows = execute_query(
"""
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE is_active = true
AND type IN ('brand', 'type')
ORDER BY type, name
""",
(),
) or []
haystack = " ".join(
[
str(case_row.get("titel") or ""),
str(case_row.get("beskrivelse") or ""),
str(case_row.get("template_key") or ""),
]
).lower()
suggestions = []
for row in candidate_rows:
tag_id = int(row.get("id"))
if tag_id in existing_tag_ids:
continue
catch_words = _normalize_catch_words(row.get("catch_words"))
if not catch_words:
continue
matched_words = [word for word in catch_words if word in haystack]
if not matched_words:
continue
suggestions.append(
{
"tag": _tag_row_to_response(row),
"matched_words": matched_words,
"score": len(matched_words),
}
)
suggestions.sort(key=lambda item: (-item["score"], item["tag"]["type"], item["tag"]["name"]))
return suggestions
@router.get("/search") @router.get("/search")
async def search_tags(q: str, type: Optional[TagType] = None): async def search_tags(q: str, type: Optional[TagType] = None):
"""Search tags by name (fuzzy search)""" """Search tags by name (fuzzy search)"""
query = """ query = """
SELECT * FROM tags SELECT id, name, type, description, color, icon, is_active, tag_group_id,
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
created_at, updated_at
FROM tags
WHERE is_active = true WHERE is_active = true
AND LOWER(name) LIKE LOWER(%s) AND LOWER(name) LIKE LOWER(%s)
""" """
@ -240,7 +647,7 @@ async def search_tags(q: str, type: Optional[TagType] = None):
query += " ORDER BY name LIMIT 20" query += " ORDER BY name LIMIT 20"
results = execute_query(query, tuple(params)) results = execute_query(query, tuple(params))
return results return [_tag_row_to_response(row) for row in (results or [])]
# ============= WORKFLOW MANAGEMENT ============= # ============= WORKFLOW MANAGEMENT =============

View File

@ -1,11 +1,8 @@
<!DOCTYPE html> {% extends "shared/frontend/base.html" %}
<html lang="da">
<head> {% block title %}Tag Administration - BMC Hub{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block extra_css %}
<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> <style>
:root { :root {
--primary-color: #0f4c75; --primary-color: #0f4c75;
@ -14,6 +11,8 @@
--category-color: #0f4c75; --category-color: #0f4c75;
--priority-color: #dc3545; --priority-color: #dc3545;
--billing-color: #2d6a4f; --billing-color: #2d6a4f;
--brand-color: #006d77;
--type-color: #5c677d;
} }
.tag-badge { .tag-badge {
@ -37,6 +36,8 @@
.tag-type-category { background-color: var(--category-color); color: white; } .tag-type-category { background-color: var(--category-color); color: white; }
.tag-type-priority { background-color: var(--priority-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-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 { .tag-list-item {
padding: 1rem; padding: 1rem;
@ -53,6 +54,8 @@
.tag-list-item[data-type="category"] { border-left-color: var(--category-color); } .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="priority"] { border-left-color: var(--priority-color); }
.tag-list-item[data-type="billing"] { border-left-color: var(--billing-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 { .color-preview {
width: 40px; width: 40px;
@ -60,9 +63,68 @@
border-radius: 8px; border-radius: 8px;
border: 2px solid #dee2e6; 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> </style>
</head> {% endblock %}
<body>
{% block content %}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <div class="col">
@ -76,6 +138,17 @@
</div> </div>
</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 --> <!-- Type Filter Tabs -->
<ul class="nav nav-tabs mb-4" id="typeFilter"> <ul class="nav nav-tabs mb-4" id="typeFilter">
<li class="nav-item"> <li class="nav-item">
@ -106,6 +179,16 @@
<span class="tag-badge tag-type-billing">Billing</span> <span class="tag-badge tag-type-billing">Billing</span>
</a> </a>
</li> </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> </ul>
<!-- Tags List --> <!-- Tags List -->
@ -120,6 +203,98 @@
</div> </div>
</div> </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> </div>
<!-- Create/Edit Tag Modal --> <!-- Create/Edit Tag Modal -->
@ -148,9 +323,17 @@
<option value="category">Category - Emne/område</option> <option value="category">Category - Emne/område</option>
<option value="priority">Priority - Hastighed</option> <option value="priority">Priority - Hastighed</option>
<option value="billing">Billing - Økonomi</option> <option value="billing">Billing - Økonomi</option>
<option value="brand">Brand - Leverandør/produktbrand</option>
<option value="type">Type - Sagstype/arbejdstype</option>
</select> </select>
</div> </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"> <div class="mb-3">
<label for="tagDescription" class="form-label">Beskrivelse</label> <label for="tagDescription" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="tagDescription" rows="3"></textarea> <textarea class="form-control" id="tagDescription" rows="3"></textarea>
@ -186,19 +369,59 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> {% block extra_js %}
<script> <script>
let allTags = []; let allTags = [];
let currentFilter = 'all'; 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 // Load tags on page load
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadTags(); loadTags();
loadTagUsage();
setupEventListeners(); 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() { function setupEventListeners() {
// Section tabs
document.querySelectorAll('#sectionTabs button').forEach(btn => {
btn.addEventListener('click', () => {
switchTagSection(btn.dataset.section);
});
});
// Type filter tabs // Type filter tabs
document.querySelectorAll('#typeFilter a').forEach(tab => { document.querySelectorAll('#typeFilter a').forEach(tab => {
tab.addEventListener('click', (e) => { tab.addEventListener('click', (e) => {
@ -229,7 +452,9 @@
'status': '#ffd700', 'status': '#ffd700',
'category': '#0f4c75', 'category': '#0f4c75',
'priority': '#dc3545', 'priority': '#dc3545',
'billing': '#2d6a4f' 'billing': '#2d6a4f',
'brand': '#006d77',
'type': '#5c677d'
}; };
if (colorMap[type]) { if (colorMap[type]) {
document.getElementById('tagColor').value = colorMap[type]; document.getElementById('tagColor').value = colorMap[type];
@ -240,6 +465,61 @@
// Save button // Save button
document.getElementById('saveTagBtn').addEventListener('click', saveTag); 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 // Modal reset on close
document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => { document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => {
document.getElementById('tagForm').reset(); document.getElementById('tagForm').reset();
@ -264,6 +544,131 @@
} }
} }
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function debounceUsageLoad() {
if (usageDebounceTimer) {
clearTimeout(usageDebounceTimer);
}
usageDebounceTimer = setTimeout(() => loadTagUsage(), 280);
}
function updateSortIndicators() {
document.querySelectorAll('.usage-sort-btn').forEach(btn => {
const icon = btn.querySelector('i');
if (!icon) return;
btn.classList.remove('active');
icon.className = 'bi bi-chevron-expand';
if (btn.dataset.sortBy === usageState.sort_by) {
btn.classList.add('active');
icon.className = usageState.sort_dir === 'asc' ? 'bi bi-sort-up' : 'bi bi-sort-down';
}
});
}
function renderUsageTable(items) {
const tbody = document.getElementById('usageTableBody');
if (!Array.isArray(items) || !items.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Ingen taggede rækker matcher filtrene.</td></tr>';
return;
}
tbody.innerHTML = items.map(row => {
const taggedAt = row.tagged_at ? new Date(row.tagged_at).toLocaleString('da-DK') : '-';
const color = /^#[0-9A-Fa-f]{6}$/.test(String(row.tag_color || '')) ? row.tag_color : '#0f4c75';
const inactiveBadge = row.tag_is_active ? '' : '<span class="badge bg-secondary ms-2">Inaktiv</span>';
const entityTitle = escapeHtml(row.entity_title || `#${row.entity_id || ''}`);
const entityCell = row.entity_url
? `<a href="${escapeHtml(row.entity_url)}" class="text-decoration-none fw-semibold">${entityTitle}</a>`
: `<span class="fw-semibold">${entityTitle}</span>`;
return `
<tr>
<td>
<span class="tag-badge" style="background:${color}; color:#fff; margin:0;">${escapeHtml(row.tag_name)}</span>
${inactiveBadge}
</td>
<td><span class="badge bg-light text-dark text-uppercase">${escapeHtml(row.tag_type)}</span></td>
<td><span class="module-badge"><i class="bi bi-box"></i>${escapeHtml(row.module)}</span></td>
<td>${entityCell}</td>
<td><span class="text-muted">${escapeHtml(row.entity_type)}</span></td>
<td><strong>#${escapeHtml(row.entity_id)}</strong></td>
<td class="small text-muted">${escapeHtml(taggedAt)}</td>
</tr>
`;
}).join('');
}
function renderUsageSummary() {
const summary = document.getElementById('usageSummary');
const prevBtn = document.getElementById('usagePrevBtn');
const nextBtn = document.getElementById('usageNextBtn');
const total = usageState.total;
const page = usageState.page;
const pageSize = usageState.page_size;
const from = total ? ((page - 1) * pageSize + 1) : 0;
const to = total ? Math.min(page * pageSize, total) : 0;
summary.textContent = `Viser ${from}-${to} af ${total} rækker`;
prevBtn.disabled = page <= 1;
nextBtn.disabled = page >= usageState.total_pages;
}
function fillModuleFilter(options) {
const select = document.getElementById('usageFilterModule');
const currentValue = usageState.filters.module;
const base = '<option value="">Alle moduler</option>';
const rows = (options || []).map(option => {
return `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`;
}).join('');
select.innerHTML = `${base}${rows}`;
select.value = currentValue || '';
}
async function loadTagUsage() {
const tbody = document.getElementById('usageTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Indlæser...</td></tr>';
try {
const params = new URLSearchParams({
page: String(usageState.page),
page_size: String(usageState.page_size),
sort_by: usageState.sort_by,
sort_dir: usageState.sort_dir
});
if (usageState.filters.tag_name) params.set('tag_name', usageState.filters.tag_name);
if (usageState.filters.tag_type) params.set('tag_type', usageState.filters.tag_type);
if (usageState.filters.module) params.set('module', usageState.filters.module);
const response = await fetch(`/api/v1/tags/usage?${params.toString()}`);
if (!response.ok) {
throw new Error('Kunne ikke hente tag søgning');
}
const payload = await response.json();
usageState.total = Number(payload?.pagination?.total || 0);
usageState.total_pages = Number(payload?.pagination?.total_pages || 0);
usageState.page = Number(payload?.pagination?.page || usageState.page);
fillModuleFilter(payload.module_options || []);
renderUsageTable(payload.items || []);
renderUsageSummary();
updateSortIndicators();
} catch (error) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">Fejl ved indlæsning af tag søgning: ${escapeHtml(error.message)}</td></tr>`;
document.getElementById('usageSummary').textContent = 'Fejl ved datahentning';
}
}
function renderTags() { function renderTags() {
const container = document.getElementById('tagsList'); const container = document.getElementById('tagsList');
const filteredTags = currentFilter === 'all' const filteredTags = currentFilter === 'all'
@ -293,6 +698,7 @@
${!tag.is_active ? '<span class="badge bg-secondary ms-2">Inaktiv</span>' : ''} ${!tag.is_active ? '<span class="badge bg-secondary ms-2">Inaktiv</span>' : ''}
</div> </div>
${tag.description ? `<p class="text-muted mb-0 small">${tag.description}</p>` : ''} ${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>
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})"> <button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})">
@ -315,7 +721,11 @@
description: document.getElementById('tagDescription').value || null, description: document.getElementById('tagDescription').value || null,
color: document.getElementById('tagColorHex').value, color: document.getElementById('tagColorHex').value,
icon: document.getElementById('tagIcon').value || null, icon: document.getElementById('tagIcon').value || null,
is_active: document.getElementById('tagActive').checked is_active: document.getElementById('tagActive').checked,
catch_words: document.getElementById('tagCatchWords').value
.split(/[\n,]+/)
.map(v => v.trim().toLowerCase())
.filter(v => v.length > 1)
}; };
try { try {
@ -352,6 +762,7 @@
document.getElementById('tagColorHex').value = tag.color; document.getElementById('tagColorHex').value = tag.color;
document.getElementById('tagIcon').value = tag.icon || ''; document.getElementById('tagIcon').value = tag.icon || '';
document.getElementById('tagActive').checked = tag.is_active; 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'; document.querySelector('#createTagModal .modal-title').textContent = 'Rediger Tag';
new bootstrap.Modal(document.getElementById('createTagModal')).show(); new bootstrap.Modal(document.getElementById('createTagModal')).show();
@ -374,5 +785,4 @@
} }
} }
</script> </script>
</body> {% endblock %}
</html>

View File

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

View File

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

View File

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

38
main.py
View File

@ -16,6 +16,29 @@ from app.core.database import init_db
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from app.core.database import execute_query_single 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(): def get_version():
"""Read version from VERSION file""" """Read version from VERSION file"""
try: try:
@ -265,11 +288,16 @@ async def auth_middleware(request: Request, call_next):
content={"detail": "Invalid token"} content={"detail": "Invalid token"}
) )
user_id = int(payload.get("sub")) user_id = int(payload.get("sub"))
user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s", if _users_column_exists("is_2fa_enabled"):
(user_id,) user = execute_query_single(
) "SELECT COALESCE(is_2fa_enabled, FALSE) AS is_2fa_enabled FROM users WHERE user_id = %s",
is_2fa_enabled = bool(user and user.get("is_2fa_enabled")) (user_id,),
)
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
else:
# Older schemas without 2FA columns should not block authenticated requests.
is_2fa_enabled = False
if not is_2fa_enabled: if not is_2fa_enabled:
allowed_2fa_paths = ( allowed_2fa_paths = (

View File

@ -37,7 +37,7 @@ CREATE TABLE email_rules (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_user_id INTEGER, created_by_user_id INTEGER,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) ON DELETE SET NULL
); );
-- Email Messages Table (main storage) -- Email Messages Table (main storage)
@ -183,7 +183,7 @@ SELECT
COUNT(ea.id) as attachment_count_actual, COUNT(ea.id) as attachment_count_actual,
er.name as rule_name, er.name as rule_name,
v.name as supplier_name, v.name as supplier_name,
tc.customer_name, tc.name as customer_name,
tcase.title as case_title tcase.title as case_title
FROM email_messages em FROM email_messages em
LEFT JOIN email_attachments ea ON em.id = ea.email_id 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 LEFT JOIN tmodule_cases tcase ON em.linked_case_id = tcase.id
WHERE em.deleted_at IS NULL WHERE em.deleted_at IS NULL
AND em.status IN ('new', 'error') AND em.status IN ('new', 'error')
GROUP BY em.id, er.name, v.name, tc.customer_name, tcase.title GROUP BY em.id, er.name, v.name, tc.name, tcase.title
ORDER BY em.received_date DESC; ORDER BY em.received_date DESC;
-- View for recent email activity -- 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) CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id)
); );
CREATE INDEX idx_tticket_relations_ticket ON tticket_relations(ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_relations_ticket ON tticket_relations(ticket_id);
CREATE INDEX idx_tticket_relations_related ON tticket_relations(related_ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_relations_related ON tticket_relations(related_ticket_id);
CREATE INDEX idx_tticket_relations_type ON tticket_relations(relation_type); CREATE INDEX IF NOT EXISTS idx_tticket_relations_type ON tticket_relations(relation_type);
-- View for at finde alle relationer for en ticket (begge retninger) -- View for at finde alle relationer for en ticket (begge retninger)
CREATE OR REPLACE VIEW tticket_all_relations AS CREATE OR REPLACE VIEW tticket_all_relations AS
@ -90,10 +90,10 @@ CREATE TABLE IF NOT EXISTS tticket_calendar_events (
completed_at TIMESTAMP completed_at TIMESTAMP
); );
CREATE INDEX idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
CREATE INDEX idx_tticket_calendar_date ON tticket_calendar_events(event_date); CREATE INDEX IF NOT EXISTS idx_tticket_calendar_date ON tticket_calendar_events(event_date);
CREATE INDEX idx_tticket_calendar_type ON tticket_calendar_events(event_type); CREATE INDEX IF NOT EXISTS idx_tticket_calendar_type ON tticket_calendar_events(event_type);
CREATE INDEX idx_tticket_calendar_status ON tticket_calendar_events(status); CREATE INDEX IF NOT EXISTS idx_tticket_calendar_status ON tticket_calendar_events(status);
-- ============================================================================ -- ============================================================================
-- TEMPLATES (svarskabeloner, guides, standardbreve) -- TEMPLATES (svarskabeloner, guides, standardbreve)
@ -128,8 +128,8 @@ CREATE TABLE IF NOT EXISTS tticket_templates (
usage_count INTEGER DEFAULT 0 usage_count INTEGER DEFAULT 0
); );
CREATE INDEX idx_tticket_templates_category ON tticket_templates(category); CREATE INDEX IF NOT EXISTS idx_tticket_templates_category ON tticket_templates(category);
CREATE INDEX idx_tticket_templates_active ON tticket_templates(is_active); CREATE INDEX IF NOT EXISTS idx_tticket_templates_active ON tticket_templates(is_active);
-- ============================================================================ -- ============================================================================
-- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt) -- 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? was_modified BOOLEAN DEFAULT false -- Blev template redigeret før afsendelse?
); );
CREATE INDEX idx_tticket_template_usage_template ON tticket_template_usage(template_id); CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_template ON tticket_template_usage(template_id);
CREATE INDEX idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
-- ============================================================================ -- ============================================================================
-- AI SUGGESTIONS (forslag til actions - aldrig automatisk) -- 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 expires_at TIMESTAMP -- Forslag udløber efter X dage
); );
CREATE INDEX idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id); CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
CREATE INDEX idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at); CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
-- ============================================================================ -- ============================================================================
-- EMAIL METADATA (udvidet til contact identification) -- EMAIL METADATA (udvidet til contact identification)
@ -227,9 +227,9 @@ CREATE TABLE IF NOT EXISTS tticket_email_metadata (
updated_at TIMESTAMP updated_at TIMESTAMP
); );
CREATE INDEX idx_tticket_email_ticket ON tticket_email_metadata(ticket_id); CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS idx_tticket_email_message_id ON tticket_email_metadata(message_id);
CREATE INDEX idx_tticket_email_from ON tticket_email_metadata(from_email); CREATE INDEX IF NOT EXISTS idx_tticket_email_from ON tticket_email_metadata(from_email);
-- ============================================================================ -- ============================================================================
-- Tilføj manglende kolonner til existing tticket_tickets -- Tilføj manglende kolonner til existing tticket_tickets
@ -265,9 +265,15 @@ CREATE TABLE IF NOT EXISTS tticket_audit_log (
metadata JSONB -- Additional context metadata JSONB -- Additional context
); );
CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id); ALTER TABLE tticket_audit_log
CREATE INDEX idx_tticket_audit_action ON tticket_audit_log(action); ADD COLUMN IF NOT EXISTS field_name VARCHAR(100),
CREATE INDEX idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC); ADD COLUMN IF NOT EXISTS performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN IF NOT EXISTS reason TEXT,
ADD COLUMN IF NOT EXISTS metadata JSONB;
CREATE INDEX IF NOT EXISTS idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
CREATE INDEX IF NOT EXISTS idx_tticket_audit_action ON tticket_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
-- ============================================================================ -- ============================================================================
-- TRIGGERS for audit logging -- TRIGGERS for audit logging

View File

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

View File

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

View File

@ -1,5 +1,5 @@
-- 069_conversation_category.sql -- 069_conversation_category.sql
-- Add category column for conversation classification -- Add category column for conversation classification
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General'; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting'; 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 -- 072_add_category_to_conversations.sql
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General'; ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)'; 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 deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
); );
CREATE INDEX idx_sag_kommentarer_sag_id ON sag_comments(sag_id); CREATE INDEX IF NOT EXISTS idx_sag_kommentarer_sag_id ON sag_kommentarer(sag_id);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

159
scripts/run_migrations.py Normal file
View File

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

View File

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