Compare commits
No commits in common. "main" and "v2.2.55" have entirely different histories.
@ -1,22 +0,0 @@
|
||||
# Release Notes v2.2.56
|
||||
|
||||
Dato: 2026-03-18
|
||||
|
||||
## Fokus
|
||||
Stabilisering af email-visning og hardening af supplier-invoices flows.
|
||||
|
||||
## Aendringer
|
||||
- Rettet layout-overflow i email-detaljevisning, saa lange emner, afsenderadresser, HTML-indhold og filnavne ikke skubber kolonnerne ud af layoutet.
|
||||
- Tilfoejet robust wrapping/truncering i emails UI for bedre responsiv opfoersel.
|
||||
- Tilfoejet manglende "Klar til Bogforing" tab i supplier-invoices navigation.
|
||||
- Rettet endpoint mismatch for AI template-analyse i supplier-invoices frontend.
|
||||
- Fjernet JS-funktionskonflikter i supplier-invoices ved at adskille single/bulk send flows.
|
||||
- Tilfoejet backend endpoint til at markere supplier-invoices som betalt.
|
||||
- Fjernet route-konflikt for send-to-economic ved at flytte legacy placeholder til separat sti.
|
||||
- Forbedret approve-flow ved at bruge dynamisk brugeropslag i stedet for hardcoded vaerdi.
|
||||
|
||||
## Berorte filer
|
||||
- app/emails/frontend/emails.html
|
||||
- app/billing/frontend/supplier_invoices.html
|
||||
- app/billing/backend/supplier_invoices.py
|
||||
- RELEASE_NOTES_v2.2.56.md
|
||||
@ -1,18 +0,0 @@
|
||||
# Release Notes v2.2.57
|
||||
|
||||
Dato: 2026-03-18
|
||||
|
||||
## Fokus
|
||||
Stabilisering af UI i Email- og SAG-modulerne.
|
||||
|
||||
## Aendringer
|
||||
- Email-visning: yderligere hardening af HTML-tabeller i mail-body, inklusive normalisering af inline styles for at undgaa layout break.
|
||||
- Email-visning: forbedret overflow-haandtering for bredt indhold (tabeller, celler og media).
|
||||
- SAG detaljeside: forbedret tab-loading, saa data hentes ved faneskift for Varekob & Salg, Abonnement og Paamindelser.
|
||||
- SAG detaljeside: robust fallback for reminder user-id via `/api/v1/auth/me`.
|
||||
- SAG detaljeside: rettet API-kald for reminders og kalender til stabil case-id reference.
|
||||
|
||||
## Berorte filer
|
||||
- app/emails/frontend/emails.html
|
||||
- app/modules/sag/templates/detail.html
|
||||
- RELEASE_NOTES_v2.2.57.md
|
||||
@ -1,15 +0,0 @@
|
||||
# Release Notes v2.2.58
|
||||
|
||||
Dato: 2026-03-18
|
||||
|
||||
## Fokus
|
||||
Forbedret UX paa SAG detaljesiden, saa fanernes indhold vises i toppen ved faneskift.
|
||||
|
||||
## Aendringer
|
||||
- SAG tabs: aktiv tab-pane flyttes til toppen af tab-content ved faneskift.
|
||||
- SAG tabs: automatisk scroll til fanebjaelken efter faneskift.
|
||||
- SAG tabs: samme top-positionering og scroll ved `?tab=` deep-link aktivering.
|
||||
|
||||
## Berorte filer
|
||||
- app/modules/sag/templates/detail.html
|
||||
- RELEASE_NOTES_v2.2.58.md
|
||||
@ -1,16 +0,0 @@
|
||||
# Release Notes v2.2.59
|
||||
|
||||
Dato: 2026-03-18
|
||||
|
||||
## Fokus
|
||||
Stabil scroll/navigation i SAG-faner, saa bruger lander ved reelt indhold i den valgte fane.
|
||||
|
||||
## Aendringer
|
||||
- Fjernet DOM-reordering af tab-pane elementer i SAG detaljesiden.
|
||||
- Ny scroll-logik: ved faneskift scrolles til foerste meningsfulde indholdselement i aktiv fane.
|
||||
- Scroll-offset tager hoejde for navbar-hoejde.
|
||||
- Deep-link (`?tab=...`) bruger nu samme robuste scroll-adfaerd.
|
||||
|
||||
## Berorte filer
|
||||
- app/modules/sag/templates/detail.html
|
||||
- RELEASE_NOTES_v2.2.59.md
|
||||
@ -1,17 +0,0 @@
|
||||
# Release Notes v2.2.60
|
||||
|
||||
Dato: 2026-03-18
|
||||
|
||||
## Fokus
|
||||
Korrekt top-visning af aktiv fane paa SAG detaljesiden.
|
||||
|
||||
## Aendringer
|
||||
- Tvang korrekt tab-pane synlighed i `#caseTabsContent`:
|
||||
- inaktive faner skjules (`display: none`)
|
||||
- kun aktiv fane vises (`display: block`)
|
||||
- Fjernet tidligere scroll/DOM-workaround til fanevisning.
|
||||
- Resultat: aktiv fane vises i toppen under fanebjaelken uden tom top-sektion.
|
||||
|
||||
## Berorte filer
|
||||
- app/modules/sag/templates/detail.html
|
||||
- RELEASE_NOTES_v2.2.60.md
|
||||
@ -1,15 +0,0 @@
|
||||
# Release Notes v2.2.61
|
||||
|
||||
Dato: 18. marts 2026
|
||||
|
||||
## Fixes
|
||||
|
||||
- Rettet SAG-fanevisning i sag-detaljesiden, så kun den aktive fane vises i toppen.
|
||||
- Tilføjet direkte klik-fallback på faneknapper (`onclick`) for robust aktivering, også hvis Bootstrap tab-events fejler.
|
||||
- Sat eksplicit start-visibility på tab-panes for at undgå "lang side"-effekten med indhold langt nede.
|
||||
- Fjernet to ødelagte CSS-blokke i toppen af templaten, som kunne skabe ustabil styling/parsing.
|
||||
|
||||
## Berørte filer
|
||||
|
||||
- `app/modules/sag/templates/detail.html`
|
||||
- `RELEASE_NOTES_v2.2.61.md`
|
||||
@ -1,14 +0,0 @@
|
||||
# Release Notes v2.2.62
|
||||
|
||||
Dato: 18. marts 2026
|
||||
|
||||
## Fixes
|
||||
|
||||
- Rettet grid/nesting i SAG detaljevisning, så højre kolonne ligger i samme row som venstre/center.
|
||||
- `Hardware`, `Salgspipeline`, `Opkaldshistorik` og `Todo-opgaver` vises nu i højre kolonne som forventet.
|
||||
- Fjernet en for tidlig afsluttende `</div>` i detaljer-layoutet, som tidligere fik højre modulkolonne til at falde ned under venstre indhold.
|
||||
|
||||
## Berørte filer
|
||||
|
||||
- `app/modules/sag/templates/detail.html`
|
||||
- `RELEASE_NOTES_v2.2.62.md`
|
||||
@ -1,14 +0,0 @@
|
||||
# Release Notes v2.2.63
|
||||
|
||||
Dato: 18. marts 2026
|
||||
|
||||
## Fixes
|
||||
|
||||
- Rettet QuickCreate AI-analyse request i frontend.
|
||||
- `POST /api/v1/sag/analyze-quick-create` får nu korrekt payload med både `text` og `user_id` i body.
|
||||
- Forbedret fejllog i frontend ved AI-fejl (inkl. HTTP status), så fejl ikke bliver skjult som generisk "Analysis failed".
|
||||
|
||||
## Berørte filer
|
||||
|
||||
- `app/shared/frontend/quick_create_modal.html`
|
||||
- `RELEASE_NOTES_v2.2.63.md`
|
||||
@ -1,18 +0,0 @@
|
||||
# Release Notes v2.2.64
|
||||
|
||||
Dato: 18. marts 2026
|
||||
|
||||
## Fixes
|
||||
|
||||
- Forbedret QuickCreate robusthed når AI/LLM er utilgængelig.
|
||||
- Tilføjet lokal heuristisk fallback i `CaseAnalysisService`, så brugeren stadig får:
|
||||
- foreslået titel
|
||||
- foreslået prioritet
|
||||
- simple tags
|
||||
- kunde-match forsøg
|
||||
- Fjernet afhængighed af at Ollama altid svarer, så QuickCreate ikke længere ender i tom AI-unavailable flow ved midlertidige AI-fejl.
|
||||
|
||||
## Berørte filer
|
||||
|
||||
- `app/services/case_analysis_service.py`
|
||||
- `RELEASE_NOTES_v2.2.64.md`
|
||||
@ -74,8 +74,6 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
|
||||
|
||||
requires_2fa_setup = (
|
||||
not user.get("is_shadow_admin", False)
|
||||
and not settings.AUTH_DISABLE_2FA
|
||||
and AuthService.is_2fa_supported()
|
||||
and not user.get("is_2fa_enabled", False)
|
||||
)
|
||||
|
||||
@ -141,18 +139,10 @@ async def setup_2fa(current_user: dict = Depends(get_current_user)):
|
||||
detail="Shadow admin cannot configure 2FA",
|
||||
)
|
||||
|
||||
try:
|
||||
result = AuthService.setup_user_2fa(
|
||||
user_id=current_user["id"],
|
||||
username=current_user["username"]
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
if "2FA columns missing" in str(exc):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA er ikke tilgaengelig i denne database (mangler kolonner).",
|
||||
)
|
||||
raise
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@ -25,26 +25,8 @@ class BackupService:
|
||||
"""Service for managing backup operations"""
|
||||
|
||||
def __init__(self):
|
||||
configured_backup_dir = Path(settings.BACKUP_STORAGE_PATH)
|
||||
self.backup_dir = configured_backup_dir
|
||||
try:
|
||||
self.backup_dir = Path(settings.BACKUP_STORAGE_PATH)
|
||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
# Local development can run outside Docker where /app is not writable.
|
||||
# Fall back to the workspace data path so app startup does not fail.
|
||||
if str(configured_backup_dir).startswith('/app/'):
|
||||
project_root = Path(__file__).resolve().parents[3]
|
||||
fallback_dir = project_root / 'data' / 'backups'
|
||||
logger.warning(
|
||||
"⚠️ Backup path %s not writable (%s). Using fallback %s",
|
||||
configured_backup_dir,
|
||||
exc,
|
||||
fallback_dir,
|
||||
)
|
||||
fallback_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.backup_dir = fallback_dir
|
||||
else:
|
||||
raise
|
||||
|
||||
# Subdirectories for different backup types
|
||||
self.db_dir = self.backup_dir / "database"
|
||||
|
||||
@ -1703,10 +1703,6 @@ async def delete_supplier_invoice(invoice_id: int):
|
||||
class ApproveRequest(BaseModel):
|
||||
approved_by: str
|
||||
|
||||
|
||||
class MarkPaidRequest(BaseModel):
|
||||
paid_date: Optional[date] = None
|
||||
|
||||
@router.post("/supplier-invoices/{invoice_id}/approve")
|
||||
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
||||
"""Approve supplier invoice for payment"""
|
||||
@ -1739,58 +1735,6 @@ async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/supplier-invoices/{invoice_id}/mark-paid")
|
||||
async def mark_supplier_invoice_paid(invoice_id: int, request: MarkPaidRequest):
|
||||
"""Mark supplier invoice as paid."""
|
||||
try:
|
||||
invoice = execute_query_single(
|
||||
"SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s",
|
||||
(invoice_id,)
|
||||
)
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
|
||||
|
||||
if invoice['status'] == 'paid':
|
||||
return {"success": True, "invoice_id": invoice_id, "status": "paid"}
|
||||
|
||||
if invoice['status'] not in ('approved', 'sent_to_economic'):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Faktura har status '{invoice['status']}' - "
|
||||
"kun 'approved' eller 'sent_to_economic' kan markeres som betalt"
|
||||
)
|
||||
)
|
||||
|
||||
execute_update(
|
||||
"""UPDATE supplier_invoices
|
||||
SET status = 'paid', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(invoice_id,)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Marked supplier invoice %s (ID: %s) as paid (date: %s)",
|
||||
invoice['invoice_number'],
|
||||
invoice_id,
|
||||
request.paid_date,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"invoice_id": invoice_id,
|
||||
"status": "paid",
|
||||
"paid_date": request.paid_date,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to mark invoice {invoice_id} as paid: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/supplier-invoices/{invoice_id}/send-to-economic")
|
||||
async def send_to_economic(invoice_id: int):
|
||||
"""
|
||||
@ -2260,7 +2204,7 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
|
||||
|
||||
|
||||
|
||||
@router.post("/supplier-invoices/{invoice_id}/send-to-economic-legacy-unimplemented")
|
||||
@router.post("/supplier-invoices/{invoice_id}/send-to-economic")
|
||||
async def send_invoice_to_economic(invoice_id: int):
|
||||
"""Send supplier invoice to e-conomic - requires separate implementation"""
|
||||
raise HTTPException(status_code=501, detail="e-conomic integration kommer senere")
|
||||
|
||||
@ -173,11 +173,6 @@
|
||||
<i class="bi bi-calendar-check me-2"></i>Til Betaling
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="ready-tab" data-bs-toggle="tab" href="#ready-content" onclick="switchToReadyTab()">
|
||||
<i class="bi bi-check-circle me-2"></i>Klar til Bogføring
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="lines-tab" data-bs-toggle="tab" href="#lines-content" onclick="switchToLinesTab()">
|
||||
<i class="bi bi-list-ul me-2"></i>Varelinjer
|
||||
@ -253,7 +248,7 @@
|
||||
<strong><span id="selectedKassekladdeCount">0</span> fakturaer valgt</strong>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomicKassekladde()" title="Send til e-conomic kassekladde">
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomic()" title="Send til e-conomic kassekladde">
|
||||
<i class="bi bi-send me-1"></i>Send til e-conomic
|
||||
</button>
|
||||
</div>
|
||||
@ -1397,7 +1392,7 @@ async function markSingleAsPaid(invoiceId) {
|
||||
}
|
||||
|
||||
// Helper function to send single invoice to e-conomic
|
||||
async function sendToEconomicById(invoiceId) {
|
||||
async function sendToEconomic(invoiceId) {
|
||||
if (!confirm('Send denne faktura til e-conomic?')) return;
|
||||
|
||||
try {
|
||||
@ -1685,7 +1680,7 @@ async function loadReadyForBookingView() {
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${invoice.id})" title="Se/Rediger detaljer">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="sendToEconomicById(${invoice.id})" title="Send til e-conomic">
|
||||
<button class="btn btn-sm btn-primary" onclick="sendToEconomic(${invoice.id})" title="Send til e-conomic">
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</td>
|
||||
@ -4056,11 +4051,12 @@ async function bulkMarkAsPaid() {
|
||||
|
||||
for (const invoiceId of invoiceIds) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
|
||||
method: 'POST',
|
||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
paid_date: new Date().toISOString().split('T')[0]
|
||||
status: 'paid',
|
||||
payment_date: new Date().toISOString().split('T')[0]
|
||||
})
|
||||
});
|
||||
|
||||
@ -4091,11 +4087,12 @@ async function markInvoiceAsPaid(invoiceId) {
|
||||
if (!confirm('Marker denne faktura som betalt?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
|
||||
method: 'POST',
|
||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
paid_date: new Date().toISOString().split('T')[0]
|
||||
status: 'paid',
|
||||
payment_date: new Date().toISOString().split('T')[0]
|
||||
})
|
||||
});
|
||||
|
||||
@ -4560,7 +4557,7 @@ async function approveInvoice() {
|
||||
const response = await fetch(`/api/v1/supplier-invoices/${currentInvoiceId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approved_by: getApprovalUser() })
|
||||
body: JSON.stringify({ approved_by: 'CurrentUser' }) // TODO: Get from auth
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@ -4613,7 +4610,7 @@ async function quickApprove(invoiceId) {
|
||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approved_by: getApprovalUser() })
|
||||
body: JSON.stringify({ approved_by: 'CurrentUser' })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@ -4958,7 +4955,7 @@ async function createTemplateFromInvoice(invoiceId, vendorId) {
|
||||
}
|
||||
|
||||
// Step 2: AI analyze
|
||||
const aiResp = await fetch('/api/v1/supplier-invoices/ai/analyze', {
|
||||
const aiResp = await fetch('/api/v1/supplier-invoices/ai-analyze', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
@ -5120,7 +5117,7 @@ async function sendSingleToEconomic(invoiceId) {
|
||||
}
|
||||
|
||||
// Bulk send selected invoices to e-conomic
|
||||
async function bulkSendToEconomicKassekladde() {
|
||||
async function bulkSendToEconomic() {
|
||||
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
|
||||
const invoiceIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.invoiceId));
|
||||
|
||||
@ -5168,16 +5165,6 @@ async function bulkSendToEconomicKassekladde() {
|
||||
}
|
||||
}
|
||||
|
||||
function getApprovalUser() {
|
||||
const bodyUser = document.body?.dataset?.currentUser;
|
||||
if (bodyUser && bodyUser.trim()) return bodyUser.trim();
|
||||
|
||||
const metaUser = document.querySelector('meta[name="current-user"]')?.content;
|
||||
if (metaUser && metaUser.trim()) return metaUser.trim();
|
||||
|
||||
return 'System';
|
||||
}
|
||||
|
||||
// Select vendor for file (when <100% match)
|
||||
async function selectVendorForFile(fileId, vendorId) {
|
||||
if (!vendorId) return;
|
||||
|
||||
@ -15,21 +15,6 @@ logger = logging.getLogger(__name__)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _users_column_exists(column_name: str) -> bool:
|
||||
result = execute_query_single(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'users'
|
||||
AND column_name = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(column_name,),
|
||||
)
|
||||
return bool(result)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||
@ -85,11 +70,9 @@ async def get_current_user(
|
||||
}
|
||||
|
||||
# Get additional user details from database
|
||||
is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
|
||||
user_details = execute_query_single(
|
||||
f"SELECT email, full_name, {is_2fa_expr} FROM users WHERE user_id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
"SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
|
||||
(user_id,))
|
||||
|
||||
return {
|
||||
"id": user_id,
|
||||
|
||||
@ -15,28 +15,6 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_users_column_cache: Dict[str, bool] = {}
|
||||
|
||||
|
||||
def _users_column_exists(column_name: str) -> bool:
|
||||
if column_name in _users_column_cache:
|
||||
return _users_column_cache[column_name]
|
||||
|
||||
result = execute_query_single(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'users'
|
||||
AND column_name = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(column_name,),
|
||||
)
|
||||
exists = bool(result)
|
||||
_users_column_cache[column_name] = exists
|
||||
return exists
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY = settings.JWT_SECRET_KEY
|
||||
ALGORITHM = "HS256"
|
||||
@ -48,11 +26,6 @@ pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"],
|
||||
class AuthService:
|
||||
"""Service for authentication and authorization"""
|
||||
|
||||
@staticmethod
|
||||
def is_2fa_supported() -> bool:
|
||||
"""Return True only when required 2FA columns exist in users table."""
|
||||
return _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret")
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
@ -116,9 +89,6 @@ class AuthService:
|
||||
@staticmethod
|
||||
def setup_user_2fa(user_id: int, username: str) -> Dict:
|
||||
"""Create and store a new TOTP secret (not enabled until verified)"""
|
||||
if not AuthService.is_2fa_supported():
|
||||
raise RuntimeError("2FA columns missing in users table")
|
||||
|
||||
secret = AuthService.generate_2fa_secret()
|
||||
execute_update(
|
||||
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||
@ -133,9 +103,6 @@ class AuthService:
|
||||
@staticmethod
|
||||
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
|
||||
"""Enable 2FA after verifying TOTP code"""
|
||||
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
|
||||
return False
|
||||
|
||||
user = execute_query_single(
|
||||
"SELECT totp_secret FROM users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
@ -156,9 +123,6 @@ class AuthService:
|
||||
@staticmethod
|
||||
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
|
||||
"""Disable 2FA after verifying TOTP code"""
|
||||
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
|
||||
return False
|
||||
|
||||
user = execute_query_single(
|
||||
"SELECT totp_secret FROM users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
@ -187,7 +151,6 @@ class AuthService:
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret"):
|
||||
execute_update(
|
||||
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||
(user_id,)
|
||||
@ -293,18 +256,13 @@ class AuthService:
|
||||
request_username = (username or "").strip().lower()
|
||||
|
||||
# Get user
|
||||
is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
|
||||
totp_expr = "totp_secret" if _users_column_exists("totp_secret") else "NULL::text AS totp_secret"
|
||||
last_2fa_expr = "last_2fa_at" if _users_column_exists("last_2fa_at") else "NULL::timestamp AS last_2fa_at"
|
||||
|
||||
user = execute_query_single(
|
||||
f"""SELECT user_id, username, email, password_hash, full_name,
|
||||
"""SELECT user_id, username, email, password_hash, full_name,
|
||||
is_active, is_superadmin, failed_login_attempts, locked_until,
|
||||
{is_2fa_expr}, {totp_expr}, {last_2fa_expr}
|
||||
is_2fa_enabled, totp_secret, last_2fa_at
|
||||
FROM users
|
||||
WHERE username = %s OR email = %s""",
|
||||
(username, username),
|
||||
)
|
||||
(username, username))
|
||||
|
||||
if not user:
|
||||
# Shadow Admin fallback (only when no regular user matches)
|
||||
@ -409,7 +367,6 @@ class AuthService:
|
||||
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
|
||||
return None, "Invalid 2FA code"
|
||||
|
||||
if _users_column_exists("last_2fa_at"):
|
||||
execute_update(
|
||||
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||
(user['user_id'],)
|
||||
@ -459,9 +416,6 @@ class AuthService:
|
||||
@staticmethod
|
||||
def is_user_2fa_enabled(user_id: int) -> bool:
|
||||
"""Check if user has 2FA enabled"""
|
||||
if not _users_column_exists("is_2fa_enabled"):
|
||||
return False
|
||||
|
||||
user = execute_query_single(
|
||||
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
|
||||
@ -6,7 +6,6 @@ PostgreSQL connection and helpers using psycopg2
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from psycopg2.pool import SimpleConnectionPool
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
@ -129,34 +128,3 @@ def execute_query_single(query: str, params: tuple = None):
|
||||
"""Execute query and return single row (backwards compatibility for fetchone=True)"""
|
||||
result = execute_query(query, params)
|
||||
return result[0] if result and len(result) > 0 else None
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def table_has_column(table_name: str, column_name: str, schema: str = "public") -> bool:
|
||||
"""Return whether a column exists in the current database schema."""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = %s
|
||||
AND table_name = %s
|
||||
AND column_name = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(schema, table_name, column_name),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Schema lookup failed for %s.%s.%s: %s",
|
||||
schema,
|
||||
table_name,
|
||||
column_name,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
|
||||
@ -125,24 +125,10 @@ async def dashboard(request: Request):
|
||||
|
||||
from app.core.database import execute_query
|
||||
|
||||
try:
|
||||
result = execute_query_single(unknown_query)
|
||||
unknown_count = result['count'] if result else 0
|
||||
except Exception as exc:
|
||||
if "tticket_worklog" in str(exc):
|
||||
logger.warning("⚠️ tticket_worklog table not found; defaulting unknown worklog count to 0")
|
||||
unknown_count = 0
|
||||
else:
|
||||
raise
|
||||
|
||||
try:
|
||||
raw_alerts = execute_query(bankruptcy_query) or []
|
||||
except Exception as exc:
|
||||
if "email_messages" in str(exc):
|
||||
logger.warning("⚠️ email_messages table not found; skipping bankruptcy alerts")
|
||||
raw_alerts = []
|
||||
else:
|
||||
raise
|
||||
bankruptcy_alerts = []
|
||||
|
||||
for alert in raw_alerts:
|
||||
|
||||
@ -12,10 +12,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.email-container > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Left Sidebar - Email List (25%) */
|
||||
.email-list-sidebar {
|
||||
flex: 0 0 400px;
|
||||
@ -214,7 +210,6 @@
|
||||
/* Center Pane - Email Content (50%) */
|
||||
.email-content-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
@ -225,7 +220,6 @@
|
||||
.email-content-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.email-content-subject {
|
||||
@ -233,8 +227,6 @@
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.email-content-meta {
|
||||
@ -242,20 +234,17 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sender-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sender-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
@ -266,8 +255,6 @@
|
||||
.sender-email {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.email-timestamp {
|
||||
@ -280,14 +267,6 @@
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
background: var(--bg-body);
|
||||
flex-wrap: wrap;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.attachment-chip {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.email-actions .btn-primary {
|
||||
@ -305,56 +284,7 @@
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.6;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.email-body pre,
|
||||
.email-html-body,
|
||||
.email-html-body * {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.email-html-body table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
table-layout: auto !important;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.email-html-body tbody,
|
||||
.email-html-body thead,
|
||||
.email-html-body tfoot,
|
||||
.email-html-body tr,
|
||||
.email-html-body td,
|
||||
.email-html-body th {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.email-html-body td,
|
||||
.email-html-body th {
|
||||
white-space: normal !important;
|
||||
word-break: break-word !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
|
||||
.email-html-body img,
|
||||
.email-html-body video,
|
||||
.email-html-body iframe {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.email-html-body [style*="position:fixed"],
|
||||
.email-html-body [style*="position: fixed"] {
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
.email-body iframe {
|
||||
@ -377,14 +307,6 @@
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-item .flex-grow-1 {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-item:hover {
|
||||
@ -420,7 +342,6 @@
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.analysis-card {
|
||||
@ -1892,12 +1813,12 @@ function renderEmailDetail(email) {
|
||||
return `
|
||||
${canPreview ? `
|
||||
<button onclick="previewAttachment(${email.id}, ${att.id}, '${escapeHtml(att.filename)}', '${att.content_type}')"
|
||||
class="btn btn-sm btn-outline-primary attachment-chip" title="Se ${att.filename}">
|
||||
class="btn btn-sm btn-outline-primary" title="Se ${att.filename}">
|
||||
<i class="bi bi-eye me-1"></i>${att.filename}
|
||||
</button>
|
||||
` : `
|
||||
<a href="/api/v1/emails/${email.id}/attachments/${att.id}"
|
||||
class="btn btn-sm btn-outline-secondary attachment-chip"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
download="${att.filename}"
|
||||
title="Download ${att.filename}">
|
||||
<i class="bi bi-download me-1"></i>${att.filename}
|
||||
@ -1917,39 +1838,9 @@ function renderEmailDetail(email) {
|
||||
// If HTML, inject it as innerHTML after rendering
|
||||
if (email.body_html) {
|
||||
const htmlDiv = pane.querySelector('.email-html-body');
|
||||
if (htmlDiv) {
|
||||
htmlDiv.innerHTML = email.body_html;
|
||||
normalizeEmailHtmlLayout(htmlDiv);
|
||||
if (htmlDiv) htmlDiv.innerHTML = email.body_html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEmailHtmlLayout(container) {
|
||||
if (!container) return;
|
||||
|
||||
const tables = container.querySelectorAll('table');
|
||||
tables.forEach((table) => {
|
||||
table.style.maxWidth = '100%';
|
||||
table.style.width = '100%';
|
||||
table.style.tableLayout = 'auto';
|
||||
table.removeAttribute('width');
|
||||
});
|
||||
|
||||
const cells = container.querySelectorAll('td, th');
|
||||
cells.forEach((cell) => {
|
||||
cell.style.whiteSpace = 'normal';
|
||||
cell.style.wordBreak = 'break-word';
|
||||
cell.style.overflowWrap = 'anywhere';
|
||||
});
|
||||
|
||||
const images = container.querySelectorAll('img, iframe, video');
|
||||
images.forEach((el) => {
|
||||
el.style.maxWidth = '100%';
|
||||
if (el.tagName === 'IMG' || el.tagName === 'VIDEO') {
|
||||
el.style.height = 'auto';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderEmailAnalysis(email) {
|
||||
const aiAnalysisTab = document.getElementById('aiAnalysisTab');
|
||||
|
||||
@ -280,7 +280,6 @@ class TodoStepCreate(TodoStepBase):
|
||||
class TodoStepUpdate(BaseModel):
|
||||
"""Schema for updating a todo step"""
|
||||
is_done: Optional[bool] = None
|
||||
is_next: Optional[bool] = None
|
||||
|
||||
|
||||
class TodoStep(TodoStepBase):
|
||||
@ -288,7 +287,6 @@ class TodoStep(TodoStepBase):
|
||||
id: int
|
||||
sag_id: int
|
||||
is_done: bool
|
||||
is_next: bool = False
|
||||
created_by_user_id: Optional[int] = None
|
||||
created_by_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
@ -51,64 +50,15 @@ def _get_user_id_from_request(request: Request) -> int:
|
||||
|
||||
|
||||
def _normalize_case_status(status_value: Optional[str]) -> str:
|
||||
allowed_statuses = []
|
||||
seen = set()
|
||||
|
||||
def _add_status(value: Optional[str]) -> None:
|
||||
candidate = str(value or "").strip()
|
||||
if not candidate:
|
||||
return
|
||||
key = candidate.lower()
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
allowed_statuses.append(candidate)
|
||||
|
||||
try:
|
||||
setting_row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("case_statuses",))
|
||||
if setting_row and setting_row.get("value"):
|
||||
parsed = json.loads(setting_row.get("value") or "[]")
|
||||
for item in parsed if isinstance(parsed, list) else []:
|
||||
if isinstance(item, str):
|
||||
value = item.strip()
|
||||
elif isinstance(item, dict):
|
||||
value = str(item.get("value") or "").strip()
|
||||
else:
|
||||
value = ""
|
||||
_add_status(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Include historical/current DB statuses so legacy values remain valid
|
||||
try:
|
||||
rows = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
|
||||
for row in rows:
|
||||
_add_status(row.get("status"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not allowed_statuses:
|
||||
allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
|
||||
|
||||
allowed_map = {s.lower(): s for s in allowed_statuses}
|
||||
|
||||
if not status_value:
|
||||
return allowed_map.get("åben", allowed_statuses[0])
|
||||
return "åben"
|
||||
|
||||
normalized = str(status_value).strip().lower()
|
||||
if normalized in allowed_map:
|
||||
return allowed_map[normalized]
|
||||
|
||||
# Backward compatibility for legacy mapping
|
||||
if normalized == "afventer" and "åben" in allowed_map:
|
||||
return allowed_map["åben"]
|
||||
|
||||
# Do not force unknown values back to default; preserve user-entered/custom DB values
|
||||
raw_value = str(status_value).strip()
|
||||
if raw_value:
|
||||
return raw_value
|
||||
|
||||
return allowed_map.get("åben", allowed_statuses[0])
|
||||
if normalized == "afventer":
|
||||
return "åben"
|
||||
if normalized in {"åben", "lukket"}:
|
||||
return normalized
|
||||
return "åben"
|
||||
|
||||
|
||||
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
|
||||
@ -172,8 +122,6 @@ class SagSendEmailRequest(BaseModel):
|
||||
bcc: List[str] = Field(default_factory=list)
|
||||
body_html: Optional[str] = None
|
||||
attachment_file_ids: List[int] = Field(default_factory=list)
|
||||
thread_email_id: Optional[int] = None
|
||||
thread_key: Optional[str] = None
|
||||
|
||||
|
||||
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
|
||||
@ -461,7 +409,7 @@ async def list_todo_steps(sag_id: int):
|
||||
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
|
||||
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
|
||||
WHERE t.sag_id = %s AND t.deleted_at IS NULL
|
||||
ORDER BY t.is_done ASC, t.is_next DESC, t.due_date NULLS LAST, t.created_at DESC
|
||||
ORDER BY t.is_done ASC, t.due_date NULLS LAST, t.created_at DESC
|
||||
"""
|
||||
return execute_query(query, (sag_id,)) or []
|
||||
except Exception as e:
|
||||
@ -516,29 +464,20 @@ async def create_todo_step(sag_id: int, request: Request, data: TodoStepCreate):
|
||||
@router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep)
|
||||
async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate):
|
||||
try:
|
||||
if data.is_done is None and data.is_next is None:
|
||||
raise HTTPException(status_code=400, detail="Provide is_done or is_next")
|
||||
if data.is_done is None:
|
||||
raise HTTPException(status_code=400, detail="is_done is required")
|
||||
|
||||
step_row = execute_query_single(
|
||||
"SELECT id, sag_id, is_done FROM sag_todo_steps WHERE id = %s AND deleted_at IS NULL",
|
||||
(step_id,)
|
||||
)
|
||||
if not step_row:
|
||||
raise HTTPException(status_code=404, detail="Todo step not found")
|
||||
|
||||
if data.is_done is not None:
|
||||
user_id = _get_user_id_from_request(request)
|
||||
if data.is_done:
|
||||
update_query = """
|
||||
UPDATE sag_todo_steps
|
||||
SET is_done = TRUE,
|
||||
is_next = FALSE,
|
||||
completed_by_user_id = %s,
|
||||
completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
"""
|
||||
execute_query(update_query, (user_id, step_id))
|
||||
result = execute_query(update_query, (user_id, step_id))
|
||||
else:
|
||||
update_query = """
|
||||
UPDATE sag_todo_steps
|
||||
@ -548,30 +487,10 @@ async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate)
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
"""
|
||||
execute_query(update_query, (step_id,))
|
||||
result = 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)
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Todo step not found")
|
||||
|
||||
return execute_query(
|
||||
"""
|
||||
@ -631,12 +550,8 @@ async def update_sag(sag_id: int, updates: dict):
|
||||
updates["status"] = _normalize_case_status(updates.get("status"))
|
||||
if "deadline" in updates:
|
||||
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
||||
if "start_date" in updates:
|
||||
updates["start_date"] = _normalize_optional_timestamp(updates.get("start_date"), "start_date")
|
||||
if "deferred_until" in updates:
|
||||
updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until")
|
||||
if "priority" in updates:
|
||||
updates["priority"] = (str(updates.get("priority") or "").strip().lower() or "normal")
|
||||
if "ansvarlig_bruger_id" in updates:
|
||||
updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
|
||||
_validate_user_id(updates["ansvarlig_bruger_id"])
|
||||
@ -652,8 +567,6 @@ async def update_sag(sag_id: int, updates: dict):
|
||||
"status",
|
||||
"ansvarlig_bruger_id",
|
||||
"assigned_group_id",
|
||||
"priority",
|
||||
"start_date",
|
||||
"deadline",
|
||||
"deferred_until",
|
||||
"deferred_until_case_id",
|
||||
@ -2286,42 +2199,6 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
"file_path": str(path),
|
||||
})
|
||||
|
||||
in_reply_to_header = None
|
||||
references_header = None
|
||||
if payload.thread_email_id:
|
||||
thread_row = None
|
||||
try:
|
||||
thread_row = execute_query_single(
|
||||
"""
|
||||
SELECT id, message_id, in_reply_to, email_references
|
||||
FROM email_messages
|
||||
WHERE id = %s
|
||||
""",
|
||||
(payload.thread_email_id,),
|
||||
)
|
||||
except Exception:
|
||||
# Backward compatibility for DBs without in_reply_to/email_references columns.
|
||||
thread_row = execute_query_single(
|
||||
"""
|
||||
SELECT id, message_id
|
||||
FROM email_messages
|
||||
WHERE id = %s
|
||||
""",
|
||||
(payload.thread_email_id,),
|
||||
)
|
||||
if thread_row:
|
||||
base_message_id = str(thread_row.get("message_id") or "").strip()
|
||||
if base_message_id and not base_message_id.startswith("<"):
|
||||
base_message_id = f"<{base_message_id}>"
|
||||
if base_message_id:
|
||||
in_reply_to_header = base_message_id
|
||||
|
||||
existing_refs = str(thread_row.get("email_references") or "").strip()
|
||||
if existing_refs:
|
||||
references_header = f"{existing_refs} {base_message_id}".strip()
|
||||
else:
|
||||
references_header = base_message_id
|
||||
|
||||
email_service = EmailService()
|
||||
success, send_message, generated_message_id = await email_service.send_email_with_attachments(
|
||||
to_addresses=to_addresses,
|
||||
@ -2330,8 +2207,6 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
body_html=payload.body_html,
|
||||
cc=cc_addresses,
|
||||
bcc=bcc_addresses,
|
||||
in_reply_to=in_reply_to_header,
|
||||
references=references_header,
|
||||
attachments=smtp_attachments,
|
||||
respect_dry_run=False,
|
||||
)
|
||||
@ -2343,42 +2218,6 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub"
|
||||
sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or ""
|
||||
|
||||
insert_result = None
|
||||
try:
|
||||
insert_email_query = """
|
||||
INSERT INTO email_messages (
|
||||
message_id, subject, sender_email, sender_name,
|
||||
recipient_email, cc, body_text, body_html,
|
||||
in_reply_to, email_references,
|
||||
received_date, folder, has_attachments, attachment_count,
|
||||
status, import_method, linked_case_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
"""
|
||||
insert_result = execute_query(
|
||||
insert_email_query,
|
||||
(
|
||||
generated_message_id,
|
||||
subject,
|
||||
sender_email,
|
||||
sender_name,
|
||||
", ".join(to_addresses),
|
||||
", ".join(cc_addresses),
|
||||
body_text,
|
||||
payload.body_html,
|
||||
in_reply_to_header,
|
||||
references_header,
|
||||
datetime.now(),
|
||||
"Sent",
|
||||
bool(smtp_attachments),
|
||||
len(smtp_attachments),
|
||||
"sent",
|
||||
"sag_outbound",
|
||||
sag_id,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
insert_email_query = """
|
||||
INSERT INTO email_messages (
|
||||
message_id, subject, sender_email, sender_name,
|
||||
@ -2447,11 +2286,9 @@ async def send_sag_email(sag_id: int, payload: SagSendEmailRequest):
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)",
|
||||
"✅ Outbound case email sent and linked (case=%s, email_id=%s, recipients=%s)",
|
||||
sag_id,
|
||||
email_id,
|
||||
payload.thread_email_id,
|
||||
payload.thread_key,
|
||||
", ".join(to_addresses),
|
||||
)
|
||||
return {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
@ -57,50 +56,6 @@ def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_case_status_options() -> list[str]:
|
||||
default_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
|
||||
values = []
|
||||
seen = set()
|
||||
|
||||
def _add(value: Optional[str]) -> None:
|
||||
candidate = str(value or "").strip()
|
||||
if not candidate:
|
||||
return
|
||||
key = candidate.lower()
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
values.append(candidate)
|
||||
|
||||
setting_row = execute_query(
|
||||
"SELECT value FROM settings WHERE key = %s",
|
||||
("case_statuses",)
|
||||
)
|
||||
|
||||
if setting_row and setting_row[0].get("value"):
|
||||
try:
|
||||
parsed = json.loads(setting_row[0].get("value") or "[]")
|
||||
for item in parsed if isinstance(parsed, list) else []:
|
||||
value = ""
|
||||
if isinstance(item, str):
|
||||
value = item.strip()
|
||||
elif isinstance(item, dict):
|
||||
value = str(item.get("value") or "").strip()
|
||||
_add(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) or []
|
||||
|
||||
for row in statuses:
|
||||
_add(row.get("status"))
|
||||
|
||||
for default in default_statuses:
|
||||
_add(default)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
@router.get("/sag", response_class=HTMLResponse)
|
||||
async def sager_liste(
|
||||
request: Request,
|
||||
@ -122,9 +77,7 @@ async def sager_liste(
|
||||
c.name as customer_name,
|
||||
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
g.name AS assigned_group_name,
|
||||
nt.title AS next_todo_title,
|
||||
nt.due_date AS next_todo_due_date
|
||||
g.name AS assigned_group_name
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||
@ -137,22 +90,6 @@ async def sager_liste(
|
||||
LIMIT 1
|
||||
) cc_first ON true
|
||||
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT t.title, t.due_date
|
||||
FROM sag_todo_steps t
|
||||
WHERE t.sag_id = s.id
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.is_done = FALSE
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN t.is_next THEN 0
|
||||
WHEN t.due_date IS NOT NULL THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
t.due_date ASC NULLS LAST,
|
||||
t.created_at ASC
|
||||
LIMIT 1
|
||||
) nt ON true
|
||||
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
|
||||
WHERE s.deleted_at IS NULL
|
||||
"""
|
||||
@ -225,11 +162,7 @@ async def sager_liste(
|
||||
sager = [s for s in sager if s['id'] in tagged_ids]
|
||||
|
||||
# Fetch all distinct statuses and tags for filters
|
||||
status_options = _fetch_case_status_options()
|
||||
|
||||
current_status = str(status or "").strip()
|
||||
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
|
||||
status_options.append(current_status)
|
||||
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
|
||||
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ())
|
||||
|
||||
toggle_include_deferred_url = str(
|
||||
@ -241,7 +174,7 @@ async def sager_liste(
|
||||
"sager": sager,
|
||||
"relations_map": relations_map,
|
||||
"child_ids": list(child_ids),
|
||||
"statuses": status_options,
|
||||
"statuses": [s['status'] for s in statuses],
|
||||
"all_tags": [t['tag_navn'] for t in all_tags],
|
||||
"current_status": status,
|
||||
"current_tag": tag,
|
||||
@ -518,10 +451,7 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
logger.warning("⚠️ Could not load pipeline stages: %s", e)
|
||||
pipeline_stages = []
|
||||
|
||||
status_options = _fetch_case_status_options()
|
||||
current_status = str(sag.get("status") or "").strip()
|
||||
if current_status and current_status.lower() not in {s.lower() for s in status_options}:
|
||||
status_options.append(current_status)
|
||||
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
|
||||
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
|
||||
|
||||
return templates.TemplateResponse("modules/sag/templates/detail.html", {
|
||||
@ -545,7 +475,7 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
"nextcloud_instance": nextcloud_instance,
|
||||
"related_case_options": related_case_options,
|
||||
"pipeline_stages": pipeline_stages,
|
||||
"status_options": status_options,
|
||||
"status_options": [s["status"] for s in statuses],
|
||||
"is_deadline_overdue": is_deadline_overdue,
|
||||
"assignment_users": _fetch_assignment_users(),
|
||||
"assignment_groups": _fetch_assignment_groups(),
|
||||
|
||||
@ -33,7 +33,7 @@ class RelationService:
|
||||
|
||||
# 2. Fetch details for these cases
|
||||
placeholders = ','.join(['%s'] * len(tree_ids))
|
||||
tree_cases_query = f"SELECT id, titel, status, type, template_key FROM sag_sager WHERE id IN ({placeholders})"
|
||||
tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})"
|
||||
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
|
||||
|
||||
# 3. Fetch all edges between these cases
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -17,14 +17,12 @@
|
||||
.table-wrapper {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.sag-table {
|
||||
width: 100%;
|
||||
min-width: 1760px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -34,13 +32,12 @@
|
||||
}
|
||||
|
||||
.sag-table thead th {
|
||||
padding: 0.6rem 0.75rem;
|
||||
padding: 0.8rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
letter-spacing: 0.5px;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sag-table tbody tr {
|
||||
@ -54,30 +51,9 @@
|
||||
}
|
||||
|
||||
.sag-table tbody td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
vertical-align: top;
|
||||
font-size: 0.86rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sag-table td.col-company,
|
||||
.sag-table td.col-contact,
|
||||
.sag-table td.col-owner,
|
||||
.sag-table td.col-group,
|
||||
.sag-table td.col-desc {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.sag-table td.col-company,
|
||||
.sag-table td.col-contact,
|
||||
.sag-table td.col-owner,
|
||||
.sag-table td.col-group {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.sag-table td.col-desc {
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
padding: 0.6rem 1rem;
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sag-id {
|
||||
@ -270,7 +246,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
|
||||
<div class="container-fluid" style="max-width: 1400px; padding-top: 2rem;">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="margin: 0; color: var(--accent);">
|
||||
@ -354,19 +330,17 @@
|
||||
<table class="sag-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 90px;">SagsID</th>
|
||||
<th style="width: 180px;">Virksom.</th>
|
||||
<th style="width: 150px;">Kontakt</th>
|
||||
<th style="width: 300px;">Beskr.</th>
|
||||
<th style="width: 90px;">ID</th>
|
||||
<th>Titel & Beskrivelse</th>
|
||||
<th style="width: 120px;">Type</th>
|
||||
<th style="width: 110px;">Prioritet</th>
|
||||
<th style="width: 160px;">Ansvarl.</th>
|
||||
<th style="width: 170px;">Gruppe/Level</th>
|
||||
<th style="width: 240px;">Næste todo</th>
|
||||
<th style="width: 120px;">Opret.</th>
|
||||
<th style="width: 120px;">Start arbejde</th>
|
||||
<th style="width: 140px;">Start inden</th>
|
||||
<th style="width: 120px;">Deadline</th>
|
||||
<th style="width: 180px;">Kunde</th>
|
||||
<th style="width: 150px;">Hovedkontakt</th>
|
||||
<th style="width: 160px;">Ansvarlig</th>
|
||||
<th style="width: 160px;">Gruppe</th>
|
||||
<th style="width: 100px;">Status</th>
|
||||
<th style="width: 120px;">Udsat start</th>
|
||||
<th style="width: 120px;">Oprettet</th>
|
||||
<th style="width: 120px;">Opdateret</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sagTableBody">
|
||||
@ -383,13 +357,7 @@
|
||||
{% endif %}
|
||||
<span class="sag-id">#{{ sag.id }}</span>
|
||||
</td>
|
||||
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.customer_name if sag.customer_name else '-' }}
|
||||
</td>
|
||||
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
|
||||
</td>
|
||||
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||
<div class="sag-titel">{{ sag.titel }}</div>
|
||||
{% if sag.beskrivelse %}
|
||||
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
|
||||
@ -398,36 +366,29 @@
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||
{{ sag.priority if sag.priority else 'normal' }}
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.customer_name if sag.customer_name else '-' }}
|
||||
</td>
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
|
||||
</td>
|
||||
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
|
||||
{% if sag.next_todo_title %}
|
||||
<div>{{ sag.next_todo_title }}</div>
|
||||
{% if sag.next_todo_due_date %}
|
||||
<div class="small text-muted">Forfald: {{ sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ sag.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }}
|
||||
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ sag.updated_at.strftime('%d/%m-%Y') if sag.updated_at else '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if has_relations %}
|
||||
@ -441,13 +402,7 @@
|
||||
<td>
|
||||
<span class="sag-id">#{{ related_sag.id }}</span>
|
||||
</td>
|
||||
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
||||
</td>
|
||||
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
|
||||
</td>
|
||||
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||
{% for rt in all_rel_types %}
|
||||
<span class="relation-badge">{{ rt }}</span>
|
||||
{% endfor %}
|
||||
@ -459,36 +414,29 @@
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
||||
</td>
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
|
||||
</td>
|
||||
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
|
||||
{% if related_sag.next_todo_title %}
|
||||
<div>{{ related_sag.next_todo_title }}</div>
|
||||
{% if related_sag.next_todo_due_date %}
|
||||
<div class="small text-muted">Forfald: {{ related_sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ related_sag.start_date.strftime('%d/%m-%Y') if related_sag.start_date else '-' }}
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ related_sag.deadline.strftime('%d/%m-%Y') if related_sag.deadline else '-' }}
|
||||
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
|
||||
{{ related_sag.updated_at.strftime('%d/%m-%Y') if related_sag.updated_at else '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
@ -12,31 +12,15 @@ import os
|
||||
import shutil
|
||||
|
||||
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# APIRouter instance (module_loader kigger efter denne)
|
||||
router = APIRouter()
|
||||
|
||||
# Upload directory for logos (works in both Docker and local development)
|
||||
_logo_base_dir = os.path.abspath(settings.UPLOAD_DIR)
|
||||
LOGO_UPLOAD_DIR = os.path.join(_logo_base_dir, "webshop_logos")
|
||||
try:
|
||||
# Upload directory for logos
|
||||
LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@ -67,12 +67,12 @@ class CaseAnalysisService:
|
||||
|
||||
return analysis
|
||||
else:
|
||||
logger.warning("⚠️ Ollama returned no result, using heuristic fallback analysis")
|
||||
return await self._heuristic_fallback_analysis(text)
|
||||
logger.warning("⚠️ Ollama returned no result, using empty analysis")
|
||||
return self._empty_analysis(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Case analysis failed: {e}", exc_info=True)
|
||||
return await self._heuristic_fallback_analysis(text)
|
||||
return self._empty_analysis(text)
|
||||
|
||||
def _build_analysis_prompt(self) -> str:
|
||||
"""Build Danish system prompt for case analysis"""
|
||||
@ -471,73 +471,6 @@ Returner JSON med suggested_title, suggested_description, priority, customer_hin
|
||||
ai_reasoning="AI unavailable - fill fields manually"
|
||||
)
|
||||
|
||||
async def _heuristic_fallback_analysis(self, text: str) -> QuickCreateAnalysis:
|
||||
"""Local fallback when AI service is unavailable."""
|
||||
cleaned_text = (text or "").strip()
|
||||
if not cleaned_text:
|
||||
return self._empty_analysis(text)
|
||||
|
||||
lowered = cleaned_text.lower()
|
||||
|
||||
# Priority heuristics based on urgency wording.
|
||||
urgent_terms = ["nede", "kritisk", "asap", "omgående", "straks", "akut", "haster"]
|
||||
high_terms = ["hurtigt", "vigtigt", "snarest", "prioriter"]
|
||||
low_terms = ["når i får tid", "ikke hastende", "lavprioriteret"]
|
||||
|
||||
if any(term in lowered for term in urgent_terms):
|
||||
priority = SagPriority.URGENT
|
||||
elif any(term in lowered for term in high_terms):
|
||||
priority = SagPriority.HIGH
|
||||
elif any(term in lowered for term in low_terms):
|
||||
priority = SagPriority.LOW
|
||||
else:
|
||||
priority = SagPriority.NORMAL
|
||||
|
||||
# Basic title heuristic: first non-empty line/sentence, clipped to 80 chars.
|
||||
first_line = cleaned_text.splitlines()[0].strip()
|
||||
first_sentence = re.split(r"[.!?]", first_line)[0].strip()
|
||||
title_source = first_sentence or first_line or cleaned_text
|
||||
title = title_source[:80].strip()
|
||||
if not title:
|
||||
title = "Ny sag"
|
||||
|
||||
# Lightweight keyword tags.
|
||||
keyword_tags = {
|
||||
"printer": "printer",
|
||||
"mail": "mail",
|
||||
"email": "mail",
|
||||
"vpn": "vpn",
|
||||
"net": "netværk",
|
||||
"wifi": "wifi",
|
||||
"server": "server",
|
||||
"laptop": "laptop",
|
||||
"adgang": "adgang",
|
||||
"onboarding": "onboarding",
|
||||
}
|
||||
suggested_tags: List[str] = []
|
||||
for key, tag in keyword_tags.items():
|
||||
if key in lowered and tag not in suggested_tags:
|
||||
suggested_tags.append(tag)
|
||||
|
||||
# Try simple customer matching from long words in text.
|
||||
candidate_hints = []
|
||||
for token in re.findall(r"[A-Za-z0-9ÆØÅæøå._-]{3,}", cleaned_text):
|
||||
if token.lower() in {"ring", "kunde", "sag", "skal", "have", "virker", "ikke"}:
|
||||
continue
|
||||
candidate_hints.append(token)
|
||||
customer_id, customer_name = await self._match_customer(candidate_hints[:8])
|
||||
|
||||
return QuickCreateAnalysis(
|
||||
suggested_title=title,
|
||||
suggested_description=cleaned_text,
|
||||
suggested_priority=priority,
|
||||
suggested_customer_id=customer_id,
|
||||
suggested_customer_name=customer_name,
|
||||
suggested_tags=suggested_tags,
|
||||
confidence=0.35,
|
||||
ai_reasoning="AI service unavailable - using local fallback suggestions"
|
||||
)
|
||||
|
||||
def _get_cached_analysis(self, text: str) -> Optional[QuickCreateAnalysis]:
|
||||
"""Get cached analysis if available and not expired"""
|
||||
text_hash = hashlib.md5(text.encode()).hexdigest()
|
||||
|
||||
@ -6,7 +6,6 @@ Adapted from OmniSync for BMC Hub timetracking use cases
|
||||
|
||||
import logging
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Dict, Optional, List
|
||||
from datetime import datetime
|
||||
import aiohttp
|
||||
|
||||
@ -1026,8 +1026,6 @@ class EmailService:
|
||||
cc: Optional[List[str]] = None,
|
||||
bcc: Optional[List[str]] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
in_reply_to: Optional[str] = None,
|
||||
references: Optional[str] = None,
|
||||
attachments: Optional[List[Dict]] = None,
|
||||
respect_dry_run: bool = True,
|
||||
) -> Tuple[bool, str, str]:
|
||||
@ -1062,10 +1060,6 @@ class EmailService:
|
||||
msg['Cc'] = ', '.join(cc)
|
||||
if reply_to:
|
||||
msg['Reply-To'] = reply_to
|
||||
if in_reply_to:
|
||||
msg['In-Reply-To'] = in_reply_to
|
||||
if references:
|
||||
msg['References'] = references
|
||||
|
||||
content_part = MIMEMultipart('alternative')
|
||||
content_part.attach(MIMEText(body_text, 'plain'))
|
||||
|
||||
@ -10,7 +10,6 @@ from app.core.config import settings
|
||||
import httpx
|
||||
import time
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -76,7 +75,7 @@ async def get_setting(key: str):
|
||||
query = "SELECT * FROM settings WHERE key = %s"
|
||||
result = execute_query(query, (key,))
|
||||
|
||||
if not result and key in {"case_types", "case_type_module_defaults", "case_statuses"}:
|
||||
if not result and key in {"case_types", "case_type_module_defaults"}:
|
||||
seed_query = """
|
||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
@ -109,25 +108,6 @@ async def get_setting(key: str):
|
||||
)
|
||||
)
|
||||
|
||||
if key == "case_statuses":
|
||||
execute_query(
|
||||
seed_query,
|
||||
(
|
||||
"case_statuses",
|
||||
json.dumps([
|
||||
{"value": "åben", "is_closed": False},
|
||||
{"value": "under behandling", "is_closed": False},
|
||||
{"value": "afventer", "is_closed": False},
|
||||
{"value": "løst", "is_closed": True},
|
||||
{"value": "lukket", "is_closed": True},
|
||||
], ensure_ascii=False),
|
||||
"system",
|
||||
"Sagsstatus værdier og lukkede markeringer",
|
||||
"json",
|
||||
True,
|
||||
)
|
||||
)
|
||||
|
||||
result = execute_query(query, (key,))
|
||||
|
||||
if not result:
|
||||
@ -598,12 +578,9 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
model_normalized = (model or "").strip().lower()
|
||||
# qwen models are more reliable with /api/chat than /api/generate.
|
||||
use_chat_api = model_normalized.startswith("qwen")
|
||||
use_chat_api = model.startswith("qwen3")
|
||||
|
||||
timeout = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
if use_chat_api:
|
||||
response = await client.post(
|
||||
f"{endpoint}/api/chat",
|
||||
@ -634,14 +611,7 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
|
||||
detail=f"AI endpoint fejl: {response.status_code} - {response.text[:300]}",
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception as parse_error:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"AI endpoint returnerede ugyldig JSON: {str(parse_error)}",
|
||||
)
|
||||
|
||||
if use_chat_api:
|
||||
message_data = data.get("message", {})
|
||||
ai_response = (message_data.get("content") or message_data.get("thinking") or "").strip()
|
||||
@ -664,12 +634,8 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest):
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"❌ AI prompt test timed out for {key}: {repr(e)}")
|
||||
raise HTTPException(status_code=504, detail="AI test timed out (model svarer for langsomt)")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
|
||||
err = str(e) or e.__class__.__name__
|
||||
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
|
||||
logger.error(f"❌ AI prompt test failed for {key}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {str(e)}")
|
||||
|
||||
|
||||
|
||||
@ -1143,33 +1143,6 @@ async def scan_document(file_path: str):
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1 fw-bold">Sagsstatus</h5>
|
||||
<p class="text-muted mb-0">Styr hvilke status-værdier der kan vælges, og marker hvilke der er lukkede.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<input type="text" class="form-control" id="caseStatusInput" placeholder="F.eks. afventer kunde" style="max-width: 260px;">
|
||||
<button class="btn btn-primary" onclick="addCaseStatus()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th class="text-center" style="width: 150px;">Lukket værdi</th>
|
||||
<th class="text-end" style="width: 100px;">Handling</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="caseStatusesTableBody">
|
||||
<tr><td colspan="3" class="text-muted">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
@ -1689,8 +1662,6 @@ async function loadSettings() {
|
||||
displaySettingsByCategory();
|
||||
renderTelefoniSettings();
|
||||
await loadCaseTypesSetting();
|
||||
await loadCaseStatusesSetting();
|
||||
await loadTagsManagement();
|
||||
await loadNextcloudInstances();
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
@ -2060,132 +2031,6 @@ const CASE_MODULE_LABELS = {
|
||||
};
|
||||
|
||||
let caseTypeModuleDefaultsCache = {};
|
||||
let caseStatusesCache = [];
|
||||
|
||||
function normalizeCaseStatuses(raw) {
|
||||
const normalized = [];
|
||||
const seen = new Set();
|
||||
const source = Array.isArray(raw) ? raw : [];
|
||||
|
||||
source.forEach((item) => {
|
||||
const row = typeof item === 'string'
|
||||
? { value: item, is_closed: false }
|
||||
: (item && typeof item === 'object' ? item : null);
|
||||
|
||||
if (!row) return;
|
||||
const value = String(row.value || '').trim();
|
||||
if (!value) return;
|
||||
|
||||
const key = value.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
normalized.push({
|
||||
value,
|
||||
is_closed: Boolean(row.is_closed)
|
||||
});
|
||||
});
|
||||
|
||||
const defaults = [
|
||||
{ value: 'åben', is_closed: false },
|
||||
{ value: 'under behandling', is_closed: false },
|
||||
{ value: 'afventer', is_closed: false },
|
||||
{ value: 'løst', is_closed: true },
|
||||
{ value: 'lukket', is_closed: true }
|
||||
];
|
||||
|
||||
defaults.forEach((item) => {
|
||||
const key = item.value.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
normalized.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function renderCaseStatuses(rows) {
|
||||
const tbody = document.getElementById('caseStatusesTableBody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!Array.isArray(rows) || !rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="text-muted">Ingen statusværdier defineret</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map((row, index) => `
|
||||
<tr>
|
||||
<td><span class="fw-semibold">${escapeHtml(row.value)}</span></td>
|
||||
<td class="text-center">
|
||||
<div class="form-check form-switch d-inline-flex">
|
||||
<input class="form-check-input" type="checkbox" id="caseStatusClosed_${index}" ${row.is_closed ? 'checked' : ''}
|
||||
onchange="toggleCaseStatusClosed(${index}, this.checked)">
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeCaseStatus(${index})" title="Slet status">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadCaseStatusesSetting() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings/case_statuses');
|
||||
if (!response.ok) {
|
||||
caseStatusesCache = normalizeCaseStatuses([]);
|
||||
renderCaseStatuses(caseStatusesCache);
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = await response.json();
|
||||
const parsed = JSON.parse(setting.value || '[]');
|
||||
caseStatusesCache = normalizeCaseStatuses(parsed);
|
||||
renderCaseStatuses(caseStatusesCache);
|
||||
} catch (error) {
|
||||
console.error('Error loading case statuses:', error);
|
||||
caseStatusesCache = normalizeCaseStatuses([]);
|
||||
renderCaseStatuses(caseStatusesCache);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCaseStatuses() {
|
||||
await updateSetting('case_statuses', JSON.stringify(caseStatusesCache));
|
||||
renderCaseStatuses(caseStatusesCache);
|
||||
}
|
||||
|
||||
async function addCaseStatus() {
|
||||
const input = document.getElementById('caseStatusInput');
|
||||
if (!input) return;
|
||||
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
|
||||
const exists = caseStatusesCache.some((row) => String(row.value || '').toLowerCase() === value.toLowerCase());
|
||||
if (!exists) {
|
||||
caseStatusesCache.push({ value, is_closed: false });
|
||||
await saveCaseStatuses();
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function removeCaseStatus(index) {
|
||||
caseStatusesCache = caseStatusesCache.filter((_, i) => i !== index);
|
||||
if (!caseStatusesCache.length) {
|
||||
caseStatusesCache = normalizeCaseStatuses([]);
|
||||
}
|
||||
await saveCaseStatuses();
|
||||
}
|
||||
|
||||
async function toggleCaseStatusClosed(index, checked) {
|
||||
if (!caseStatusesCache[index]) return;
|
||||
caseStatusesCache[index].is_closed = Boolean(checked);
|
||||
await saveCaseStatuses();
|
||||
}
|
||||
|
||||
function normalizeCaseTypeModuleDefaults(raw, caseTypes) {
|
||||
const normalized = {};
|
||||
@ -3240,8 +3085,6 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
||||
// Load data for tab
|
||||
if (tab === 'users') {
|
||||
loadUsers();
|
||||
} else if (tab === 'tags') {
|
||||
loadTagsManagement();
|
||||
} else if (tab === 'telefoni') {
|
||||
renderTelefoniSettings();
|
||||
} else if (tab === 'ai-prompts') {
|
||||
@ -3316,19 +3159,13 @@ let showInactive = false;
|
||||
async function loadTagsManagement() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/tags');
|
||||
if (!response.ok) {
|
||||
const msg = await getErrorMessage(response, 'Kunne ikke indlæse tags');
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (!response.ok) throw new Error('Failed to load tags');
|
||||
allTagsData = await response.json();
|
||||
updateTagsStats();
|
||||
renderTagsGrid();
|
||||
} catch (error) {
|
||||
console.error('Error loading tags:', error);
|
||||
allTagsData = [];
|
||||
updateTagsStats();
|
||||
renderTagsGrid();
|
||||
showNotification('Fejl ved indlæsning af tags: ' + (error.message || 'ukendt fejl'), 'error');
|
||||
showNotification('Fejl ved indlæsning af tags', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -253,7 +253,6 @@
|
||||
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
@ -282,6 +281,21 @@
|
||||
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li class="dropdown-submenu">
|
||||
<a class="dropdown-item dropdown-toggle py-2" href="#" data-submenu-toggle="timetracking">
|
||||
<span><i class="bi bi-clock-history me-2"></i>Timetracking</span>
|
||||
<i class="bi bi-chevron-right small opacity-75"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu" data-submenu="timetracking">
|
||||
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
@ -292,19 +306,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-clock-history me-2"></i>Data migration
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
||||
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
|
||||
<i class="bi bi-plus-circle-fill fs-5"></i>
|
||||
</button>
|
||||
@ -320,7 +321,6 @@
|
||||
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
||||
<li><a class="dropdown-item py-2" href="#" data-bs-toggle="modal" data-bs-target="#profileModal">Profil</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
@ -303,19 +303,15 @@
|
||||
async function performAnalysis(text) {
|
||||
try {
|
||||
const userId = getUserId();
|
||||
const response = await fetch('/api/v1/sag/analyze-quick-create', {
|
||||
const response = await fetch(`/api/v1/sag/analyze-quick-create?user_id=${userId}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
user_id: parseInt(userId, 10)
|
||||
})
|
||||
body: JSON.stringify({text})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Analysis failed (${response.status}): ${errorText || 'unknown error'}`);
|
||||
throw new Error('Analysis failed');
|
||||
}
|
||||
|
||||
const analysis = await response.json();
|
||||
|
||||
@ -6,7 +6,7 @@ from typing import Optional, List, Literal
|
||||
from datetime import datetime
|
||||
|
||||
# Tag types
|
||||
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type']
|
||||
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing']
|
||||
TagGroupBehavior = Literal['multi', 'single', 'toggle']
|
||||
|
||||
|
||||
@ -37,7 +37,6 @@ class TagBase(BaseModel):
|
||||
icon: Optional[str] = None
|
||||
is_active: bool = True
|
||||
tag_group_id: Optional[int] = None
|
||||
catch_words: Optional[List[str]] = None
|
||||
|
||||
class TagCreate(TagBase):
|
||||
"""Tag creation model"""
|
||||
@ -60,7 +59,6 @@ class TagUpdate(BaseModel):
|
||||
icon: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
tag_group_id: Optional[int] = None
|
||||
catch_words: Optional[List[str]] = None
|
||||
|
||||
|
||||
class EntityTagBase(BaseModel):
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
"""
|
||||
Tag system API endpoints
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import List, Optional
|
||||
import json
|
||||
import re
|
||||
from app.tags.backend.models import (
|
||||
Tag, TagCreate, TagUpdate,
|
||||
EntityTag, EntityTagCreate,
|
||||
@ -16,197 +14,6 @@ from app.core.database import execute_query, execute_query_single, execute_updat
|
||||
|
||||
router = APIRouter(prefix="/tags")
|
||||
|
||||
MODULE_LABELS = {
|
||||
"case": "Sager",
|
||||
"email": "Email",
|
||||
"ticket": "Tickets",
|
||||
"customer": "Kunder",
|
||||
"contact": "Kontakter",
|
||||
"time_entry": "Tid",
|
||||
"order": "Ordrer",
|
||||
"comment": "Ticket kommentarer",
|
||||
"worklog": "Ticket worklog",
|
||||
}
|
||||
|
||||
|
||||
def _module_label_for_entity_type(entity_type: Optional[str]) -> str:
|
||||
key = str(entity_type or "").strip().lower()
|
||||
if not key:
|
||||
return "Ukendt modul"
|
||||
return MODULE_LABELS.get(key, f"Ukendt modul ({key})")
|
||||
|
||||
|
||||
def _entity_reference_payload(entity_type: Optional[str], entity_id: Optional[int]) -> dict:
|
||||
etype = str(entity_type or "").strip().lower()
|
||||
eid = int(entity_id or 0)
|
||||
default_label = f"#{eid}" if eid else "Ukendt"
|
||||
|
||||
if not etype or not eid:
|
||||
return {"entity_title": default_label, "entity_url": None}
|
||||
|
||||
try:
|
||||
if etype == "case":
|
||||
row = execute_query_single(
|
||||
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||
(eid,),
|
||||
)
|
||||
if row:
|
||||
title = str(row.get("titel") or "Sag").strip()
|
||||
return {"entity_title": title, "entity_url": f"/sag/{eid}"}
|
||||
|
||||
elif etype == "email":
|
||||
row = execute_query_single(
|
||||
"SELECT id, subject FROM email_messages WHERE id = %s AND deleted_at IS NULL",
|
||||
(eid,),
|
||||
)
|
||||
if row:
|
||||
title = str(row.get("subject") or "Email").strip()
|
||||
return {"entity_title": title, "entity_url": f"/emails?id={eid}"}
|
||||
|
||||
elif etype == "ticket":
|
||||
row = execute_query_single(
|
||||
"SELECT id, ticket_number, subject FROM tticket_tickets WHERE id = %s",
|
||||
(eid,),
|
||||
)
|
||||
if row:
|
||||
ticket_number = str(row.get("ticket_number") or "").strip()
|
||||
subject = str(row.get("subject") or "Ticket").strip()
|
||||
title = f"{ticket_number} - {subject}" if ticket_number else subject
|
||||
return {"entity_title": title, "entity_url": f"/ticket/tickets/{eid}"}
|
||||
|
||||
elif etype == "customer":
|
||||
row = execute_query_single("SELECT id, name FROM customers WHERE id = %s", (eid,))
|
||||
if row:
|
||||
title = str(row.get("name") or "Kunde").strip()
|
||||
return {"entity_title": title, "entity_url": f"/customers/{eid}"}
|
||||
|
||||
elif etype == "contact":
|
||||
row = execute_query_single(
|
||||
"SELECT id, first_name, last_name, email FROM contacts WHERE id = %s",
|
||||
(eid,),
|
||||
)
|
||||
if row:
|
||||
name = " ".join(
|
||||
[str(row.get("first_name") or "").strip(), str(row.get("last_name") or "").strip()]
|
||||
).strip()
|
||||
title = name or str(row.get("email") or "Kontakt").strip()
|
||||
return {"entity_title": title, "entity_url": f"/contacts/{eid}"}
|
||||
|
||||
elif etype == "time_entry":
|
||||
row = execute_query_single(
|
||||
"SELECT id, description, worked_date FROM tmodule_times WHERE id = %s",
|
||||
(eid,),
|
||||
)
|
||||
if row:
|
||||
description = str(row.get("description") or "Tidsregistrering").strip()
|
||||
return {"entity_title": description[:90], "entity_url": "/timetracking/registrations"}
|
||||
|
||||
elif etype == "order":
|
||||
row = execute_query_single(
|
||||
"SELECT id, order_number, total_amount FROM tmodule_orders WHERE id = %s",
|
||||
(eid,),
|
||||
)
|
||||
if row:
|
||||
order_number = str(row.get("order_number") or "Ordre").strip()
|
||||
total_amount = row.get("total_amount")
|
||||
suffix = f" ({total_amount} kr.)" if total_amount is not None else ""
|
||||
return {"entity_title": f"{order_number}{suffix}", "entity_url": "/timetracking/orders"}
|
||||
|
||||
elif etype == "worklog":
|
||||
row = execute_query_single(
|
||||
"""
|
||||
SELECT w.id, w.description, w.ticket_id, t.ticket_number
|
||||
FROM tticket_worklog w
|
||||
LEFT JOIN tticket_tickets t ON t.id = w.ticket_id
|
||||
WHERE w.id = %s
|
||||
""",
|
||||
(eid,),
|
||||
)
|
||||
if row:
|
||||
ticket_id = row.get("ticket_id")
|
||||
ticket_number = str(row.get("ticket_number") or "Ticket").strip()
|
||||
description = str(row.get("description") or "Worklog").strip()
|
||||
url = f"/ticket/tickets/{ticket_id}" if ticket_id else None
|
||||
return {"entity_title": f"{ticket_number} - {description[:70]}", "entity_url": url}
|
||||
|
||||
elif etype == "comment":
|
||||
row = execute_query_single(
|
||||
"""
|
||||
SELECT c.id, c.comment_text, c.ticket_id, t.ticket_number
|
||||
FROM tticket_comments c
|
||||
LEFT JOIN tticket_tickets t ON t.id = c.ticket_id
|
||||
WHERE c.id = %s
|
||||
""",
|
||||
(eid,),
|
||||
)
|
||||
if row:
|
||||
ticket_id = row.get("ticket_id")
|
||||
ticket_number = str(row.get("ticket_number") or "Ticket").strip()
|
||||
comment_text = str(row.get("comment_text") or "Kommentar").strip()
|
||||
url = f"/ticket/tickets/{ticket_id}" if ticket_id else None
|
||||
return {"entity_title": f"{ticket_number} - {comment_text[:70]}", "entity_url": url}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"entity_title": default_label, "entity_url": None}
|
||||
|
||||
|
||||
def _normalize_catch_words(value) -> List[str]:
|
||||
"""Normalize catch words from JSON/text/list to a clean lowercase list."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
words = value
|
||||
elif isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return []
|
||||
if stripped.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
words = parsed if isinstance(parsed, list) else []
|
||||
except Exception:
|
||||
words = [w.strip() for w in stripped.replace("\n", ",").split(",")]
|
||||
else:
|
||||
words = [w.strip() for w in stripped.replace("\n", ",").split(",")]
|
||||
else:
|
||||
words = []
|
||||
|
||||
cleaned = []
|
||||
seen = set()
|
||||
for word in words:
|
||||
normalized = str(word or "").strip().lower()
|
||||
if len(normalized) < 2:
|
||||
continue
|
||||
if normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
cleaned.append(normalized)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _tag_row_to_response(row: dict) -> dict:
|
||||
"""Ensure API response always exposes catch_words as a list."""
|
||||
if not row:
|
||||
return row
|
||||
out = dict(row)
|
||||
|
||||
valid_types = {"workflow", "status", "category", "priority", "billing", "brand", "type"}
|
||||
tag_type = str(out.get("type") or "").strip().lower()
|
||||
if tag_type not in valid_types:
|
||||
tag_type = "category"
|
||||
out["type"] = tag_type
|
||||
|
||||
color = str(out.get("color") or "").strip()
|
||||
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", color):
|
||||
out["color"] = "#0f4c75"
|
||||
|
||||
if not out.get("name"):
|
||||
out["name"] = "Unnamed tag"
|
||||
|
||||
out["catch_words"] = _normalize_catch_words(out.get("catch_words"))
|
||||
return out
|
||||
|
||||
# ============= TAG GROUPS =============
|
||||
|
||||
@router.get("/groups", response_model=List[TagGroup])
|
||||
@ -227,131 +34,13 @@ async def create_tag_group(group: TagGroupCreate):
|
||||
|
||||
# ============= TAG CRUD =============
|
||||
|
||||
@router.get("/usage")
|
||||
async def list_tag_usage(
|
||||
tag_name: Optional[str] = Query(None),
|
||||
tag_type: Optional[TagType] = Query(None),
|
||||
module: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(25, ge=1, le=200),
|
||||
sort_by: str = Query("tagged_at"),
|
||||
sort_dir: str = Query("desc"),
|
||||
):
|
||||
"""List tag usage across modules with server-side filtering and pagination."""
|
||||
where_parts = ["1=1"]
|
||||
params: List[object] = []
|
||||
|
||||
if tag_name:
|
||||
where_parts.append("LOWER(t.name) LIKE LOWER(%s)")
|
||||
params.append(f"%{tag_name.strip()}%")
|
||||
|
||||
if tag_type:
|
||||
where_parts.append("t.type = %s")
|
||||
params.append(tag_type)
|
||||
|
||||
if module:
|
||||
where_parts.append("LOWER(et.entity_type) = LOWER(%s)")
|
||||
params.append(module.strip())
|
||||
|
||||
where_clause = " AND ".join(where_parts)
|
||||
|
||||
sortable = {
|
||||
"tagged_at": "et.tagged_at",
|
||||
"tag_name": "t.name",
|
||||
"tag_type": "t.type",
|
||||
"module": "et.entity_type",
|
||||
"entity_id": "et.entity_id",
|
||||
}
|
||||
order_column = sortable.get(sort_by, "et.tagged_at")
|
||||
order_direction = "ASC" if str(sort_dir).lower() == "asc" else "DESC"
|
||||
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) AS total
|
||||
FROM entity_tags et
|
||||
JOIN tags t ON t.id = et.tag_id
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
count_row = execute_query_single(count_query, tuple(params)) or {"total": 0}
|
||||
total = int(count_row.get("total") or 0)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_query = f"""
|
||||
SELECT
|
||||
et.id AS entity_tag_id,
|
||||
et.entity_type,
|
||||
et.entity_id,
|
||||
et.tagged_at,
|
||||
t.id AS tag_id,
|
||||
t.name AS tag_name,
|
||||
t.type AS tag_type,
|
||||
t.color AS tag_color,
|
||||
t.is_active AS tag_is_active
|
||||
FROM entity_tags et
|
||||
JOIN tags t ON t.id = et.tag_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_column} {order_direction}, et.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
|
||||
rows = execute_query(data_query, tuple(params + [page_size, offset])) or []
|
||||
items = []
|
||||
for row in rows:
|
||||
entity_type = row.get("entity_type")
|
||||
entity_ref = _entity_reference_payload(entity_type, row.get("entity_id"))
|
||||
items.append(
|
||||
{
|
||||
"entity_tag_id": row.get("entity_tag_id"),
|
||||
"tag_id": row.get("tag_id"),
|
||||
"tag_name": row.get("tag_name"),
|
||||
"tag_type": row.get("tag_type"),
|
||||
"tag_color": row.get("tag_color"),
|
||||
"tag_is_active": bool(row.get("tag_is_active")),
|
||||
"module": _module_label_for_entity_type(entity_type),
|
||||
"entity_type": entity_type,
|
||||
"entity_id": row.get("entity_id"),
|
||||
"entity_title": entity_ref.get("entity_title"),
|
||||
"entity_url": entity_ref.get("entity_url"),
|
||||
"tagged_at": row.get("tagged_at"),
|
||||
}
|
||||
)
|
||||
|
||||
module_rows = execute_query(
|
||||
"SELECT DISTINCT entity_type FROM entity_tags ORDER BY entity_type",
|
||||
(),
|
||||
) or []
|
||||
module_options = [
|
||||
{
|
||||
"value": row.get("entity_type"),
|
||||
"label": _module_label_for_entity_type(row.get("entity_type")),
|
||||
}
|
||||
for row in module_rows
|
||||
]
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"total_pages": (total + page_size - 1) // page_size if total else 0,
|
||||
},
|
||||
"sort": {"sort_by": sort_by, "sort_dir": order_direction.lower()},
|
||||
"module_options": module_options,
|
||||
}
|
||||
|
||||
@router.get("", response_model=List[Tag])
|
||||
async def list_tags(
|
||||
type: Optional[TagType] = None,
|
||||
is_active: Optional[bool] = None
|
||||
):
|
||||
"""List all tags with optional filtering"""
|
||||
query = """
|
||||
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
|
||||
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
|
||||
created_at, updated_at
|
||||
FROM tags
|
||||
WHERE 1=1
|
||||
"""
|
||||
query = "SELECT * FROM tags WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if type:
|
||||
@ -365,52 +54,32 @@ async def list_tags(
|
||||
query += " ORDER BY type, name"
|
||||
|
||||
results = execute_query(query, tuple(params) if params else ())
|
||||
return [_tag_row_to_response(row) for row in (results or [])]
|
||||
return results
|
||||
|
||||
@router.get("/{tag_id}", response_model=Tag)
|
||||
async def get_tag(tag_id: int):
|
||||
"""Get single tag by ID"""
|
||||
result = execute_query_single(
|
||||
"""
|
||||
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
|
||||
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
|
||||
created_at, updated_at
|
||||
FROM tags
|
||||
WHERE id = %s
|
||||
""",
|
||||
"SELECT * FROM tags WHERE id = %s",
|
||||
(tag_id,)
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
return _tag_row_to_response(result)
|
||||
return result
|
||||
|
||||
@router.post("", response_model=Tag)
|
||||
async def create_tag(tag: TagCreate):
|
||||
"""Create new tag"""
|
||||
query = """
|
||||
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id, catch_words_json)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING id, name, type, description, color, icon, is_active, tag_group_id,
|
||||
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
|
||||
created_at, updated_at
|
||||
INSERT INTO tags (name, type, description, color, icon, is_active, tag_group_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
catch_words = _normalize_catch_words(tag.catch_words)
|
||||
result = execute_query_single(
|
||||
query,
|
||||
(
|
||||
tag.name,
|
||||
tag.type,
|
||||
tag.description,
|
||||
tag.color,
|
||||
tag.icon,
|
||||
tag.is_active,
|
||||
tag.tag_group_id,
|
||||
json.dumps(catch_words),
|
||||
(tag.name, tag.type, tag.description, tag.color, tag.icon, tag.is_active, tag.tag_group_id)
|
||||
)
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create tag")
|
||||
return _tag_row_to_response(result)
|
||||
return result
|
||||
|
||||
@router.put("/{tag_id}", response_model=Tag)
|
||||
async def update_tag(tag_id: int, tag: TagUpdate):
|
||||
@ -437,9 +106,6 @@ async def update_tag(tag_id: int, tag: TagUpdate):
|
||||
if tag.tag_group_id is not None:
|
||||
updates.append("tag_group_id = %s")
|
||||
params.append(tag.tag_group_id)
|
||||
if tag.catch_words is not None:
|
||||
updates.append("catch_words_json = %s::jsonb")
|
||||
params.append(json.dumps(_normalize_catch_words(tag.catch_words)))
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
@ -451,15 +117,13 @@ async def update_tag(tag_id: int, tag: TagUpdate):
|
||||
UPDATE tags
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = %s
|
||||
RETURNING id, name, type, description, color, icon, is_active, tag_group_id,
|
||||
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
|
||||
created_at, updated_at
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
result = execute_query_single(query, tuple(params))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
return _tag_row_to_response(result)
|
||||
return result
|
||||
|
||||
@router.delete("/{tag_id}")
|
||||
async def delete_tag(tag_id: int):
|
||||
@ -550,91 +214,20 @@ async def remove_tag_from_entity_path(
|
||||
async def get_entity_tags(entity_type: str, entity_id: int):
|
||||
"""Get all tags for a specific entity"""
|
||||
query = """
|
||||
SELECT t.id, t.name, t.type, t.description, t.color, t.icon, t.is_active, t.tag_group_id,
|
||||
COALESCE(t.catch_words_json, '[]'::jsonb) AS catch_words,
|
||||
t.created_at, t.updated_at
|
||||
SELECT t.*
|
||||
FROM tags t
|
||||
JOIN entity_tags et ON et.tag_id = t.id
|
||||
WHERE et.entity_type = %s AND et.entity_id = %s
|
||||
ORDER BY t.type, t.name
|
||||
"""
|
||||
results = execute_query(query, (entity_type, entity_id))
|
||||
return [_tag_row_to_response(row) for row in (results or [])]
|
||||
|
||||
|
||||
@router.get("/entity/{entity_type}/{entity_id}/suggestions")
|
||||
async def suggest_entity_tags(entity_type: str, entity_id: int):
|
||||
"""Suggest tags based on catch words for brand/type tags."""
|
||||
if entity_type != "case":
|
||||
return []
|
||||
|
||||
case_row = execute_query_single(
|
||||
"SELECT id, titel, beskrivelse, template_key FROM sag_sager WHERE id = %s",
|
||||
(entity_id,),
|
||||
)
|
||||
if not case_row:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
|
||||
existing_rows = execute_query(
|
||||
"SELECT tag_id FROM entity_tags WHERE entity_type = %s AND entity_id = %s",
|
||||
(entity_type, entity_id),
|
||||
) or []
|
||||
existing_tag_ids = {int(row.get("tag_id")) for row in existing_rows if row.get("tag_id") is not None}
|
||||
|
||||
candidate_rows = execute_query(
|
||||
"""
|
||||
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
|
||||
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
|
||||
created_at, updated_at
|
||||
FROM tags
|
||||
WHERE is_active = true
|
||||
AND type IN ('brand', 'type')
|
||||
ORDER BY type, name
|
||||
""",
|
||||
(),
|
||||
) or []
|
||||
|
||||
haystack = " ".join(
|
||||
[
|
||||
str(case_row.get("titel") or ""),
|
||||
str(case_row.get("beskrivelse") or ""),
|
||||
str(case_row.get("template_key") or ""),
|
||||
]
|
||||
).lower()
|
||||
|
||||
suggestions = []
|
||||
for row in candidate_rows:
|
||||
tag_id = int(row.get("id"))
|
||||
if tag_id in existing_tag_ids:
|
||||
continue
|
||||
|
||||
catch_words = _normalize_catch_words(row.get("catch_words"))
|
||||
if not catch_words:
|
||||
continue
|
||||
|
||||
matched_words = [word for word in catch_words if word in haystack]
|
||||
if not matched_words:
|
||||
continue
|
||||
|
||||
suggestions.append(
|
||||
{
|
||||
"tag": _tag_row_to_response(row),
|
||||
"matched_words": matched_words,
|
||||
"score": len(matched_words),
|
||||
}
|
||||
)
|
||||
|
||||
suggestions.sort(key=lambda item: (-item["score"], item["tag"]["type"], item["tag"]["name"]))
|
||||
return suggestions
|
||||
return results
|
||||
|
||||
@router.get("/search")
|
||||
async def search_tags(q: str, type: Optional[TagType] = None):
|
||||
"""Search tags by name (fuzzy search)"""
|
||||
query = """
|
||||
SELECT id, name, type, description, color, icon, is_active, tag_group_id,
|
||||
COALESCE(catch_words_json, '[]'::jsonb) AS catch_words,
|
||||
created_at, updated_at
|
||||
FROM tags
|
||||
SELECT * FROM tags
|
||||
WHERE is_active = true
|
||||
AND LOWER(name) LIKE LOWER(%s)
|
||||
"""
|
||||
@ -647,7 +240,7 @@ async def search_tags(q: str, type: Optional[TagType] = None):
|
||||
query += " ORDER BY name LIMIT 20"
|
||||
|
||||
results = execute_query(query, tuple(params))
|
||||
return [_tag_row_to_response(row) for row in (results or [])]
|
||||
return results
|
||||
|
||||
|
||||
# ============= WORKFLOW MANAGEMENT =============
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Tag Administration - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tag Administration - BMC Hub</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #0f4c75;
|
||||
@ -11,8 +14,6 @@
|
||||
--category-color: #0f4c75;
|
||||
--priority-color: #dc3545;
|
||||
--billing-color: #2d6a4f;
|
||||
--brand-color: #006d77;
|
||||
--type-color: #5c677d;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
@ -36,8 +37,6 @@
|
||||
.tag-type-category { background-color: var(--category-color); color: white; }
|
||||
.tag-type-priority { background-color: var(--priority-color); color: white; }
|
||||
.tag-type-billing { background-color: var(--billing-color); color: white; }
|
||||
.tag-type-brand { background-color: var(--brand-color); color: white; }
|
||||
.tag-type-type { background-color: var(--type-color); color: white; }
|
||||
|
||||
.tag-list-item {
|
||||
padding: 1rem;
|
||||
@ -54,8 +53,6 @@
|
||||
.tag-list-item[data-type="category"] { border-left-color: var(--category-color); }
|
||||
.tag-list-item[data-type="priority"] { border-left-color: var(--priority-color); }
|
||||
.tag-list-item[data-type="billing"] { border-left-color: var(--billing-color); }
|
||||
.tag-list-item[data-type="brand"] { border-left-color: var(--brand-color); }
|
||||
.tag-list-item[data-type="type"] { border-left-color: var(--type-color); }
|
||||
|
||||
.color-preview {
|
||||
width: 40px;
|
||||
@ -63,68 +60,9 @@
|
||||
border-radius: 8px;
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.section-tabs .nav-link {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-tabs .nav-link.active {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.module-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
background: #e7f1f8;
|
||||
color: #0b3552;
|
||||
border: 1px solid #c7dceb;
|
||||
}
|
||||
|
||||
.usage-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-table .filter-cell {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.usage-sort-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.usage-sort-btn .bi {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.usage-sort-btn.active .bi {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.usage-table .filter-cell {
|
||||
min-width: 130px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
@ -138,17 +76,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills section-tabs mb-4" id="sectionTabs">
|
||||
<li class="nav-item">
|
||||
<button type="button" class="nav-link active" data-section="admin">Tag administration</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button type="button" class="nav-link" data-section="search">Tag søgning</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div id="tagAdminSection">
|
||||
|
||||
<!-- Type Filter Tabs -->
|
||||
<ul class="nav nav-tabs mb-4" id="typeFilter">
|
||||
<li class="nav-item">
|
||||
@ -179,16 +106,6 @@
|
||||
<span class="tag-badge tag-type-billing">Billing</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" data-type="brand">
|
||||
<span class="tag-badge tag-type-brand">Brand</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" data-type="type">
|
||||
<span class="tag-badge tag-type-type">Type</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tags List -->
|
||||
@ -205,98 +122,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tagSearchSection" class="d-none">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1">Tag søgning på tværs af moduler</h5>
|
||||
<p class="text-muted mb-0 small">Filtrer efter tag-navn, type og modul. Hver række viser tydeligt hvilket modul tagningen kommer fra.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="resetUsageFiltersBtn">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Nulstil filtre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle usage-table mb-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<button type="button" class="usage-sort-btn" data-sort-by="tag_name">
|
||||
Tag <i class="bi bi-chevron-expand"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" class="usage-sort-btn" data-sort-by="tag_type">
|
||||
Type <i class="bi bi-chevron-expand"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" class="usage-sort-btn" data-sort-by="module">
|
||||
Modul <i class="bi bi-chevron-expand"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>Objekt</th>
|
||||
<th>Entity type</th>
|
||||
<th>
|
||||
<button type="button" class="usage-sort-btn" data-sort-by="entity_id">
|
||||
Entity ID <i class="bi bi-chevron-expand"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" class="usage-sort-btn active" data-sort-by="tagged_at">
|
||||
Tagget <i class="bi bi-sort-down"></i>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="filter-cell">
|
||||
<input id="usageFilterTagName" type="search" class="form-control form-control-sm" placeholder="Søg tag-navn">
|
||||
</th>
|
||||
<th class="filter-cell">
|
||||
<select id="usageFilterTagType" class="form-select form-select-sm">
|
||||
<option value="">Alle typer</option>
|
||||
<option value="workflow">workflow</option>
|
||||
<option value="status">status</option>
|
||||
<option value="category">category</option>
|
||||
<option value="priority">priority</option>
|
||||
<option value="billing">billing</option>
|
||||
<option value="brand">brand</option>
|
||||
<option value="type">type</option>
|
||||
</select>
|
||||
</th>
|
||||
<th class="filter-cell">
|
||||
<select id="usageFilterModule" class="form-select form-select-sm">
|
||||
<option value="">Alle moduler</option>
|
||||
</select>
|
||||
</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usageTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">Indlæser...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div class="small text-muted" id="usageSummary">-</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="usagePrevBtn">Forrige</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="usageNextBtn">Næste</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Tag Modal -->
|
||||
<div class="modal fade" id="createTagModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@ -323,17 +148,9 @@
|
||||
<option value="category">Category - Emne/område</option>
|
||||
<option value="priority">Priority - Hastighed</option>
|
||||
<option value="billing">Billing - Økonomi</option>
|
||||
<option value="brand">Brand - Leverandør/produktbrand</option>
|
||||
<option value="type">Type - Sagstype/arbejdstype</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="tagCatchWords" class="form-label">Catch words</label>
|
||||
<textarea class="form-control" id="tagCatchWords" rows="3" placeholder="fx: office 365, outlook, smtp"></textarea>
|
||||
<small class="text-muted">Brug komma eller ny linje mellem ord. Bruges til auto-forslag på sager.</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="tagDescription" class="form-label">Beskrivelse</label>
|
||||
<textarea class="form-control" id="tagDescription" rows="3"></textarea>
|
||||
@ -369,59 +186,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
let allTags = [];
|
||||
let currentFilter = 'all';
|
||||
let usageDebounceTimer = null;
|
||||
const usageState = {
|
||||
filters: {
|
||||
tag_name: '',
|
||||
tag_type: '',
|
||||
module: ''
|
||||
},
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
sort_by: 'tagged_at',
|
||||
sort_dir: 'desc',
|
||||
total: 0,
|
||||
total_pages: 0
|
||||
};
|
||||
|
||||
// Load tags on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTags();
|
||||
loadTagUsage();
|
||||
setupEventListeners();
|
||||
const initialSection = window.location.hash === '#search' ? 'search' : 'admin';
|
||||
switchTagSection(initialSection, false);
|
||||
});
|
||||
|
||||
function switchTagSection(section, updateHash = true) {
|
||||
const normalized = section === 'search' ? 'search' : 'admin';
|
||||
document.querySelectorAll('#sectionTabs .nav-link').forEach(link => {
|
||||
link.classList.toggle('active', link.dataset.section === normalized);
|
||||
});
|
||||
|
||||
document.getElementById('tagAdminSection').classList.toggle('d-none', normalized !== 'admin');
|
||||
document.getElementById('tagSearchSection').classList.toggle('d-none', normalized !== 'search');
|
||||
|
||||
if (updateHash) {
|
||||
const hash = normalized === 'search' ? '#search' : '#admin';
|
||||
window.history.replaceState(null, '', hash);
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Section tabs
|
||||
document.querySelectorAll('#sectionTabs button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
switchTagSection(btn.dataset.section);
|
||||
});
|
||||
});
|
||||
|
||||
// Type filter tabs
|
||||
document.querySelectorAll('#typeFilter a').forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
@ -452,9 +229,7 @@
|
||||
'status': '#ffd700',
|
||||
'category': '#0f4c75',
|
||||
'priority': '#dc3545',
|
||||
'billing': '#2d6a4f',
|
||||
'brand': '#006d77',
|
||||
'type': '#5c677d'
|
||||
'billing': '#2d6a4f'
|
||||
};
|
||||
if (colorMap[type]) {
|
||||
document.getElementById('tagColor').value = colorMap[type];
|
||||
@ -465,61 +240,6 @@
|
||||
// Save button
|
||||
document.getElementById('saveTagBtn').addEventListener('click', saveTag);
|
||||
|
||||
// Usage filters
|
||||
document.getElementById('usageFilterTagName').addEventListener('input', () => {
|
||||
usageState.filters.tag_name = document.getElementById('usageFilterTagName').value.trim();
|
||||
usageState.page = 1;
|
||||
debounceUsageLoad();
|
||||
});
|
||||
document.getElementById('usageFilterTagType').addEventListener('change', () => {
|
||||
usageState.filters.tag_type = document.getElementById('usageFilterTagType').value;
|
||||
usageState.page = 1;
|
||||
loadTagUsage();
|
||||
});
|
||||
document.getElementById('usageFilterModule').addEventListener('change', () => {
|
||||
usageState.filters.module = document.getElementById('usageFilterModule').value;
|
||||
usageState.page = 1;
|
||||
loadTagUsage();
|
||||
});
|
||||
|
||||
document.getElementById('resetUsageFiltersBtn').addEventListener('click', () => {
|
||||
usageState.filters = { tag_name: '', tag_type: '', module: '' };
|
||||
usageState.page = 1;
|
||||
document.getElementById('usageFilterTagName').value = '';
|
||||
document.getElementById('usageFilterTagType').value = '';
|
||||
document.getElementById('usageFilterModule').value = '';
|
||||
loadTagUsage();
|
||||
});
|
||||
|
||||
document.getElementById('usagePrevBtn').addEventListener('click', () => {
|
||||
if (usageState.page > 1) {
|
||||
usageState.page -= 1;
|
||||
loadTagUsage();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('usageNextBtn').addEventListener('click', () => {
|
||||
if (usageState.page < usageState.total_pages) {
|
||||
usageState.page += 1;
|
||||
loadTagUsage();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.usage-sort-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const sortBy = btn.dataset.sortBy;
|
||||
if (usageState.sort_by === sortBy) {
|
||||
usageState.sort_dir = usageState.sort_dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
usageState.sort_by = sortBy;
|
||||
usageState.sort_dir = sortBy === 'tagged_at' ? 'desc' : 'asc';
|
||||
}
|
||||
usageState.page = 1;
|
||||
updateSortIndicators();
|
||||
loadTagUsage();
|
||||
});
|
||||
});
|
||||
|
||||
// Modal reset on close
|
||||
document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => {
|
||||
document.getElementById('tagForm').reset();
|
||||
@ -544,131 +264,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function debounceUsageLoad() {
|
||||
if (usageDebounceTimer) {
|
||||
clearTimeout(usageDebounceTimer);
|
||||
}
|
||||
usageDebounceTimer = setTimeout(() => loadTagUsage(), 280);
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
document.querySelectorAll('.usage-sort-btn').forEach(btn => {
|
||||
const icon = btn.querySelector('i');
|
||||
if (!icon) return;
|
||||
btn.classList.remove('active');
|
||||
icon.className = 'bi bi-chevron-expand';
|
||||
if (btn.dataset.sortBy === usageState.sort_by) {
|
||||
btn.classList.add('active');
|
||||
icon.className = usageState.sort_dir === 'asc' ? 'bi bi-sort-up' : 'bi bi-sort-down';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderUsageTable(items) {
|
||||
const tbody = document.getElementById('usageTableBody');
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Ingen taggede rækker matcher filtrene.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = items.map(row => {
|
||||
const taggedAt = row.tagged_at ? new Date(row.tagged_at).toLocaleString('da-DK') : '-';
|
||||
const color = /^#[0-9A-Fa-f]{6}$/.test(String(row.tag_color || '')) ? row.tag_color : '#0f4c75';
|
||||
const inactiveBadge = row.tag_is_active ? '' : '<span class="badge bg-secondary ms-2">Inaktiv</span>';
|
||||
const entityTitle = escapeHtml(row.entity_title || `#${row.entity_id || ''}`);
|
||||
const entityCell = row.entity_url
|
||||
? `<a href="${escapeHtml(row.entity_url)}" class="text-decoration-none fw-semibold">${entityTitle}</a>`
|
||||
: `<span class="fw-semibold">${entityTitle}</span>`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="tag-badge" style="background:${color}; color:#fff; margin:0;">${escapeHtml(row.tag_name)}</span>
|
||||
${inactiveBadge}
|
||||
</td>
|
||||
<td><span class="badge bg-light text-dark text-uppercase">${escapeHtml(row.tag_type)}</span></td>
|
||||
<td><span class="module-badge"><i class="bi bi-box"></i>${escapeHtml(row.module)}</span></td>
|
||||
<td>${entityCell}</td>
|
||||
<td><span class="text-muted">${escapeHtml(row.entity_type)}</span></td>
|
||||
<td><strong>#${escapeHtml(row.entity_id)}</strong></td>
|
||||
<td class="small text-muted">${escapeHtml(taggedAt)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderUsageSummary() {
|
||||
const summary = document.getElementById('usageSummary');
|
||||
const prevBtn = document.getElementById('usagePrevBtn');
|
||||
const nextBtn = document.getElementById('usageNextBtn');
|
||||
|
||||
const total = usageState.total;
|
||||
const page = usageState.page;
|
||||
const pageSize = usageState.page_size;
|
||||
const from = total ? ((page - 1) * pageSize + 1) : 0;
|
||||
const to = total ? Math.min(page * pageSize, total) : 0;
|
||||
|
||||
summary.textContent = `Viser ${from}-${to} af ${total} rækker`;
|
||||
prevBtn.disabled = page <= 1;
|
||||
nextBtn.disabled = page >= usageState.total_pages;
|
||||
}
|
||||
|
||||
function fillModuleFilter(options) {
|
||||
const select = document.getElementById('usageFilterModule');
|
||||
const currentValue = usageState.filters.module;
|
||||
const base = '<option value="">Alle moduler</option>';
|
||||
const rows = (options || []).map(option => {
|
||||
return `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`;
|
||||
}).join('');
|
||||
select.innerHTML = `${base}${rows}`;
|
||||
select.value = currentValue || '';
|
||||
}
|
||||
|
||||
async function loadTagUsage() {
|
||||
const tbody = document.getElementById('usageTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Indlæser...</td></tr>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(usageState.page),
|
||||
page_size: String(usageState.page_size),
|
||||
sort_by: usageState.sort_by,
|
||||
sort_dir: usageState.sort_dir
|
||||
});
|
||||
|
||||
if (usageState.filters.tag_name) params.set('tag_name', usageState.filters.tag_name);
|
||||
if (usageState.filters.tag_type) params.set('tag_type', usageState.filters.tag_type);
|
||||
if (usageState.filters.module) params.set('module', usageState.filters.module);
|
||||
|
||||
const response = await fetch(`/api/v1/tags/usage?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Kunne ikke hente tag søgning');
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
usageState.total = Number(payload?.pagination?.total || 0);
|
||||
usageState.total_pages = Number(payload?.pagination?.total_pages || 0);
|
||||
usageState.page = Number(payload?.pagination?.page || usageState.page);
|
||||
|
||||
fillModuleFilter(payload.module_options || []);
|
||||
renderUsageTable(payload.items || []);
|
||||
renderUsageSummary();
|
||||
updateSortIndicators();
|
||||
} catch (error) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">Fejl ved indlæsning af tag søgning: ${escapeHtml(error.message)}</td></tr>`;
|
||||
document.getElementById('usageSummary').textContent = 'Fejl ved datahentning';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
const container = document.getElementById('tagsList');
|
||||
const filteredTags = currentFilter === 'all'
|
||||
@ -698,7 +293,6 @@
|
||||
${!tag.is_active ? '<span class="badge bg-secondary ms-2">Inaktiv</span>' : ''}
|
||||
</div>
|
||||
${tag.description ? `<p class="text-muted mb-0 small">${tag.description}</p>` : ''}
|
||||
${Array.isArray(tag.catch_words) && tag.catch_words.length ? `<p class="mb-0 mt-1"><small class="text-muted">Catch words: ${tag.catch_words.join(', ')}</small></p>` : ''}
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})">
|
||||
@ -721,11 +315,7 @@
|
||||
description: document.getElementById('tagDescription').value || null,
|
||||
color: document.getElementById('tagColorHex').value,
|
||||
icon: document.getElementById('tagIcon').value || null,
|
||||
is_active: document.getElementById('tagActive').checked,
|
||||
catch_words: document.getElementById('tagCatchWords').value
|
||||
.split(/[\n,]+/)
|
||||
.map(v => v.trim().toLowerCase())
|
||||
.filter(v => v.length > 1)
|
||||
is_active: document.getElementById('tagActive').checked
|
||||
};
|
||||
|
||||
try {
|
||||
@ -762,7 +352,6 @@
|
||||
document.getElementById('tagColorHex').value = tag.color;
|
||||
document.getElementById('tagIcon').value = tag.icon || '';
|
||||
document.getElementById('tagActive').checked = tag.is_active;
|
||||
document.getElementById('tagCatchWords').value = Array.isArray(tag.catch_words) ? tag.catch_words.join(', ') : '';
|
||||
|
||||
document.querySelector('#createTagModal .modal-title').textContent = 'Rediger Tag';
|
||||
new bootstrap.Modal(document.getElementById('createTagModal')).show();
|
||||
@ -785,4 +374,5 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -2,21 +2,6 @@
|
||||
|
||||
{% block title %}Tekniker Dashboard V1 - Overblik{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
#caseTable thead th {
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
#caseTable tbody td {
|
||||
font-size: 0.84rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
@ -80,22 +65,15 @@
|
||||
<table class="table table-sm table-hover mb-0" id="caseTable">
|
||||
<thead class="table-light" id="tableHead">
|
||||
<tr>
|
||||
<th>SagsID</th>
|
||||
<th>Virksom.</th>
|
||||
<th>Kontakt</th>
|
||||
<th>Beskr.</th>
|
||||
<th>Type</th>
|
||||
<th>Prioritet</th>
|
||||
<th>Ansvarl.</th>
|
||||
<th>Gruppe/Level</th>
|
||||
<th>Opret.</th>
|
||||
<th>Start arbejde</th>
|
||||
<th>Start inden</th>
|
||||
<th>Deadline</th>
|
||||
<th>ID</th>
|
||||
<th>Titel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Status</th>
|
||||
<th>Dato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody">
|
||||
<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
|
||||
<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -189,16 +167,8 @@ const allData = {
|
||||
{
|
||||
id: {{ item.id }},
|
||||
titel: {{ item.titel | tojson | safe }},
|
||||
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
|
||||
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
|
||||
customer_name: {{ item.customer_name | tojson | safe }},
|
||||
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
|
||||
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
|
||||
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
|
||||
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
|
||||
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
|
||||
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
|
||||
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
|
||||
status: {{ item.status | tojson | safe if item.status else 'null' }},
|
||||
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
@ -209,16 +179,7 @@ const allData = {
|
||||
{
|
||||
id: {{ item.id }},
|
||||
titel: {{ item.titel | tojson | safe }},
|
||||
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
|
||||
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
|
||||
customer_name: {{ item.customer_name | tojson | safe }},
|
||||
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
|
||||
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
|
||||
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
|
||||
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
|
||||
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
|
||||
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
|
||||
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
|
||||
status: {{ item.status | tojson | safe if item.status else 'null' }},
|
||||
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
@ -230,16 +191,9 @@ const allData = {
|
||||
item_type: {{ item.item_type | tojson | safe }},
|
||||
item_id: {{ item.item_id }},
|
||||
title: {{ item.title | tojson | safe }},
|
||||
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
|
||||
customer_name: {{ item.customer_name | tojson | safe }},
|
||||
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
|
||||
task_reason: {{ item.task_reason | tojson | safe if item.task_reason else 'null' }},
|
||||
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
|
||||
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
|
||||
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
|
||||
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
|
||||
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
|
||||
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
|
||||
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
|
||||
status: {{ item.status | tojson | safe if item.status else 'null' }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
@ -251,16 +205,7 @@ const allData = {
|
||||
id: {{ item.id }},
|
||||
titel: {{ item.titel | tojson | safe }},
|
||||
group_name: {{ item.group_name | tojson | safe }},
|
||||
beskrivelse: {{ item.beskrivelse | tojson | safe if item.beskrivelse else 'null' }},
|
||||
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
|
||||
customer_name: {{ item.customer_name | tojson | safe }},
|
||||
kontakt_navn: {{ item.kontakt_navn | tojson | safe if item.kontakt_navn else 'null' }},
|
||||
case_type: {{ item.case_type | tojson | safe if item.case_type else 'null' }},
|
||||
ansvarlig_navn: {{ item.ansvarlig_navn | tojson | safe if item.ansvarlig_navn else 'null' }},
|
||||
assigned_group_name: {{ item.assigned_group_name | tojson | safe if item.assigned_group_name else 'null' }},
|
||||
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
|
||||
start_date: {{ item.start_date.isoformat() | tojson | safe if item.start_date else 'null' }},
|
||||
deferred_until: {{ item.deferred_until.isoformat() | tojson | safe if item.deferred_until else 'null' }},
|
||||
status: {{ item.status | tojson | safe if item.status else 'null' }},
|
||||
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
@ -280,32 +225,6 @@ function formatShortDate(dateStr) {
|
||||
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function renderCaseTableRow(item, idField = 'id', typeField = 'case') {
|
||||
const itemId = item[idField];
|
||||
const openType = typeField === 'item_type' ? item.item_type : 'case';
|
||||
const description = item.beskrivelse || item.titel || item.title || '-';
|
||||
const typeValue = item.case_type || item.item_type || '-';
|
||||
const groupLevel = item.assigned_group_name || item.group_name || '-';
|
||||
const priorityValue = item.priority || 'normal';
|
||||
|
||||
return `
|
||||
<tr onclick="showCaseDetails(${itemId}, '${openType}')" style="cursor:pointer;">
|
||||
<td>#${itemId}</td>
|
||||
<td>${item.customer_name || '-'}</td>
|
||||
<td>${item.kontakt_navn || '-'}</td>
|
||||
<td>${description}</td>
|
||||
<td>${typeValue}</td>
|
||||
<td>${priorityValue}</td>
|
||||
<td>${item.ansvarlig_navn || '-'}</td>
|
||||
<td>${groupLevel}</td>
|
||||
<td>${formatShortDate(item.created_at)}</td>
|
||||
<td>${formatShortDate(item.start_date)}</td>
|
||||
<td>${formatShortDate(item.deferred_until)}</td>
|
||||
<td>${formatShortDate(item.deadline)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function toggleSection(filterName) {
|
||||
const kpiCard = document.getElementById('kpi' + filterName.charAt(0).toUpperCase() + filterName.slice(1));
|
||||
const listTitle = document.getElementById('listTitle');
|
||||
@ -323,7 +242,7 @@ function toggleSection(filterName) {
|
||||
if (currentFilter === filterName) {
|
||||
currentFilter = null;
|
||||
listTitle.textContent = 'Alle sager';
|
||||
tableBody.innerHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
|
||||
tableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -347,43 +266,70 @@ function filterAndPopulateTable(filterName) {
|
||||
listTitle.innerHTML = '<i class="bi bi-inbox-fill text-primary"></i> Nye sager';
|
||||
const data = allData.newCases || [];
|
||||
if (data.length === 0) {
|
||||
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
|
||||
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
|
||||
} else {
|
||||
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
|
||||
bodyHTML = data.map(item => `
|
||||
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
|
||||
<td>#${item.id}</td>
|
||||
<td>${item.titel || '-'}</td>
|
||||
<td>${item.customer_name || '-'}</td>
|
||||
<td><span class="badge bg-secondary">${item.status || 'Ny'}</span></td>
|
||||
<td>${formatDate(item.created_at)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
} else if (filterName === 'myCases') {
|
||||
listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager';
|
||||
const data = allData.myCases || [];
|
||||
if (data.length === 0) {
|
||||
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
|
||||
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
|
||||
} else {
|
||||
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
|
||||
bodyHTML = data.map(item => `
|
||||
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
|
||||
<td>#${item.id}</td>
|
||||
<td>${item.titel || '-'}</td>
|
||||
<td>${item.customer_name || '-'}</td>
|
||||
<td><span class="badge bg-info">${item.status || '-'}</span></td>
|
||||
<td>${formatShortDate(item.deadline)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
} else if (filterName === 'todayTasks') {
|
||||
listTitle.innerHTML = '<i class="bi bi-calendar-check text-primary"></i> Dagens opgaver';
|
||||
const data = allData.todayTasks || [];
|
||||
if (data.length === 0) {
|
||||
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
|
||||
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
|
||||
} else {
|
||||
bodyHTML = data.map(item => {
|
||||
const normalized = {
|
||||
...item,
|
||||
id: item.item_id,
|
||||
titel: item.title,
|
||||
beskrivelse: item.task_reason || item.beskrivelse,
|
||||
deadline: item.deadline || item.due_at,
|
||||
case_type: item.case_type || item.item_type
|
||||
};
|
||||
return renderCaseTableRow(normalized, 'id', 'item_type');
|
||||
const badge = item.item_type === 'case'
|
||||
? '<span class="badge bg-primary">Sag</span>'
|
||||
: '<span class="badge bg-info">Ticket</span>';
|
||||
return `
|
||||
<tr onclick="showCaseDetails(${item.item_id}, '${item.item_type}')" style="cursor:pointer;">
|
||||
<td>#${item.item_id}</td>
|
||||
<td>${item.title || '-'}<br><small class="text-muted">${item.task_reason || ''}</small></td>
|
||||
<td>${item.customer_name || '-'}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${formatDate(item.created_at)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} else if (filterName === 'groupCases') {
|
||||
listTitle.innerHTML = '<i class="bi bi-people-fill text-info"></i> Gruppe-sager';
|
||||
const data = allData.groupCases || [];
|
||||
if (data.length === 0) {
|
||||
bodyHTML = '<tr><td colspan="12" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
|
||||
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
|
||||
} else {
|
||||
bodyHTML = data.map(item => renderCaseTableRow(item)).join('');
|
||||
bodyHTML = data.map(item => `
|
||||
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
|
||||
<td>#${item.id}</td>
|
||||
<td>${item.titel || '-'}<br><span class="badge bg-secondary">${item.group_name || '-'}</span></td>
|
||||
<td>${item.customer_name || '-'}</td>
|
||||
<td><span class="badge bg-info">${item.status || '-'}</span></td>
|
||||
<td>${formatShortDate(item.deadline)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -86,38 +86,14 @@
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>SagsID</th>
|
||||
<th>Virksom.</th>
|
||||
<th>Kontakt</th>
|
||||
<th>Beskr.</th>
|
||||
<th>Type</th>
|
||||
<th>Ansvarl.</th>
|
||||
<th>Gruppe/Level</th>
|
||||
<th>Opret.</th>
|
||||
<th>Start arbejde</th>
|
||||
<th>Start inden</th>
|
||||
<th>Deadline</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Kunde</th><th>Oprettet</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in new_cases %}
|
||||
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
|
||||
<td>#{{ item.id }}</td>
|
||||
<td>{{ item.customer_name or '-' }}</td>
|
||||
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
|
||||
<td>{{ item.beskrivelse or item.titel or '-' }}</td>
|
||||
<td>{{ item.case_type or '-' }}</td>
|
||||
<td>{{ item.ansvarlig_navn or '-' }}</td>
|
||||
<td>{{ item.assigned_group_name or '-' }}</td>
|
||||
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
|
||||
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
|
||||
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
|
||||
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
|
||||
<td>#{{ item.id }}</td><td>{{ item.titel }}</td><td>{{ item.customer_name }}</td><td>{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="11" class="text-center text-muted py-3">Ingen nye sager</td></tr>
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Ingen nye sager</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -32,71 +32,59 @@
|
||||
<table class="table table-hover table-sm mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>SagsID</th>
|
||||
<th>Virksom.</th>
|
||||
<th>Kontakt</th>
|
||||
<th>Beskr.</th>
|
||||
<th>Type</th>
|
||||
<th>Ansvarl.</th>
|
||||
<th>Gruppe/Level</th>
|
||||
<th>Opret.</th>
|
||||
<th>Start arbejde</th>
|
||||
<th>Start inden</th>
|
||||
<th>ID</th>
|
||||
<th>Titel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Status</th>
|
||||
<th>Prioritet/Reason</th>
|
||||
<th>Deadline</th>
|
||||
<th>Handling</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in urgent_overdue %}
|
||||
<tr>
|
||||
<td><span class="badge bg-danger">Haste</span></td>
|
||||
<td>#{{ item.item_id }}</td>
|
||||
<td>{{ item.customer_name or '-' }}</td>
|
||||
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
|
||||
<td>{{ item.beskrivelse or item.title or '-' }}</td>
|
||||
<td>{{ item.case_type or item.item_type or '-' }}</td>
|
||||
<td>{{ item.ansvarlig_navn or '-' }}</td>
|
||||
<td>{{ item.assigned_group_name or '-' }}</td>
|
||||
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
|
||||
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
|
||||
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
|
||||
<td>{{ item.title }}</td>
|
||||
<td>{{ item.customer_name }}</td>
|
||||
<td>{{ item.status }}</td>
|
||||
<td>{{ item.attention_reason }}</td>
|
||||
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
|
||||
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-danger">Åbn</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% for item in today_tasks %}
|
||||
<tr>
|
||||
<td><span class="badge bg-primary">I dag</span></td>
|
||||
<td>#{{ item.item_id }}</td>
|
||||
<td>{{ item.customer_name or '-' }}</td>
|
||||
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
|
||||
<td>{{ item.beskrivelse or item.title or item.task_reason or '-' }}</td>
|
||||
<td>{{ item.case_type or item.item_type or '-' }}</td>
|
||||
<td>{{ item.ansvarlig_navn or '-' }}</td>
|
||||
<td>{{ item.assigned_group_name or '-' }}</td>
|
||||
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
|
||||
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
|
||||
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
|
||||
<td>{{ item.title }}</td>
|
||||
<td>{{ item.customer_name }}</td>
|
||||
<td>{{ item.status }}</td>
|
||||
<td>{{ item.task_reason }}</td>
|
||||
<td>{{ item.due_at.strftime('%d/%m/%Y') if item.due_at else '-' }}</td>
|
||||
<td><a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-outline-primary">Åbn</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% for item in my_cases %}
|
||||
<tr>
|
||||
<td><span class="badge bg-secondary">Min sag</span></td>
|
||||
<td>#{{ item.id }}</td>
|
||||
<td>{{ item.customer_name or '-' }}</td>
|
||||
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
|
||||
<td>{{ item.beskrivelse or item.titel or '-' }}</td>
|
||||
<td>{{ item.case_type or '-' }}</td>
|
||||
<td>{{ item.ansvarlig_navn or '-' }}</td>
|
||||
<td>{{ item.assigned_group_name or '-' }}</td>
|
||||
<td>{{ item.created_at.strftime('%d/%m/%Y') if item.created_at else '-' }}</td>
|
||||
<td>{{ item.start_date.strftime('%d/%m/%Y') if item.start_date else '-' }}</td>
|
||||
<td>{{ item.deferred_until.strftime('%d/%m/%Y') if item.deferred_until else '-' }}</td>
|
||||
<td>{{ item.titel }}</td>
|
||||
<td>{{ item.customer_name }}</td>
|
||||
<td>{{ item.status }}</td>
|
||||
<td>-</td>
|
||||
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
|
||||
<td><a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary">Åbn</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if not urgent_overdue and not today_tasks and not my_cases %}
|
||||
<tr>
|
||||
<td colspan="11" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td>
|
||||
<td colspan="8" class="text-center text-muted py-4">Ingen data at vise for denne tekniker.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
|
||||
@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import date
|
||||
|
||||
from app.core.database import execute_query, execute_update, execute_query_single, table_has_column
|
||||
from app.core.database import execute_query, execute_update, execute_query_single
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -18,20 +18,6 @@ router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
|
||||
|
||||
def _case_start_date_sql(alias: str = "s") -> str:
|
||||
"""Select start_date only when the live schema actually has it."""
|
||||
if table_has_column("sag_sager", "start_date"):
|
||||
return f"{alias}.start_date"
|
||||
return "NULL::date AS start_date"
|
||||
|
||||
|
||||
def _case_type_sql(alias: str = "s") -> str:
|
||||
"""Select case type across old/new sag schemas."""
|
||||
if table_has_column("sag_sager", "type"):
|
||||
return f"COALESCE({alias}.template_key, {alias}.type, 'ticket') AS case_type"
|
||||
return f"COALESCE({alias}.template_key, 'ticket') AS case_type"
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def ticket_root_redirect():
|
||||
return RedirectResponse(url="/sag", status_code=302)
|
||||
@ -376,8 +362,6 @@ async def new_ticket_page(request: Request):
|
||||
|
||||
def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||
"""Collect live data slices for technician-focused dashboard variants."""
|
||||
case_start_date_sql = _case_start_date_sql()
|
||||
case_type_sql = _case_type_sql()
|
||||
user_query = """
|
||||
SELECT user_id, COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS display_name
|
||||
FROM users
|
||||
@ -387,34 +371,16 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||
user_result = execute_query(user_query, (technician_user_id,))
|
||||
technician_name = user_result[0]["display_name"] if user_result else f"Bruger #{technician_user_id}"
|
||||
|
||||
new_cases_query = f"""
|
||||
new_cases_query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.beskrivelse,
|
||||
s.priority,
|
||||
s.status,
|
||||
s.created_at,
|
||||
{case_start_date_sql},
|
||||
s.deferred_until,
|
||||
s.deadline,
|
||||
{case_type_sql},
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
g.name AS assigned_group_name
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cc.contact_id
|
||||
FROM contact_companies cc
|
||||
WHERE cc.customer_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) cc_first ON true
|
||||
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND s.status = 'åben'
|
||||
ORDER BY s.created_at DESC
|
||||
@ -422,34 +388,16 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
new_cases = execute_query(new_cases_query)
|
||||
|
||||
my_cases_query = f"""
|
||||
my_cases_query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.beskrivelse,
|
||||
s.priority,
|
||||
s.status,
|
||||
s.created_at,
|
||||
{case_start_date_sql},
|
||||
s.deferred_until,
|
||||
s.deadline,
|
||||
{case_type_sql},
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
g.name AS assigned_group_name
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cc.contact_id
|
||||
FROM contact_companies cc
|
||||
WHERE cc.customer_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) cc_first ON true
|
||||
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND s.ansvarlig_bruger_id = %s
|
||||
AND s.status <> 'lukket'
|
||||
@ -458,36 +406,19 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
my_cases = execute_query(my_cases_query, (technician_user_id,))
|
||||
|
||||
today_tasks_query = f"""
|
||||
today_tasks_query = """
|
||||
SELECT
|
||||
'case' AS item_type,
|
||||
s.id AS item_id,
|
||||
s.titel AS title,
|
||||
s.beskrivelse,
|
||||
s.status,
|
||||
s.deadline AS due_at,
|
||||
s.created_at,
|
||||
{case_start_date_sql},
|
||||
s.deferred_until,
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||
{case_type_sql},
|
||||
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
g.name AS assigned_group_name,
|
||||
COALESCE(s.priority::text, 'normal') AS priority,
|
||||
NULL::text AS priority,
|
||||
'Sag deadline i dag' AS task_reason
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cc.contact_id
|
||||
FROM contact_companies cc
|
||||
WHERE cc.customer_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) cc_first ON true
|
||||
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND s.ansvarlig_bruger_id = %s
|
||||
AND s.status <> 'lukket'
|
||||
@ -499,22 +430,14 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||
'ticket' AS item_type,
|
||||
t.id AS item_id,
|
||||
t.subject AS title,
|
||||
NULL::text AS beskrivelse,
|
||||
t.status,
|
||||
NULL::date AS due_at,
|
||||
t.created_at,
|
||||
NULL::date AS start_date,
|
||||
NULL::date AS deferred_until,
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||
'ticket' AS case_type,
|
||||
NULL::text AS kontakt_navn,
|
||||
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
|
||||
NULL::text AS assigned_group_name,
|
||||
COALESCE(t.priority, 'normal') AS priority,
|
||||
'Ticket oprettet i dag' AS task_reason
|
||||
FROM tticket_tickets t
|
||||
LEFT JOIN customers c ON c.id = t.customer_id
|
||||
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
|
||||
WHERE t.assigned_to_user_id = %s
|
||||
AND t.status IN ('open', 'in_progress', 'pending_customer')
|
||||
AND DATE(t.created_at) = CURRENT_DATE
|
||||
@ -524,36 +447,19 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
today_tasks = execute_query(today_tasks_query, (technician_user_id, technician_user_id))
|
||||
|
||||
urgent_overdue_query = f"""
|
||||
urgent_overdue_query = """
|
||||
SELECT
|
||||
'case' AS item_type,
|
||||
s.id AS item_id,
|
||||
s.titel AS title,
|
||||
s.beskrivelse,
|
||||
s.status,
|
||||
s.deadline AS due_at,
|
||||
s.created_at,
|
||||
{case_start_date_sql},
|
||||
s.deferred_until,
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||
{case_type_sql},
|
||||
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
g.name AS assigned_group_name,
|
||||
NULL::text AS priority,
|
||||
'Over deadline' AS attention_reason
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cc.contact_id
|
||||
FROM contact_companies cc
|
||||
WHERE cc.customer_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) cc_first ON true
|
||||
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND s.status <> 'lukket'
|
||||
AND s.deadline IS NOT NULL
|
||||
@ -565,17 +471,10 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||
'ticket' AS item_type,
|
||||
t.id AS item_id,
|
||||
t.subject AS title,
|
||||
NULL::text AS beskrivelse,
|
||||
t.status,
|
||||
NULL::date AS due_at,
|
||||
t.created_at,
|
||||
NULL::date AS start_date,
|
||||
NULL::date AS deferred_until,
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||
'ticket' AS case_type,
|
||||
NULL::text AS kontakt_navn,
|
||||
COALESCE(uu.full_name, uu.username) AS ansvarlig_navn,
|
||||
NULL::text AS assigned_group_name,
|
||||
COALESCE(t.priority, 'normal') AS priority,
|
||||
CASE
|
||||
WHEN t.priority = 'urgent' THEN 'Urgent prioritet'
|
||||
@ -583,7 +482,6 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||
END AS attention_reason
|
||||
FROM tticket_tickets t
|
||||
LEFT JOIN customers c ON c.id = t.customer_id
|
||||
LEFT JOIN users uu ON uu.user_id = t.assigned_to_user_id
|
||||
WHERE t.status IN ('open', 'in_progress', 'pending_customer')
|
||||
AND COALESCE(t.priority, '') IN ('urgent', 'high')
|
||||
AND (t.assigned_to_user_id = %s OR t.assigned_to_user_id IS NULL)
|
||||
@ -644,36 +542,19 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
|
||||
# Get group cases (cases assigned to user's groups)
|
||||
group_cases = []
|
||||
if user_group_ids:
|
||||
group_cases_query = f"""
|
||||
group_cases_query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.beskrivelse,
|
||||
s.priority,
|
||||
s.status,
|
||||
s.created_at,
|
||||
{case_start_date_sql},
|
||||
s.deferred_until,
|
||||
s.deadline,
|
||||
{case_type_sql},
|
||||
s.assigned_group_id,
|
||||
g.name AS group_name,
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) AS kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
g.name AS assigned_group_name
|
||||
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cc.contact_id
|
||||
FROM contact_companies cc
|
||||
WHERE cc.customer_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) cc_first ON true
|
||||
LEFT JOIN contacts cont ON cont.id = cc_first.contact_id
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND s.assigned_group_id = ANY(%s)
|
||||
AND s.status <> 'lukket'
|
||||
|
||||
32
main.py
32
main.py
@ -16,29 +16,6 @@ from app.core.database import init_db
|
||||
from app.core.auth_service import AuthService
|
||||
from app.core.database import execute_query_single
|
||||
|
||||
|
||||
_users_column_cache: dict[str, bool] = {}
|
||||
|
||||
|
||||
def _users_column_exists(column_name: str) -> bool:
|
||||
if column_name in _users_column_cache:
|
||||
return _users_column_cache[column_name]
|
||||
|
||||
result = execute_query_single(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'users'
|
||||
AND column_name = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(column_name,),
|
||||
)
|
||||
exists = bool(result)
|
||||
_users_column_cache[column_name] = exists
|
||||
return exists
|
||||
|
||||
def get_version():
|
||||
"""Read version from VERSION file"""
|
||||
try:
|
||||
@ -288,16 +265,11 @@ async def auth_middleware(request: Request, call_next):
|
||||
content={"detail": "Invalid token"}
|
||||
)
|
||||
user_id = int(payload.get("sub"))
|
||||
|
||||
if _users_column_exists("is_2fa_enabled"):
|
||||
user = execute_query_single(
|
||||
"SELECT COALESCE(is_2fa_enabled, FALSE) AS is_2fa_enabled FROM users WHERE user_id = %s",
|
||||
(user_id,),
|
||||
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
|
||||
else:
|
||||
# Older schemas without 2FA columns should not block authenticated requests.
|
||||
is_2fa_enabled = False
|
||||
|
||||
if not is_2fa_enabled:
|
||||
allowed_2fa_paths = (
|
||||
|
||||
@ -37,7 +37,7 @@ CREATE TABLE email_rules (
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_user_id INTEGER,
|
||||
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) ON DELETE SET NULL
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Email Messages Table (main storage)
|
||||
@ -183,7 +183,7 @@ SELECT
|
||||
COUNT(ea.id) as attachment_count_actual,
|
||||
er.name as rule_name,
|
||||
v.name as supplier_name,
|
||||
tc.name as customer_name,
|
||||
tc.customer_name,
|
||||
tcase.title as case_title
|
||||
FROM email_messages em
|
||||
LEFT JOIN email_attachments ea ON em.id = ea.email_id
|
||||
@ -193,7 +193,7 @@ LEFT JOIN tmodule_customers tc ON em.customer_id = tc.id
|
||||
LEFT JOIN tmodule_cases tcase ON em.linked_case_id = tcase.id
|
||||
WHERE em.deleted_at IS NULL
|
||||
AND em.status IN ('new', 'error')
|
||||
GROUP BY em.id, er.name, v.name, tc.name, tcase.title
|
||||
GROUP BY em.id, er.name, v.name, tc.customer_name, tcase.title
|
||||
ORDER BY em.received_date DESC;
|
||||
|
||||
-- View for recent email activity
|
||||
|
||||
@ -27,9 +27,9 @@ CREATE TABLE IF NOT EXISTS tticket_relations (
|
||||
CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_relations_ticket ON tticket_relations(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_relations_related ON tticket_relations(related_ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_relations_type ON tticket_relations(relation_type);
|
||||
CREATE INDEX idx_tticket_relations_ticket ON tticket_relations(ticket_id);
|
||||
CREATE INDEX idx_tticket_relations_related ON tticket_relations(related_ticket_id);
|
||||
CREATE INDEX idx_tticket_relations_type ON tticket_relations(relation_type);
|
||||
|
||||
-- View for at finde alle relationer for en ticket (begge retninger)
|
||||
CREATE OR REPLACE VIEW tticket_all_relations AS
|
||||
@ -90,10 +90,10 @@ CREATE TABLE IF NOT EXISTS tticket_calendar_events (
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_date ON tticket_calendar_events(event_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_type ON tticket_calendar_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_calendar_status ON tticket_calendar_events(status);
|
||||
CREATE INDEX idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
|
||||
CREATE INDEX idx_tticket_calendar_date ON tticket_calendar_events(event_date);
|
||||
CREATE INDEX idx_tticket_calendar_type ON tticket_calendar_events(event_type);
|
||||
CREATE INDEX idx_tticket_calendar_status ON tticket_calendar_events(status);
|
||||
|
||||
-- ============================================================================
|
||||
-- TEMPLATES (svarskabeloner, guides, standardbreve)
|
||||
@ -128,8 +128,8 @@ CREATE TABLE IF NOT EXISTS tticket_templates (
|
||||
usage_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_templates_category ON tticket_templates(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_templates_active ON tticket_templates(is_active);
|
||||
CREATE INDEX idx_tticket_templates_category ON tticket_templates(category);
|
||||
CREATE INDEX idx_tticket_templates_active ON tticket_templates(is_active);
|
||||
|
||||
-- ============================================================================
|
||||
-- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt)
|
||||
@ -143,8 +143,8 @@ CREATE TABLE IF NOT EXISTS tticket_template_usage (
|
||||
was_modified BOOLEAN DEFAULT false -- Blev template redigeret før afsendelse?
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_template ON tticket_template_usage(template_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
|
||||
CREATE INDEX idx_tticket_template_usage_template ON tticket_template_usage(template_id);
|
||||
CREATE INDEX idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- AI SUGGESTIONS (forslag til actions - aldrig automatisk)
|
||||
@ -186,10 +186,10 @@ CREATE TABLE IF NOT EXISTS tticket_ai_suggestions (
|
||||
expires_at TIMESTAMP -- Forslag udløber efter X dage
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
|
||||
CREATE INDEX idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
|
||||
CREATE INDEX idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type);
|
||||
CREATE INDEX idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
|
||||
CREATE INDEX idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- EMAIL METADATA (udvidet til contact identification)
|
||||
@ -227,9 +227,9 @@ CREATE TABLE IF NOT EXISTS tticket_email_metadata (
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_email_message_id ON tticket_email_metadata(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_email_from ON tticket_email_metadata(from_email);
|
||||
CREATE INDEX idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
|
||||
CREATE INDEX idx_tticket_email_message_id ON tticket_email_metadata(message_id);
|
||||
CREATE INDEX idx_tticket_email_from ON tticket_email_metadata(from_email);
|
||||
|
||||
-- ============================================================================
|
||||
-- Tilføj manglende kolonner til existing tticket_tickets
|
||||
@ -265,15 +265,9 @@ CREATE TABLE IF NOT EXISTS tticket_audit_log (
|
||||
metadata JSONB -- Additional context
|
||||
);
|
||||
|
||||
ALTER TABLE tticket_audit_log
|
||||
ADD COLUMN IF NOT EXISTS field_name VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS metadata JSONB;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_audit_action ON tticket_audit_log(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
|
||||
CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
|
||||
CREATE INDEX idx_tticket_audit_action ON tticket_audit_log(action);
|
||||
CREATE INDEX idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGERS for audit logging
|
||||
|
||||
@ -24,17 +24,7 @@ ADD COLUMN IF NOT EXISTS time_date DATE;
|
||||
ALTER TABLE tmodule_order_lines
|
||||
ADD COLUMN IF NOT EXISTS is_travel BOOLEAN DEFAULT false;
|
||||
|
||||
-- Log migration when the legacy tracking table exists
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'migration_log'
|
||||
) THEN
|
||||
-- Log migration
|
||||
INSERT INTO migration_log (migration_name, applied_at)
|
||||
VALUES ('031_add_is_travel_column', CURRENT_TIMESTAMP)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
@ -4,13 +4,13 @@
|
||||
|
||||
-- Add import_method column
|
||||
ALTER TABLE email_messages
|
||||
ADD COLUMN IF NOT EXISTS import_method VARCHAR(50) DEFAULT 'imap';
|
||||
ADD COLUMN import_method VARCHAR(50) DEFAULT 'imap';
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON COLUMN email_messages.import_method IS 'How the email was imported: imap, graph_api, or manual_upload';
|
||||
|
||||
-- Create index for filtering by import method
|
||||
CREATE INDEX IF NOT EXISTS idx_email_messages_import_method ON email_messages(import_method);
|
||||
CREATE INDEX idx_email_messages_import_method ON email_messages(import_method);
|
||||
|
||||
-- Update existing records to reflect their actual source
|
||||
-- (all existing emails were fetched via IMAP or Graph API)
|
||||
@ -19,9 +19,6 @@ SET import_method = 'imap'
|
||||
WHERE import_method IS NULL;
|
||||
|
||||
-- Add constraint to ensure valid values
|
||||
ALTER TABLE email_messages
|
||||
DROP CONSTRAINT IF EXISTS chk_email_import_method;
|
||||
|
||||
ALTER TABLE email_messages
|
||||
ADD CONSTRAINT chk_email_import_method
|
||||
CHECK (import_method IN ('imap', 'graph_api', 'manual_upload'));
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
-- 069_conversation_category.sql
|
||||
-- Add category column for conversation classification
|
||||
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
|
||||
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
|
||||
COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
-- 072_add_category_to_conversations.sql
|
||||
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'General';
|
||||
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
|
||||
COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)';
|
||||
|
||||
@ -11,4 +11,4 @@ CREATE TABLE IF NOT EXISTS sag_kommentarer (
|
||||
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sag_kommentarer_sag_id ON sag_kommentarer(sag_id);
|
||||
CREATE INDEX idx_sag_kommentarer_sag_id ON sag_comments(sag_id);
|
||||
|
||||
@ -51,7 +51,7 @@ SELECT
|
||||
s.customer_id,
|
||||
cust.name as customer_name,
|
||||
s.sag_id,
|
||||
sag.titel as sag_title,
|
||||
sag.title as sag_title,
|
||||
s.session_link,
|
||||
s.started_at,
|
||||
s.ended_at,
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
-- Migration 144: Extend tags with brand/type classes and catch words
|
||||
|
||||
-- Add catch words storage for tag suggestion matching
|
||||
ALTER TABLE tags
|
||||
ADD COLUMN IF NOT EXISTS catch_words_json JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
-- Extend allowed tag types to include brand and type
|
||||
DO $$
|
||||
DECLARE
|
||||
constraint_name text;
|
||||
BEGIN
|
||||
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_type_check;
|
||||
|
||||
FOR constraint_name IN
|
||||
SELECT con.conname
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
WHERE rel.relname = 'tags'
|
||||
AND con.contype = 'c'
|
||||
AND pg_get_constraintdef(con.oid) ILIKE '%type IN (%'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE tags DROP CONSTRAINT %I', constraint_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE tags
|
||||
ADD CONSTRAINT tags_type_check
|
||||
CHECK (type IN ('workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type'));
|
||||
|
||||
-- Seed a couple of starter tags for the new classes
|
||||
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
|
||||
VALUES
|
||||
('Microsoft', 'brand', 'Brand tag for Microsoft related cases', '#006d77', 'bi-microsoft', true, '["microsoft","ms 365","office 365","azure"]'::jsonb),
|
||||
('Adobe', 'brand', 'Brand tag for Adobe related cases', '#006d77', 'bi-box', true, '["adobe","acrobat","creative cloud"]'::jsonb),
|
||||
('Printer', 'type', 'Type tag for printer related work', '#5c677d', 'bi-printer', true, '["printer","toner","print","scanner"]'::jsonb),
|
||||
('Email', 'type', 'Type tag for mail related work', '#5c677d', 'bi-envelope', true, '["mail","email","outlook","smtp","imap"]'::jsonb)
|
||||
ON CONFLICT (name, type) DO NOTHING;
|
||||
|
||||
COMMENT ON COLUMN tags.catch_words_json IS 'JSON array of catch words used for automated tag suggestions';
|
||||
@ -1,51 +0,0 @@
|
||||
-- Migration 145: Seed brand tags (A-Z starter set)
|
||||
-- DEPRECATED: Superseded by migration 147 (master brand + type seed).
|
||||
-- Keep for historical traceability; do not run together with 147.
|
||||
|
||||
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
|
||||
VALUES
|
||||
('3 Mobil', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["3 mobil", "3"]'::jsonb),
|
||||
('ABA', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["aba"]'::jsonb),
|
||||
('Android', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["android"]'::jsonb),
|
||||
('Anydesk', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["anydesk"]'::jsonb),
|
||||
('Apple', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["apple", "iphone", "ipad", "macbook"]'::jsonb),
|
||||
('Bitwarden', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bitwarden"]'::jsonb),
|
||||
('BMC Networks', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc networks", "bmc"]'::jsonb),
|
||||
('BMC Webhosting', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc webhosting"]'::jsonb),
|
||||
('Brother', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["brother"]'::jsonb),
|
||||
('Canon', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["canon"]'::jsonb),
|
||||
('Cisco', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cisco"]'::jsonb),
|
||||
('Clickshare', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["clickshare", "barco"]'::jsonb),
|
||||
('CTS', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cts"]'::jsonb),
|
||||
('Dropbox', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["dropbox"]'::jsonb),
|
||||
('Epson', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["epson"]'::jsonb),
|
||||
('ESET', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["eset"]'::jsonb),
|
||||
('GlobalConnect', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["globalconnect"]'::jsonb),
|
||||
('Google', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["google", "gmail", "workspace"]'::jsonb),
|
||||
('HP', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["hp", "hewlett packard"]'::jsonb),
|
||||
('IBAK', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ibak"]'::jsonb),
|
||||
('IP Nordic', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ip nordic"]'::jsonb),
|
||||
('Lenovo', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["lenovo", "thinkpad"]'::jsonb),
|
||||
('Microsoft', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["microsoft", "windows", "azure", "teams"]'::jsonb),
|
||||
('Nextcloud', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nextcloud"]'::jsonb),
|
||||
('NFTV', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nftv"]'::jsonb),
|
||||
('Office 365', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["office 365", "o365", "m365", "microsoft 365"]'::jsonb),
|
||||
('Philips', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["philips"]'::jsonb),
|
||||
('Pronestor/Planner', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["pronestor", "planner"]'::jsonb),
|
||||
('Refurb', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["refurb"]'::jsonb),
|
||||
('Samsung', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["samsung"]'::jsonb),
|
||||
('Sentia', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["sentia"]'::jsonb),
|
||||
('Simply-CRM', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["simply-crm", "simply crm"]'::jsonb),
|
||||
('Syncplify', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["syncplify"]'::jsonb),
|
||||
('TDC', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["tdc"]'::jsonb),
|
||||
('Teltonika', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["teltonika"]'::jsonb),
|
||||
('The Union', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["the union"]'::jsonb),
|
||||
('Ubiquiti', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ubiquiti", "unifi"]'::jsonb),
|
||||
('Vincentz', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["vincentz"]'::jsonb),
|
||||
('VisionLine', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["visionline"]'::jsonb),
|
||||
('Yealink', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["yealink"]'::jsonb)
|
||||
ON CONFLICT (name, type) DO UPDATE
|
||||
SET
|
||||
is_active = EXCLUDED.is_active,
|
||||
catch_words_json = EXCLUDED.catch_words_json,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
@ -1,101 +0,0 @@
|
||||
-- Migration 146: Seed type tags (case type starter set)
|
||||
-- DEPRECATED: Superseded by migration 147 (master brand + type seed).
|
||||
-- Keep for historical traceability; do not run together with 147.
|
||||
|
||||
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
|
||||
VALUES
|
||||
('4g / 5g modem', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["4g", "5g", "modem"]'::jsonb),
|
||||
('Accounting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["accounting", "bogholderi"]'::jsonb),
|
||||
('Adgangskode', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["adgangskode", "password"]'::jsonb),
|
||||
('Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["andet"]'::jsonb),
|
||||
('Antivirus', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["antivirus", "virus"]'::jsonb),
|
||||
('Arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["arkiv"]'::jsonb),
|
||||
('AV udstyr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["av udstyr", "av"]'::jsonb),
|
||||
('Backup', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["backup"]'::jsonb),
|
||||
('BMC Mobil recorder', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["bmc mobil recorder", "mobil recorder"]'::jsonb),
|
||||
('Booking system', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["booking system", "booking"]'::jsonb),
|
||||
('DHCP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dhcp"]'::jsonb),
|
||||
('DNS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dns"]'::jsonb),
|
||||
('Domæne/Web', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["domæne", "domain", "web"]'::jsonb),
|
||||
('Drift', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["drift", "operations"]'::jsonb),
|
||||
('Dropbox', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dropbox"]'::jsonb),
|
||||
('Email', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["email", "e-mail"]'::jsonb),
|
||||
('Faktura spørgsmål', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura spørgsmål", "invoice question"]'::jsonb),
|
||||
('Faktura til betaling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura til betaling", "invoice payment"]'::jsonb),
|
||||
('Hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware"]'::jsonb),
|
||||
('Hardware order', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware order", "hardware ordre"]'::jsonb),
|
||||
('Headset', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["headset"]'::jsonb),
|
||||
('Hosting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hosting"]'::jsonb),
|
||||
('IBAK', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ibak"]'::jsonb),
|
||||
('Info/møde skærm', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["infoskærm", "møde skærm", "info skærm"]'::jsonb),
|
||||
('Installation af hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["installation af hardware", "hardware installation"]'::jsonb),
|
||||
('Internet forbindelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["internet forbindelse", "internet"]'::jsonb),
|
||||
('Invoice', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["invoice", "faktura"]'::jsonb),
|
||||
('IP telefon', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ip telefon", "voip telefon"]'::jsonb),
|
||||
('Kalender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalender", "calendar"]'::jsonb),
|
||||
('Kalenderopsætning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalenderopsætning", "calendar setup"]'::jsonb),
|
||||
('Kreditering', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kreditering", "credit note"]'::jsonb),
|
||||
('Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["licenser", "licenses"]'::jsonb),
|
||||
('M365 - Defender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 defender", "defender"]'::jsonb),
|
||||
('M365 - Entra/Azure', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 entra", "entra", "azure"]'::jsonb),
|
||||
('M365 - Intune', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 intune", "intune"]'::jsonb),
|
||||
('M365 - Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 licenser", "m365 licenses"]'::jsonb),
|
||||
('M365 - Office', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 office", "office"]'::jsonb),
|
||||
('M365 - Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 sharepoint", "sharepoint"]'::jsonb),
|
||||
('M365 - Users', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 users", "users"]'::jsonb),
|
||||
('MacOS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["macos", "mac"]'::jsonb),
|
||||
('Mail', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail", "email"]'::jsonb),
|
||||
('Mail-arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail-arkiv", "mail arkiv"]'::jsonb),
|
||||
('MFA / 2FA', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mfa", "2fa"]'::jsonb),
|
||||
('Mobil', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mobil", "mobile"]'::jsonb),
|
||||
('NAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nas"]'::jsonb),
|
||||
('Nedbrud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nedbrud", "outage"]'::jsonb),
|
||||
('Netværk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["netværk", "network"]'::jsonb),
|
||||
('Nextcloud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nextcloud"]'::jsonb),
|
||||
('NP ind/ud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["np ind/ud", "nummerportering"]'::jsonb),
|
||||
('Ny fiber kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny fiber kunde", "fiber kunde"]'::jsonb),
|
||||
('Ny hosting kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny hosting kunde", "hosting kunde"]'::jsonb),
|
||||
('Ny IT kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny it kunde", "it kunde"]'::jsonb),
|
||||
('Ny kontorhotel kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny kontorhotel kunde", "kontorhotel"]'::jsonb),
|
||||
('Ny telefoni kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny telefoni kunde", "telefoni kunde"]'::jsonb),
|
||||
('Offboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["offboarding"]'::jsonb),
|
||||
('Onboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["onboarding"]'::jsonb),
|
||||
('Oprettelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprettelse", "create"]'::jsonb),
|
||||
('Oprydning / Geninstallation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprydning", "geninstallation"]'::jsonb),
|
||||
('Opsætning / Installation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsætning", "installation", "setup"]'::jsonb),
|
||||
('Opsigelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsigelse", "termination"]'::jsonb),
|
||||
('Printer', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["printer"]'::jsonb),
|
||||
('RDP/Fjernskrivebord', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["rdp", "fjernskrivebord", "remote desktop"]'::jsonb),
|
||||
('Router / Firewall', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["router", "firewall"]'::jsonb),
|
||||
('Send faktura', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["send faktura", "send invoice"]'::jsonb),
|
||||
('Server - Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server andet", "server"]'::jsonb),
|
||||
('Server - SFTP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server sftp", "sftp"]'::jsonb),
|
||||
('Server - TrueNAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["truenas", "server truenas"]'::jsonb),
|
||||
('Server - Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server windows", "windows server"]'::jsonb),
|
||||
('Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sharepoint"]'::jsonb),
|
||||
('Sikkerhed', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sikkerhed", "security"]'::jsonb),
|
||||
('Simkort', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["simkort", "sim card"]'::jsonb),
|
||||
('Små ændringer!!!!!!!', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["små ændringer", "small changes"]'::jsonb),
|
||||
('Software', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["software"]'::jsonb),
|
||||
('Spærring', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["spærring", "block"]'::jsonb),
|
||||
('Switch', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["switch"]'::jsonb),
|
||||
('Teams', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["teams", "microsoft teams"]'::jsonb),
|
||||
('Telefonnr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["telefonnr", "telefonnummer"]'::jsonb),
|
||||
('Udlejning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udlejning", "rental"]'::jsonb),
|
||||
('Udvikling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udvikling", "development"]'::jsonb),
|
||||
('Uisp', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["uisp"]'::jsonb),
|
||||
('Unifi', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["unifi"]'::jsonb),
|
||||
('Vagtkald', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vagtkald"]'::jsonb),
|
||||
('Voip', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["voip"]'::jsonb),
|
||||
('VPN', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vpn"]'::jsonb),
|
||||
('WEB', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["web", "website"]'::jsonb),
|
||||
('WIFI', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["wifi", "wi-fi"]'::jsonb),
|
||||
('Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows"]'::jsonb),
|
||||
('Windows AD', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows ad", "active directory", "ad"]'::jsonb),
|
||||
('Workspace / Office365', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["workspace", "office365", "office 365"]'::jsonb),
|
||||
('Anydesk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["anydesk"]'::jsonb)
|
||||
ON CONFLICT (name, type) DO UPDATE
|
||||
SET
|
||||
is_active = EXCLUDED.is_active,
|
||||
catch_words_json = EXCLUDED.catch_words_json,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
@ -1,148 +0,0 @@
|
||||
-- Migration 147: Master seed for brand + type tags
|
||||
-- Depends on migration 144 (brand/type + catch_words_json)
|
||||
|
||||
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
|
||||
VALUES
|
||||
('3 Mobil', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["3 mobil", "3"]'::jsonb),
|
||||
('ABA', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["aba"]'::jsonb),
|
||||
('Android', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["android"]'::jsonb),
|
||||
('Anydesk', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["anydesk"]'::jsonb),
|
||||
('Apple', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["apple", "iphone", "ipad", "macbook"]'::jsonb),
|
||||
('Bitwarden', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bitwarden"]'::jsonb),
|
||||
('BMC Networks', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc networks", "bmc"]'::jsonb),
|
||||
('BMC Webhosting', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["bmc webhosting"]'::jsonb),
|
||||
('Brother', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["brother"]'::jsonb),
|
||||
('Canon', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["canon"]'::jsonb),
|
||||
('Cisco', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cisco"]'::jsonb),
|
||||
('Clickshare', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["clickshare", "barco"]'::jsonb),
|
||||
('CTS', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["cts"]'::jsonb),
|
||||
('Dropbox', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["dropbox"]'::jsonb),
|
||||
('Epson', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["epson"]'::jsonb),
|
||||
('ESET', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["eset"]'::jsonb),
|
||||
('GlobalConnect', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["globalconnect"]'::jsonb),
|
||||
('Google', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["google", "gmail", "workspace"]'::jsonb),
|
||||
('HP', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["hp", "hewlett packard"]'::jsonb),
|
||||
('IBAK', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ibak"]'::jsonb),
|
||||
('IP Nordic', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ip nordic"]'::jsonb),
|
||||
('Lenovo', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["lenovo", "thinkpad"]'::jsonb),
|
||||
('Microsoft', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["microsoft", "windows", "azure", "teams"]'::jsonb),
|
||||
('Nextcloud', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nextcloud"]'::jsonb),
|
||||
('NFTV', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["nftv"]'::jsonb),
|
||||
('Office 365', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["office 365", "o365", "m365", "microsoft 365"]'::jsonb),
|
||||
('Philips', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["philips"]'::jsonb),
|
||||
('Pronestor/Planner', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["pronestor", "planner"]'::jsonb),
|
||||
('Refurb', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["refurb"]'::jsonb),
|
||||
('Samsung', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["samsung"]'::jsonb),
|
||||
('Sentia', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["sentia"]'::jsonb),
|
||||
('Simply-CRM', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["simply-crm", "simply crm"]'::jsonb),
|
||||
('Syncplify', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["syncplify"]'::jsonb),
|
||||
('TDC', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["tdc"]'::jsonb),
|
||||
('Teltonika', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["teltonika"]'::jsonb),
|
||||
('The Union', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["the union"]'::jsonb),
|
||||
('Ubiquiti', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["ubiquiti", "unifi"]'::jsonb),
|
||||
('Vincentz', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["vincentz"]'::jsonb),
|
||||
('VisionLine', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["visionline"]'::jsonb),
|
||||
('Yealink', 'brand', 'Brand tag', '#006d77', 'bi-tag', true, '["yealink"]'::jsonb)
|
||||
ON CONFLICT (name, type) DO UPDATE
|
||||
SET
|
||||
is_active = EXCLUDED.is_active,
|
||||
catch_words_json = EXCLUDED.catch_words_json,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO tags (name, type, description, color, icon, is_active, catch_words_json)
|
||||
VALUES
|
||||
('4g / 5g modem', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["4g", "5g", "modem"]'::jsonb),
|
||||
('Accounting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["accounting", "bogholderi"]'::jsonb),
|
||||
('Adgangskode', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["adgangskode", "password"]'::jsonb),
|
||||
('Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["andet"]'::jsonb),
|
||||
('Antivirus', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["antivirus", "virus"]'::jsonb),
|
||||
('Arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["arkiv"]'::jsonb),
|
||||
('AV udstyr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["av udstyr", "av"]'::jsonb),
|
||||
('Backup', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["backup"]'::jsonb),
|
||||
('BMC Mobil recorder', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["bmc mobil recorder", "mobil recorder"]'::jsonb),
|
||||
('Booking system', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["booking system", "booking"]'::jsonb),
|
||||
('DHCP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dhcp"]'::jsonb),
|
||||
('DNS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dns"]'::jsonb),
|
||||
('Domæne/Web', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["domæne", "domain", "web"]'::jsonb),
|
||||
('Drift', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["drift", "operations"]'::jsonb),
|
||||
('Dropbox', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["dropbox"]'::jsonb),
|
||||
('Email', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["email", "e-mail"]'::jsonb),
|
||||
('Faktura spørgsmål', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura spørgsmål", "invoice question"]'::jsonb),
|
||||
('Faktura til betaling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["faktura til betaling", "invoice payment"]'::jsonb),
|
||||
('Hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware"]'::jsonb),
|
||||
('Hardware order', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hardware order", "hardware ordre"]'::jsonb),
|
||||
('Headset', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["headset"]'::jsonb),
|
||||
('Hosting', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["hosting"]'::jsonb),
|
||||
('IBAK', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ibak"]'::jsonb),
|
||||
('Info/møde skærm', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["infoskærm", "møde skærm", "info skærm"]'::jsonb),
|
||||
('Installation af hardware', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["installation af hardware", "hardware installation"]'::jsonb),
|
||||
('Internet forbindelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["internet forbindelse", "internet"]'::jsonb),
|
||||
('Invoice', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["invoice", "faktura"]'::jsonb),
|
||||
('IP telefon', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ip telefon", "voip telefon"]'::jsonb),
|
||||
('Kalender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalender", "calendar"]'::jsonb),
|
||||
('Kalenderopsætning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kalenderopsætning", "calendar setup"]'::jsonb),
|
||||
('Kreditering', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["kreditering", "credit note"]'::jsonb),
|
||||
('Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["licenser", "licenses"]'::jsonb),
|
||||
('M365 - Defender', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 defender", "defender"]'::jsonb),
|
||||
('M365 - Entra/Azure', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 entra", "entra", "azure"]'::jsonb),
|
||||
('M365 - Intune', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 intune", "intune"]'::jsonb),
|
||||
('M365 - Licenser', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 licenser", "m365 licenses"]'::jsonb),
|
||||
('M365 - Office', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 office", "office"]'::jsonb),
|
||||
('M365 - Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 sharepoint", "sharepoint"]'::jsonb),
|
||||
('M365 - Users', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["m365 users", "users"]'::jsonb),
|
||||
('MacOS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["macos", "mac"]'::jsonb),
|
||||
('Mail', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail", "email"]'::jsonb),
|
||||
('Mail-arkiv', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mail-arkiv", "mail arkiv"]'::jsonb),
|
||||
('MFA / 2FA', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mfa", "2fa"]'::jsonb),
|
||||
('Mobil', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["mobil", "mobile"]'::jsonb),
|
||||
('NAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nas"]'::jsonb),
|
||||
('Nedbrud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nedbrud", "outage"]'::jsonb),
|
||||
('Netværk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["netværk", "network"]'::jsonb),
|
||||
('Nextcloud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["nextcloud"]'::jsonb),
|
||||
('NP ind/ud', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["np ind/ud", "nummerportering"]'::jsonb),
|
||||
('Ny fiber kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny fiber kunde", "fiber kunde"]'::jsonb),
|
||||
('Ny hosting kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny hosting kunde", "hosting kunde"]'::jsonb),
|
||||
('Ny IT kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny it kunde", "it kunde"]'::jsonb),
|
||||
('Ny kontorhotel kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny kontorhotel kunde", "kontorhotel"]'::jsonb),
|
||||
('Ny telefoni kunde', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["ny telefoni kunde", "telefoni kunde"]'::jsonb),
|
||||
('Offboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["offboarding"]'::jsonb),
|
||||
('Onboarding', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["onboarding"]'::jsonb),
|
||||
('Oprettelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprettelse", "create"]'::jsonb),
|
||||
('Oprydning / Geninstallation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["oprydning", "geninstallation"]'::jsonb),
|
||||
('Opsætning / Installation', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsætning", "installation", "setup"]'::jsonb),
|
||||
('Opsigelse', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["opsigelse", "termination"]'::jsonb),
|
||||
('Printer', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["printer"]'::jsonb),
|
||||
('RDP/Fjernskrivebord', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["rdp", "fjernskrivebord", "remote desktop"]'::jsonb),
|
||||
('Router / Firewall', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["router", "firewall"]'::jsonb),
|
||||
('Send faktura', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["send faktura", "send invoice"]'::jsonb),
|
||||
('Server - Andet', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server andet", "server"]'::jsonb),
|
||||
('Server - SFTP', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server sftp", "sftp"]'::jsonb),
|
||||
('Server - TrueNAS', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["truenas", "server truenas"]'::jsonb),
|
||||
('Server - Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["server windows", "windows server"]'::jsonb),
|
||||
('Sharepoint', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sharepoint"]'::jsonb),
|
||||
('Sikkerhed', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["sikkerhed", "security"]'::jsonb),
|
||||
('Simkort', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["simkort", "sim card"]'::jsonb),
|
||||
('Små ændringer!!!!!!!', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["små ændringer", "small changes"]'::jsonb),
|
||||
('Software', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["software"]'::jsonb),
|
||||
('Spærring', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["spærring", "block"]'::jsonb),
|
||||
('Switch', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["switch"]'::jsonb),
|
||||
('Teams', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["teams", "microsoft teams"]'::jsonb),
|
||||
('Telefonnr', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["telefonnr", "telefonnummer"]'::jsonb),
|
||||
('Udlejning', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udlejning", "rental"]'::jsonb),
|
||||
('Udvikling', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["udvikling", "development"]'::jsonb),
|
||||
('Uisp', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["uisp"]'::jsonb),
|
||||
('Unifi', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["unifi"]'::jsonb),
|
||||
('Vagtkald', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vagtkald"]'::jsonb),
|
||||
('Voip', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["voip"]'::jsonb),
|
||||
('VPN', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["vpn"]'::jsonb),
|
||||
('WEB', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["web", "website"]'::jsonb),
|
||||
('WIFI', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["wifi", "wi-fi"]'::jsonb),
|
||||
('Windows', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows"]'::jsonb),
|
||||
('Windows AD', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["windows ad", "active directory", "ad"]'::jsonb),
|
||||
('Workspace / Office365', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["workspace", "office365", "office 365"]'::jsonb),
|
||||
('Anydesk', 'type', 'Case type tag', '#5c677d', 'bi-tag', true, '["anydesk"]'::jsonb)
|
||||
ON CONFLICT (name, type) DO UPDATE
|
||||
SET
|
||||
is_active = EXCLUDED.is_active,
|
||||
catch_words_json = EXCLUDED.catch_words_json,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
@ -1,5 +0,0 @@
|
||||
-- Add persistent next-task selection for case todo steps
|
||||
ALTER TABLE sag_todo_steps
|
||||
ADD COLUMN IF NOT EXISTS is_next BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sag_todo_steps_is_next ON sag_todo_steps (sag_id, is_next);
|
||||
@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NUMBERED_SQL_RE = re.compile(r"^\d+.*\.sql$")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run BMC Hub SQL migrations against the configured PostgreSQL database."
|
||||
)
|
||||
parser.add_argument(
|
||||
"files",
|
||||
nargs="*",
|
||||
help="Specific SQL files to run, relative to repo root (for example migrations/145_sag_start_date.sql).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Run all numbered SQL files from ./migrations in numeric order. Default when no files are provided.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--module",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Also run numbered SQL files from a module migration directory, relative to repo root.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the files that would run without executing them.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stop-on-error",
|
||||
action="store_true",
|
||||
help="Stop immediately on the first migration failure.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def patch_database_url_for_local_dev() -> None:
|
||||
if "@postgres" in settings.DATABASE_URL:
|
||||
logger.info("Patching DATABASE_URL for local run")
|
||||
settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433")
|
||||
|
||||
|
||||
def collect_numbered_sql(directory: Path) -> list[Path]:
|
||||
files = [p for p in directory.glob("*.sql") if NUMBERED_SQL_RE.match(p.name)]
|
||||
files.sort(key=lambda p: (int(re.match(r"^(\d+)", p.name).group(1)), p.name))
|
||||
return files
|
||||
|
||||
|
||||
def resolve_explicit_files(file_args: list[str]) -> list[Path]:
|
||||
resolved = []
|
||||
for raw in file_args:
|
||||
path = (ROOT / raw).resolve()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Migration file not found: {raw}")
|
||||
resolved.append(path)
|
||||
return resolved
|
||||
|
||||
|
||||
def build_file_list(args: argparse.Namespace) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
|
||||
if args.files:
|
||||
files.extend(resolve_explicit_files(args.files))
|
||||
else:
|
||||
files.extend(collect_numbered_sql(ROOT / "migrations"))
|
||||
|
||||
for module_dir in args.module:
|
||||
path = (ROOT / module_dir).resolve()
|
||||
if not path.exists() or not path.is_dir():
|
||||
raise FileNotFoundError(f"Module migration directory not found: {module_dir}")
|
||||
files.extend(collect_numbered_sql(path))
|
||||
|
||||
# Preserve order but remove duplicates.
|
||||
unique_files: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for path in files:
|
||||
if path not in seen:
|
||||
unique_files.append(path)
|
||||
seen.add(path)
|
||||
|
||||
return unique_files
|
||||
|
||||
|
||||
def run_files(files: list[Path], dry_run: bool, stop_on_error: bool) -> int:
|
||||
if not files:
|
||||
logger.info("No migration files selected.")
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
for path in files:
|
||||
logger.info("DRY %s", path.relative_to(ROOT))
|
||||
return 0
|
||||
|
||||
conn = psycopg2.connect(settings.DATABASE_URL)
|
||||
conn.autocommit = False
|
||||
cur = conn.cursor()
|
||||
failures: list[tuple[Path, str]] = []
|
||||
|
||||
try:
|
||||
for path in files:
|
||||
rel = path.relative_to(ROOT)
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
cur.execute(sql)
|
||||
conn.commit()
|
||||
logger.info("OK %s", rel)
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
message = str(exc).strip().splitlines()[0]
|
||||
failures.append((path, message))
|
||||
logger.error("FAIL %s: %s", rel, message)
|
||||
if stop_on_error:
|
||||
break
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if failures:
|
||||
logger.error("")
|
||||
logger.error("Failed migrations:")
|
||||
for path, message in failures:
|
||||
logger.error("- %s: %s", path.relative_to(ROOT), message)
|
||||
return 1
|
||||
|
||||
logger.info("")
|
||||
logger.info("All selected migrations completed successfully.")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
patch_database_url_for_local_dev()
|
||||
files = build_file_list(args)
|
||||
return run_files(files, dry_run=args.dry_run, stop_on_error=args.stop_on_error)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -212,12 +212,10 @@ class TagPicker {
|
||||
'status': '📊 Status - Tilstand',
|
||||
'category': '📁 Kategori - Emne',
|
||||
'priority': '🔥 Prioritet - Hastighed',
|
||||
'billing': '💰 Fakturering - Økonomi',
|
||||
'brand': '🏷️ Brand - Leverandør/produkt',
|
||||
'type': '🧩 Type - Sagstype'
|
||||
'billing': '💰 Fakturering - Økonomi'
|
||||
};
|
||||
|
||||
const typeOrder = ['workflow', 'status', 'category', 'priority', 'billing', 'brand', 'type'];
|
||||
const typeOrder = ['workflow', 'status', 'category', 'priority', 'billing'];
|
||||
|
||||
let html = '';
|
||||
typeOrder.forEach(type => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user