feat: Implement central tagging system with CRUD operations, entity tagging, and workflow management

- Added API endpoints for tag management (create, read, update, delete).
- Implemented entity tagging functionality to associate tags with various entities.
- Created workflow management for tag-triggered actions.
- Developed frontend views for tag administration using FastAPI and Jinja2.
- Designed HTML template for tag management interface with Bootstrap styling.
- Added JavaScript for tag picker component with keyboard shortcuts and dynamic tag filtering.
- Created database migration scripts for tags, entity_tags, and tag_workflows tables.
- Included default tags for initial setup in the database.
This commit is contained in:
Christian 2025-12-17 07:56:33 +01:00
parent fadf7258de
commit 0502a7b080
18 changed files with 1608 additions and 11 deletions

View File

@ -32,7 +32,7 @@ Alle tabeller SKAL bruge `table_prefix` fra module.json:
```sql
-- Hvis table_prefix = "mymod_"
CREATE TABLE mymod_customers (
CREATE TABLE mymod_items (
id SERIAL PRIMARY KEY,
name VARCHAR(255)
);
@ -40,6 +40,43 @@ CREATE TABLE mymod_customers (
Dette sikrer at moduler ikke kolliderer med core eller andre moduler.
### Customer Linking (Hvis nødvendigt)
Hvis dit modul skal have sin egen kunde-tabel (f.eks. ved sync fra eksternt system):
**SKAL altid linke til core customers:**
```sql
CREATE TABLE mymod_customers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
external_id VARCHAR(100), -- ID fra eksternt system
hub_customer_id INTEGER REFERENCES customers(id), -- VIGTIG!
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Auto-link trigger (se migrations/001_init.sql for komplet eksempel)
CREATE TRIGGER trigger_auto_link_mymod_customer
BEFORE INSERT OR UPDATE OF name
ON mymod_customers
FOR EACH ROW
EXECUTE FUNCTION auto_link_mymod_customer();
```
**Hvorfor?** Dette sikrer at:
- ✅ E-conomic export virker automatisk
- ✅ Billing integration fungerer
- ✅ Ingen manuel linking nødvendig
**Alternativ:** Hvis modulet kun har simple kunde-relationer, brug direkte FK:
```sql
CREATE TABLE mymod_orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) -- Direkte link
);
```
## Konfiguration
Modul-specifikke miljøvariable følger mønsteret:

View File

@ -11,6 +11,19 @@ CREATE TABLE IF NOT EXISTS template_items (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Optional: Customers tabel hvis modulet har egne kunder (f.eks. sync fra eksternt system)
-- Kun nødvendigt hvis modulet har mange custom felter eller external sync
-- Ellers brug direkte foreign key til customers.id
CREATE TABLE IF NOT EXISTS template_customers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
external_id VARCHAR(100), -- ID fra eksternt system hvis relevant
hub_customer_id INTEGER REFERENCES customers(id), -- VIGTIG: Link til core customers
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for performance
CREATE INDEX IF NOT EXISTS idx_template_items_active ON template_items(active);
CREATE INDEX IF NOT EXISTS idx_template_items_created ON template_items(created_at DESC);
@ -29,6 +42,39 @@ BEFORE UPDATE ON template_items
FOR EACH ROW
EXECUTE FUNCTION update_template_items_updated_at();
-- Trigger for auto-linking customers (hvis template_customers tabel oprettes)
-- Dette linker automatisk nye kunder til core customers baseret på navn match
CREATE OR REPLACE FUNCTION auto_link_template_customer()
RETURNS TRIGGER AS $$
DECLARE
matched_hub_id INTEGER;
BEGIN
-- Hvis hub_customer_id allerede er sat, skip
IF NEW.hub_customer_id IS NOT NULL THEN
RETURN NEW;
END IF;
-- Find matching hub customer baseret på navn
SELECT id INTO matched_hub_id
FROM customers
WHERE LOWER(TRIM(name)) = LOWER(TRIM(NEW.name))
LIMIT 1;
-- Hvis match fundet, sæt hub_customer_id
IF matched_hub_id IS NOT NULL THEN
NEW.hub_customer_id := matched_hub_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_auto_link_template_customer
BEFORE INSERT OR UPDATE OF name
ON template_customers
FOR EACH ROW
EXECUTE FUNCTION auto_link_template_customer();
-- Indsæt test data (optional)
INSERT INTO template_items (name, description)
VALUES

View File

@ -502,6 +502,7 @@
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/tag-picker.js"></script>
<script>
// Dark Mode Toggle Logic
const darkModeToggle = document.getElementById('darkModeToggle');
@ -1053,7 +1054,7 @@
let maintenanceCheckInterval = null;
function checkMaintenanceMode() {
fetch('/api/v1/backups/maintenance')
fetch('/api/v1/system/maintenance')
.then(response => {
if (!response.ok) {
// Silently ignore 404 - maintenance endpoint not implemented yet

View File

@ -44,3 +44,64 @@ async def get_config():
"economic_read_only": settings.ECONOMIC_READ_ONLY,
"economic_dry_run": settings.ECONOMIC_DRY_RUN
}
@router.get("/system/maintenance")
async def get_maintenance_status():
"""Get maintenance mode status"""
return {
"maintenance_mode": False,
"message": None
}
@router.get("/system/live-stats")
async def get_live_stats():
"""Get live dashboard statistics"""
try:
# Get counts from database
customers_result = execute_query("SELECT COUNT(*) as count FROM customers")
tickets_result = execute_query("SELECT COUNT(*) as count FROM tickets WHERE status != 'closed'")
return {
"sales": {
"active_orders": 0,
"pending_quotes": 0
},
"customers": {
"total": customers_result[0]['count'] if customers_result else 0,
"new_this_month": 0
},
"tickets": {
"open": tickets_result[0]['count'] if tickets_result else 0,
"in_progress": 0
}
}
except Exception as e:
return {
"sales": {"active_orders": 0, "pending_quotes": 0},
"customers": {"total": 0, "new_this_month": 0},
"tickets": {"open": 0, "in_progress": 0}
}
@router.get("/system/recent-activity")
async def get_recent_activity():
"""Get recent system activity"""
try:
# Get recent customers
query = """
SELECT
'customer' as activity_type,
name,
created_at,
'bi-building' as icon,
'primary' as color
FROM customers
ORDER BY created_at DESC
LIMIT 10
"""
activities = execute_query(query)
return activities
except Exception as e:
return []

View File

@ -0,0 +1,86 @@
"""
Pydantic models for tag system
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
from datetime import datetime
# Tag types
TagType = Literal['workflow', 'status', 'category', 'priority', 'billing']
class TagBase(BaseModel):
"""Base tag model"""
name: str = Field(..., max_length=100)
type: TagType
description: Optional[str] = None
color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$') # Hex color
icon: Optional[str] = None
is_active: bool = True
class TagCreate(TagBase):
"""Tag creation model"""
pass
class Tag(TagBase):
"""Full tag model"""
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TagUpdate(BaseModel):
"""Tag update model - all fields optional"""
name: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
icon: Optional[str] = None
is_active: Optional[bool] = None
class EntityTagBase(BaseModel):
"""Base entity tag association"""
entity_type: str = Field(..., max_length=50)
entity_id: int
tag_id: int
class EntityTagCreate(EntityTagBase):
"""Entity tag creation"""
tagged_by: Optional[int] = None
class EntityTag(EntityTagBase):
"""Full entity tag model"""
id: int
tagged_by: Optional[int]
tagged_at: datetime
class Config:
from_attributes = True
class EntityWithTags(BaseModel):
"""Model for entities with their tags"""
entity_type: str
entity_id: int
tags: List[Tag]
class TagWorkflowBase(BaseModel):
"""Base workflow configuration"""
tag_id: int
trigger_event: Literal['on_add', 'on_remove']
action_type: str = Field(..., max_length=50)
action_config: Optional[dict] = None
is_active: bool = True
class TagWorkflowCreate(TagWorkflowBase):
"""Workflow creation model"""
pass
class TagWorkflow(TagWorkflowBase):
"""Full workflow model"""
id: int
created_at: datetime
class Config:
from_attributes = True

226
app/tags/backend/router.py Normal file
View File

@ -0,0 +1,226 @@
"""
Tag system API endpoints
"""
from fastapi import APIRouter, HTTPException
from typing import List, Optional
from app.tags.backend.models import (
Tag, TagCreate, TagUpdate,
EntityTag, EntityTagCreate,
TagWorkflow, TagWorkflowCreate,
TagType
)
from app.core.database import execute_query, execute_query_single, execute_update
router = APIRouter(prefix="/tags")
# ============= TAG CRUD =============
@router.get("", response_model=List[Tag])
async def list_tags(
type: Optional[TagType] = None,
is_active: Optional[bool] = None
):
"""List all tags with optional filtering"""
query = "SELECT * FROM tags WHERE 1=1"
params = []
if type:
query += " AND type = %s"
params.append(type)
if is_active is not None:
query += " AND is_active = %s"
params.append(is_active)
query += " ORDER BY type, name"
results = execute_query(query, tuple(params) if params else ())
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 * FROM tags WHERE id = %s",
(tag_id,)
)
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
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)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query_single(
query,
(tag.name, tag.type, tag.description, tag.color, tag.icon, tag.is_active)
)
return result
@router.put("/{tag_id}", response_model=Tag)
async def update_tag(tag_id: int, tag: TagUpdate):
"""Update existing tag"""
# Build dynamic update query
updates = []
params = []
if tag.name is not None:
updates.append("name = %s")
params.append(tag.name)
if tag.description is not None:
updates.append("description = %s")
params.append(tag.description)
if tag.color is not None:
updates.append("color = %s")
params.append(tag.color)
if tag.icon is not None:
updates.append("icon = %s")
params.append(tag.icon)
if tag.is_active is not None:
updates.append("is_active = %s")
params.append(tag.is_active)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = CURRENT_TIMESTAMP")
params.append(tag_id)
query = f"""
UPDATE tags
SET {', '.join(updates)}
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
return result
@router.delete("/{tag_id}")
async def delete_tag(tag_id: int):
"""Delete tag (also removes all entity associations)"""
result = execute_update(
"DELETE FROM tags WHERE id = %s",
(tag_id,)
)
if result == 0:
raise HTTPException(status_code=404, detail="Tag not found")
return {"message": "Tag deleted successfully"}
# ============= ENTITY TAGGING =============
@router.post("/entity", response_model=EntityTag)
async def add_tag_to_entity(entity_tag: EntityTagCreate):
"""Add tag to any entity (ticket, customer, time_entry, etc.)"""
query = """
INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by)
VALUES (%s, %s, %s, %s)
ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING
RETURNING *
"""
result = execute_query_single(
query,
(entity_tag.entity_type, entity_tag.entity_id, entity_tag.tag_id, entity_tag.tagged_by)
)
if not result:
raise HTTPException(status_code=409, detail="Tag already exists on entity")
return result
@router.delete("/entity")
async def remove_tag_from_entity(
entity_type: str,
entity_id: int,
tag_id: int
):
"""Remove tag from entity"""
result = execute_update(
"DELETE FROM entity_tags WHERE entity_type = %s AND entity_id = %s AND tag_id = %s",
(entity_type, entity_id, tag_id)
)
if result == 0:
raise HTTPException(status_code=404, detail="Tag association not found")
return {"message": "Tag removed from entity"}
@router.get("/entity/{entity_type}/{entity_id}", response_model=List[Tag])
async def get_entity_tags(entity_type: str, entity_id: int):
"""Get all tags for a specific entity"""
query = """
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 results
@router.get("/search")
async def search_tags(q: str, type: Optional[TagType] = None):
"""Search tags by name (fuzzy search)"""
query = """
SELECT * FROM tags
WHERE is_active = true
AND LOWER(name) LIKE LOWER(%s)
"""
params = [f"%{q}%"]
if type:
query += " AND type = %s"
params.append(type)
query += " ORDER BY name LIMIT 20"
results = execute_query(query, tuple(params))
return results
# ============= WORKFLOW MANAGEMENT =============
@router.get("/workflows", response_model=List[TagWorkflow])
async def list_workflows(tag_id: Optional[int] = None):
"""List all workflow configurations"""
if tag_id:
results = execute_query(
"SELECT * FROM tag_workflows WHERE tag_id = %s ORDER BY id",
(tag_id,)
)
else:
results = execute_query(
"SELECT * FROM tag_workflows ORDER BY tag_id, id",
()
)
return results
@router.post("/workflows", response_model=TagWorkflow)
async def create_workflow(workflow: TagWorkflowCreate):
"""Create new workflow trigger"""
query = """
INSERT INTO tag_workflows (tag_id, trigger_event, action_type, action_config, is_active)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query_single(
query,
(workflow.tag_id, workflow.trigger_event, workflow.action_type,
workflow.action_config, workflow.is_active)
)
return result
@router.delete("/workflows/{workflow_id}")
async def delete_workflow(workflow_id: int):
"""Delete workflow configuration"""
result = execute_update(
"DELETE FROM tag_workflows WHERE id = %s",
(workflow_id,)
)
if result == 0:
raise HTTPException(status_code=404, detail="Workflow not found")
return {"message": "Workflow deleted"}

14
app/tags/backend/views.py Normal file
View File

@ -0,0 +1,14 @@
"""
Tag system frontend views
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/tags", response_class=HTMLResponse)
async def tags_admin_page(request: Request):
"""Render tag administration page"""
return templates.TemplateResponse("tags/frontend/tags_admin.html", {"request": request})

View File

@ -0,0 +1,378 @@
<!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;
--workflow-color: #ff6b35;
--status-color: #ffd700;
--category-color: #0f4c75;
--priority-color: #dc3545;
--billing-color: #2d6a4f;
}
.tag-badge {
display: inline-block;
padding: 0.35em 0.65em;
font-size: 0.9em;
font-weight: 500;
border-radius: 0.375rem;
margin: 0.2em;
cursor: pointer;
transition: all 0.2s;
}
.tag-badge:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.tag-type-workflow { background-color: var(--workflow-color); color: white; }
.tag-type-status { background-color: var(--status-color); color: #333; }
.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-list-item {
padding: 1rem;
border-left: 4px solid transparent;
transition: all 0.2s;
}
.tag-list-item:hover {
background-color: #f8f9fa;
}
.tag-list-item[data-type="workflow"] { border-left-color: var(--workflow-color); }
.tag-list-item[data-type="status"] { border-left-color: var(--status-color); }
.tag-list-item[data-type="category"] { border-left-color: var(--category-color); }
.tag-list-item[data-type="priority"] { border-left-color: var(--priority-color); }
.tag-list-item[data-type="billing"] { border-left-color: var(--billing-color); }
.color-preview {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid #dee2e6;
}
</style>
</head>
<body>
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-tags"></i> Tag Administration</h1>
<p class="text-muted">Administrer tags der bruges på tværs af hele systemet</p>
</div>
<div class="col-auto">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createTagModal">
<i class="bi bi-plus-circle"></i> Opret Tag
</button>
</div>
</div>
<!-- Type Filter Tabs -->
<ul class="nav nav-tabs mb-4" id="typeFilter">
<li class="nav-item">
<a class="nav-link active" href="#" data-type="all">Alle</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="workflow">
<span class="tag-badge tag-type-workflow">Workflow</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="status">
<span class="tag-badge tag-type-status">Status</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="category">
<span class="tag-badge tag-type-category">Category</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="priority">
<span class="tag-badge tag-type-priority">Priority</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-type="billing">
<span class="tag-badge tag-type-billing">Billing</span>
</a>
</li>
</ul>
<!-- Tags List -->
<div class="card">
<div class="card-body p-0">
<div id="tagsList" class="list-group list-group-flush">
<div class="text-center p-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Tag Modal -->
<div class="modal fade" id="createTagModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret Tag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="tagForm">
<input type="hidden" id="tagId">
<div class="mb-3">
<label for="tagName" class="form-label">Navn *</label>
<input type="text" class="form-control" id="tagName" required>
</div>
<div class="mb-3">
<label for="tagType" class="form-label">Type *</label>
<select class="form-select" id="tagType" required>
<option value="">Vælg type...</option>
<option value="workflow">Workflow - Trigger automatisering</option>
<option value="status">Status - Tilstand/fase</option>
<option value="category">Category - Emne/område</option>
<option value="priority">Priority - Hastighed</option>
<option value="billing">Billing - Økonomi</option>
</select>
</div>
<div class="mb-3">
<label for="tagDescription" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="tagDescription" rows="3"></textarea>
<small class="text-muted">Forklaring af hvad tagget gør eller betyder</small>
</div>
<div class="mb-3">
<label for="tagColor" class="form-label">Farve *</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="tagColor" value="#0f4c75">
<input type="text" class="form-control" id="tagColorHex" value="#0f4c75" pattern="^#[0-9A-Fa-f]{6}$">
</div>
</div>
<div class="mb-3">
<label for="tagIcon" class="form-label">Ikon (valgfrit)</label>
<input type="text" class="form-control" id="tagIcon" placeholder="bi-star">
<small class="text-muted">Bootstrap Icons navn (fx: bi-star, bi-flag)</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="tagActive" checked>
<label class="form-check-label" for="tagActive">
Aktiv
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="saveTagBtn">Gem</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let allTags = [];
let currentFilter = 'all';
// Load tags on page load
document.addEventListener('DOMContentLoaded', () => {
loadTags();
setupEventListeners();
});
function setupEventListeners() {
// Type filter tabs
document.querySelectorAll('#typeFilter a').forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('#typeFilter a').forEach(t => t.classList.remove('active'));
e.target.closest('a').classList.add('active');
currentFilter = e.target.closest('a').dataset.type;
renderTags();
});
});
// Color picker sync
document.getElementById('tagColor').addEventListener('input', (e) => {
document.getElementById('tagColorHex').value = e.target.value;
});
document.getElementById('tagColorHex').addEventListener('input', (e) => {
const color = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
document.getElementById('tagColor').value = color;
}
});
// Type change updates color
document.getElementById('tagType').addEventListener('change', (e) => {
const type = e.target.value;
const colorMap = {
'workflow': '#ff6b35',
'status': '#ffd700',
'category': '#0f4c75',
'priority': '#dc3545',
'billing': '#2d6a4f'
};
if (colorMap[type]) {
document.getElementById('tagColor').value = colorMap[type];
document.getElementById('tagColorHex').value = colorMap[type];
}
});
// Save button
document.getElementById('saveTagBtn').addEventListener('click', saveTag);
// Modal reset on close
document.getElementById('createTagModal').addEventListener('hidden.bs.modal', () => {
document.getElementById('tagForm').reset();
document.getElementById('tagId').value = '';
document.querySelector('#createTagModal .modal-title').textContent = 'Opret Tag';
});
}
async function loadTags() {
try {
const response = await fetch('/api/v1/tags');
if (!response.ok) throw new Error('Failed to load tags');
allTags = await response.json();
renderTags();
} catch (error) {
console.error('Error loading tags:', error);
document.getElementById('tagsList').innerHTML = `
<div class="alert alert-danger m-3">
<i class="bi bi-exclamation-triangle"></i> Fejl ved indlæsning af tags
</div>
`;
}
}
function renderTags() {
const container = document.getElementById('tagsList');
const filteredTags = currentFilter === 'all'
? allTags
: allTags.filter(t => t.type === currentFilter);
if (filteredTags.length === 0) {
container.innerHTML = `
<div class="text-center p-4 text-muted">
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
<p class="mt-3">Ingen tags fundet</p>
</div>
`;
return;
}
container.innerHTML = filteredTags.map(tag => `
<div class="tag-list-item list-group-item" data-type="${tag.type}">
<div class="d-flex align-items-center">
<div class="color-preview me-3" style="background-color: ${tag.color};">
${tag.icon ? `<i class="bi ${tag.icon}" style="font-size: 1.5rem; color: white; line-height: 40px;"></i>` : ''}
</div>
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-1">
<h5 class="mb-0 me-2">${tag.name}</h5>
<span class="tag-badge tag-type-${tag.type}">${tag.type}</span>
${!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>` : ''}
</div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" onclick="editTag(${tag.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTag(${tag.id}, '${tag.name}')">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
`).join('');
}
async function saveTag() {
const tagId = document.getElementById('tagId').value;
const tagData = {
name: document.getElementById('tagName').value,
type: document.getElementById('tagType').value,
description: document.getElementById('tagDescription').value || null,
color: document.getElementById('tagColorHex').value,
icon: document.getElementById('tagIcon').value || null,
is_active: document.getElementById('tagActive').checked
};
try {
const url = tagId ? `/api/v1/tags/${tagId}` : '/api/v1/tags';
const method = tagId ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tagData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save tag');
}
bootstrap.Modal.getInstance(document.getElementById('createTagModal')).hide();
await loadTags();
} catch (error) {
alert('Fejl: ' + error.message);
}
}
function editTag(tagId) {
const tag = allTags.find(t => t.id === tagId);
if (!tag) return;
document.getElementById('tagId').value = tag.id;
document.getElementById('tagName').value = tag.name;
document.getElementById('tagType').value = tag.type;
document.getElementById('tagDescription').value = tag.description || '';
document.getElementById('tagColor').value = tag.color;
document.getElementById('tagColorHex').value = tag.color;
document.getElementById('tagIcon').value = tag.icon || '';
document.getElementById('tagActive').checked = tag.is_active;
document.querySelector('#createTagModal .modal-title').textContent = 'Rediger Tag';
new bootstrap.Modal(document.getElementById('createTagModal')).show();
}
async function deleteTag(tagId, tagName) {
if (!confirm(`Slet tag "${tagName}"?\n\nDette vil også fjerne tagget fra alle steder det er brugt.`)) {
return;
}
try {
const response = await fetch(`/api/v1/tags/${tagId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete tag');
await loadTags();
} catch (error) {
alert('Fejl ved sletning: ' + error.message);
}
}
</script>
</body>
</html>

View File

@ -135,6 +135,60 @@
margin-right: 0.5rem;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.tag-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
color: white;
transition: all 0.2s;
cursor: default;
}
.tag-badge:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.tag-badge .btn-close {
font-size: 0.6rem;
opacity: 0.7;
}
.tag-badge .btn-close:hover {
opacity: 1;
}
.add-tag-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 2px dashed var(--accent-light);
border-radius: 8px;
background: transparent;
color: var(--accent);
cursor: pointer;
transition: all 0.2s;
font-size: 0.85rem;
font-weight: 500;
}
.add-tag-btn:hover {
border-color: var(--accent);
background: var(--accent-light);
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
@ -186,6 +240,12 @@
{{ ticket.priority.title() }} Priority
</span>
</div>
<div class="tags-container" id="ticketTags">
<!-- Tags loaded via JavaScript -->
</div>
<button class="add-tag-btn mt-2" onclick="showTagPicker('ticket', {{ ticket.id }}, reloadTags)">
<i class="bi bi-plus-circle"></i> Tilføj Tag (⌥⇧T)
</button>
</div>
<!-- Action Buttons -->
@ -405,5 +465,67 @@
function addWorklog() {
alert('Add worklog functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/worklog');
}
// Load and render ticket tags
async function loadTicketTags() {
try {
const response = await fetch('/api/v1/tags/entity/ticket/{{ ticket.id }}');
if (!response.ok) return;
const tags = await response.json();
const container = document.getElementById('ticketTags');
if (tags.length === 0) {
container.innerHTML = '<small class="text-muted"><i class="bi bi-tags"></i> Ingen tags endnu</small>';
return;
}
container.innerHTML = tags.map(tag => `
<span class="tag-badge" style="background-color: ${tag.color};">
${tag.icon ? `<i class="bi ${tag.icon}"></i>` : ''}
${tag.name}
<button type="button" class="btn-close btn-close-white btn-sm"
onclick="removeTag(${tag.id}, '${tag.name}')"
aria-label="Fjern"></button>
</span>
`).join('');
} catch (error) {
console.error('Error loading tags:', error);
}
}
async function removeTag(tagId, tagName) {
if (!confirm(`Fjern tag "${tagName}"?`)) return;
try {
const response = await fetch(`/api/v1/tags/entity?entity_type=ticket&entity_id={{ ticket.id }}&tag_id=${tagId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to remove tag');
await loadTicketTags();
} catch (error) {
console.error('Error removing tag:', error);
alert('Fejl ved fjernelse af tag');
}
}
function reloadTags() {
loadTicketTags();
}
// Load tags on page load
document.addEventListener('DOMContentLoaded', loadTicketTags);
// Override global tag picker to auto-reload after adding
if (window.tagPicker) {
const originalShow = window.tagPicker.show.bind(window.tagPicker);
window.showTagPicker = function(entityType, entityId, onSelect) {
window.tagPicker.show(entityType, entityId, () => {
loadTicketTags();
if (onSelect) onSelect();
});
};
}
</script>
{% endblock %}

View File

@ -10,7 +10,7 @@ from fastapi.templating import Jinja2Templates
from typing import Optional
from datetime import date
from app.core.database import execute_query, execute_update
from app.core.database import execute_query, execute_update, execute_query_single
logger = logging.getLogger(__name__)

View File

@ -187,7 +187,7 @@ class EconomicExportService:
WHERE order_id = %s
ORDER BY line_number
"""
lines = execute_query_single(lines_query, (request.order_id,))
lines = execute_query(lines_query, (request.order_id,))
if not lines:
raise HTTPException(
@ -244,7 +244,7 @@ class EconomicExportService:
LEFT JOIN customers c ON tc.hub_customer_id = c.id
WHERE tc.id = %s
"""
customer_data = execute_query(customer_number_query, (order['customer_id'],))
customer_data = execute_query_single(customer_number_query, (order['customer_id'],))
if not customer_data or not customer_data.get('economic_customer_number'):
raise HTTPException(

View File

@ -314,7 +314,7 @@ class OrderService:
LEFT JOIN tmodule_customers c ON o.customer_id = c.id
WHERE o.id = %s
"""
order = execute_query(order_query, (order_id,))
order = execute_query_single(order_query, (order_id,))
if not order:
raise HTTPException(status_code=404, detail="Order not found")
@ -334,7 +334,7 @@ class OrderService:
ol.product_number, ol.account_number, ol.created_at
ORDER BY ol.line_number
"""
lines = execute_query_single(lines_query, (order_id,))
lines = execute_query(lines_query, (order_id,))
return TModuleOrderWithLines(
**order,

View File

@ -99,8 +99,8 @@
</div>
</div>
<!-- Safety Banner -->
<div class="row mb-4">
<!-- Safety Banner (dynamisk) -->
<div class="row mb-4" id="safety-banner" style="display: none;">
<div class="col-12">
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="bi bi-shield-exclamation me-2"></i>
@ -190,8 +190,22 @@
let orderModal = null;
// Initialize modal
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
orderModal = new bootstrap.Modal(document.getElementById('orderModal'));
// Check if DRY-RUN mode is active
try {
const configResponse = await fetch('/api/v1/timetracking/config');
const config = await configResponse.json();
// Show banner only if DRY-RUN or READ-ONLY is enabled
if (config.economic_dry_run || config.economic_read_only) {
document.getElementById('safety-banner').style.display = 'block';
}
} catch (error) {
console.error('Error checking config:', error);
}
loadOrders();
});
@ -311,7 +325,7 @@
<hr class="my-3">
<h6 class="mb-3">Ordrelinjer:</h6>
${order.lines.map(line => {
${(order.lines || []).map(line => {
// Parse data
const caseMatch = line.description.match(/CC(\d+)/);
const caseTitle = line.description.split(' - ').slice(1).join(' - ') || line.description;

View File

@ -97,6 +97,72 @@ Dette opretter:
## Database Isolering
### Customer Linking Pattern
**VIGTIG ARKITEKTUR BESLUTNING:**
Core `customers` tabel er **single source of truth** for økonomi, fakturering og CVR.
#### Hvornår skal moduler have egen kunde-tabel?
**✅ Opret modul-specifik kunde-tabel hvis:**
- Modulet syncer fra eksternt system (vTiger, CRM, etc.)
- Mange module-specifikke felter nødvendige (external_id, sync_status, etc.)
- Custom lifecycle/workflow
- **Eksempel:** `tmodule_customers` (sync fra vTiger)
**🚫 Brug direkte `customers.id` foreign key hvis:**
- Simple relationer uden external sync
- Få/ingen custom felter
- Standard workflow
- **Eksempel:** `ticket_system` → direkte FK til `customers.id`
#### Hvis modul-kunde-tabel oprettes:
**SKAL altid have:**
```sql
hub_customer_id INTEGER REFERENCES customers(id) -- Link til core
```
**SKAL have auto-link trigger:**
```sql
CREATE OR REPLACE FUNCTION auto_link_{module}_customer()
RETURNS TRIGGER AS $$
DECLARE
matched_hub_id INTEGER;
BEGIN
IF NEW.hub_customer_id IS NOT NULL THEN
RETURN NEW;
END IF;
SELECT id INTO matched_hub_id
FROM customers
WHERE LOWER(TRIM(name)) = LOWER(TRIM(NEW.name))
LIMIT 1;
IF matched_hub_id IS NOT NULL THEN
NEW.hub_customer_id := matched_hub_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_auto_link_{module}_customer
BEFORE INSERT OR UPDATE OF name
ON {module}_customers
FOR EACH ROW
EXECUTE FUNCTION auto_link_{module}_customer();
```
**Hvorfor dette pattern?**
- ✅ E-conomic export virker automatisk (via hub_customer_id → customers.economic_customer_number)
- ✅ Billing integration automatisk
- ✅ Ingen manuel linking nødvendig
- ✅ Nye sync'ede kunder linkes automatisk
Se `migrations/028_auto_link_tmodule_customers.sql` for live eksempel.
### Table Prefix Pattern
Alle tabeller SKAL bruge prefix fra `module.json`:

View File

@ -30,6 +30,8 @@ from app.vendors.backend import views as vendors_views
from app.timetracking.backend import router as timetracking_api
from app.timetracking.frontend import views as timetracking_views
from app.contacts.backend import views as contacts_views
from app.tags.backend import router as tags_api
from app.tags.backend import views as tags_views
# Configure logging
logging.basicConfig(
@ -94,6 +96,7 @@ app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
app.include_router(timetracking_api, prefix="/api/v1", tags=["Time Tracking"])
app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])
# Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"])
@ -104,6 +107,7 @@ app.include_router(timetracking_views.router, tags=["Frontend"])
app.include_router(billing_views.router, tags=["Frontend"])
app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend"])
app.include_router(contacts_views.router, tags=["Frontend"])
app.include_router(tags_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -0,0 +1,79 @@
-- Tag System Migration
-- Central tagging system for all entities in BMC Hub
-- Main tags table
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('workflow', 'status', 'category', 'priority', 'billing')),
description TEXT,
color VARCHAR(7) NOT NULL, -- Hex color code
icon VARCHAR(50), -- Optional icon name
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, type)
);
-- Polymorphic entity tagging
CREATE TABLE IF NOT EXISTS entity_tags (
id SERIAL PRIMARY KEY,
entity_type VARCHAR(50) NOT NULL, -- 'ticket', 'customer', 'time_entry', 'contact', etc.
entity_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
tagged_by INTEGER, -- User who added the tag
tagged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(entity_type, entity_id, tag_id)
);
-- Workflow trigger configurations
CREATE TABLE IF NOT EXISTS tag_workflows (
id SERIAL PRIMARY KEY,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
trigger_event VARCHAR(50) NOT NULL, -- 'on_add', 'on_remove'
action_type VARCHAR(50) NOT NULL, -- 'send_email', 'update_status', 'assign_user', 'create_task'
action_config JSONB, -- Flexible config for different actions
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for performance
CREATE INDEX idx_entity_tags_entity ON entity_tags(entity_type, entity_id);
CREATE INDEX idx_entity_tags_tag ON entity_tags(tag_id);
CREATE INDEX idx_tags_type ON tags(type);
CREATE INDEX idx_tags_active ON tags(is_active) WHERE is_active = true;
-- Insert default tags
INSERT INTO tags (name, type, description, color) VALUES
-- Workflow tags (orange)
('Firstline', 'workflow', 'Skal håndteres af firstline support', '#ff6b35'),
('Fakturerbar', 'workflow', 'Tid skal faktureres til kunde', '#ff6b35'),
('Escalate', 'workflow', 'Skal eskaleres til senior tekniker', '#ff6b35'),
-- Status tags (yellow)
('Afventer kunde', 'status', 'Venter på svar fra kunde', '#ffd700'),
('Løst', 'status', 'Sagen er løst', '#28a745'),
('Parkeret', 'status', 'Sat på hold', '#6c757d'),
('I gang', 'status', 'Bliver arbejdet på', '#17a2b8'),
-- Category tags (blue)
('Netværk', 'category', 'Netværksrelateret problem', '#0f4c75'),
('Hardware', 'category', 'Hardware problem', '#0f4c75'),
('Software', 'category', 'Software problem', '#0f4c75'),
('Printerfejl', 'category', 'Printer relateret', '#0f4c75'),
('Mail', 'category', 'Email relateret', '#0f4c75'),
-- Priority tags (red to green)
('Kritisk', 'priority', 'Skal løses med det samme', '#dc3545'),
('Høj', 'priority', 'Høj prioritet', '#fd7e14'),
('Normal', 'priority', 'Normal prioritet', '#ffc107'),
('Lav', 'priority', 'Kan vente', '#28a745'),
-- Billing tags (green)
('SLA-inkluderet', 'billing', 'Dækket af SLA', '#2d6a4f'),
('Gratis', 'billing', 'Ingen fakturering', '#2d6a4f'),
('Ekstra arbejde', 'billing', 'Skal faktureres ekstra', '#2d6a4f');
COMMENT ON TABLE tags IS 'Central tag definitions used across all entities';
COMMENT ON TABLE entity_tags IS 'Polymorphic association between tags and any entity';
COMMENT ON TABLE tag_workflows IS 'Automation triggers based on tags';

View File

@ -0,0 +1,84 @@
-- Link tmodule_customers til Hub customers baseret på navn match
-- Dette script kører automatisk ved opstart og kan også køres manuelt
-- Drop eksisterende funktion hvis den findes
DROP FUNCTION IF EXISTS link_tmodule_customers_to_hub();
-- Funktion til at linke kunder baseret på navn
CREATE OR REPLACE FUNCTION link_tmodule_customers_to_hub()
RETURNS TABLE (
tmodule_id INTEGER,
tmodule_name TEXT,
hub_id INTEGER,
hub_name TEXT,
action TEXT
) AS $$
BEGIN
RETURN QUERY
WITH matches AS (
-- Find eksakte navn matches
SELECT
tc.id as tmodule_id,
tc.name::TEXT as tmodule_name,
c.id as hub_id,
c.name::TEXT as hub_name,
'exact_match'::TEXT as action
FROM tmodule_customers tc
JOIN customers c ON LOWER(TRIM(tc.name)) = LOWER(TRIM(c.name))
WHERE tc.hub_customer_id IS NULL
),
updates AS (
-- Opdater tmodule_customers med hub_customer_id
UPDATE tmodule_customers tc
SET hub_customer_id = m.hub_id,
updated_at = CURRENT_TIMESTAMP
FROM matches m
WHERE tc.id = m.tmodule_id
RETURNING tc.id, tc.name::TEXT, m.hub_id, m.hub_name, m.action
)
SELECT * FROM updates;
END;
$$ LANGUAGE plpgsql;
-- Kør linking
SELECT * FROM link_tmodule_customers_to_hub();
-- Trigger til automatisk linking ved insert/update af tmodule_customers
CREATE OR REPLACE FUNCTION auto_link_tmodule_customer()
RETURNS TRIGGER AS $$
DECLARE
matched_hub_id INTEGER;
BEGIN
-- Hvis hub_customer_id allerede er sat, skip
IF NEW.hub_customer_id IS NOT NULL THEN
RETURN NEW;
END IF;
-- Find matching hub customer baseret på navn
SELECT id INTO matched_hub_id
FROM customers
WHERE LOWER(TRIM(name)) = LOWER(TRIM(NEW.name))
LIMIT 1;
-- Hvis match fundet, sæt hub_customer_id
IF matched_hub_id IS NOT NULL THEN
NEW.hub_customer_id := matched_hub_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop trigger hvis den eksisterer
DROP TRIGGER IF EXISTS trigger_auto_link_tmodule_customer ON tmodule_customers;
-- Opret trigger der kører før INSERT eller UPDATE
CREATE TRIGGER trigger_auto_link_tmodule_customer
BEFORE INSERT OR UPDATE OF name
ON tmodule_customers
FOR EACH ROW
EXECUTE FUNCTION auto_link_tmodule_customer();
COMMENT ON FUNCTION link_tmodule_customers_to_hub() IS 'Linker eksisterende tmodule_customers til Hub customers baseret på navn match';
COMMENT ON FUNCTION auto_link_tmodule_customer() IS 'Automatisk linker nye/opdaterede tmodule_customers til Hub customers';
COMMENT ON TRIGGER trigger_auto_link_tmodule_customer ON tmodule_customers IS 'Auto-linker tmodule customers til Hub customers ved navn match';

379
static/js/tag-picker.js Normal file
View File

@ -0,0 +1,379 @@
/**
* Global Tag Picker Component
* Keyboard shortcut: Option+Shift+T (Mac) or Alt+Shift+T (Windows/Linux)
*/
class TagPicker {
constructor() {
this.modal = null;
this.searchInput = null;
this.resultsContainer = null;
this.allTags = [];
this.filteredTags = [];
this.selectedIndex = 0;
this.onSelectCallback = null;
this.contextType = null; // 'ticket', 'customer', 'time_entry', etc.
this.contextId = null;
this.init();
}
init() {
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.setup());
} else {
this.setup();
}
}
setup() {
console.log('🏷️ Tag Picker: Initializing...');
// Check if Bootstrap is loaded
if (typeof bootstrap === 'undefined') {
console.error('🏷️ Tag Picker: Bootstrap not loaded yet, retrying in 100ms');
setTimeout(() => this.setup(), 100);
return;
}
// Create modal HTML
this.createModal();
// Setup global keyboard listener
document.addEventListener('keydown', (e) => {
// Debug: Log all Alt+Shift combinations
if (e.altKey && e.shiftKey) {
console.log('🏷️ Alt+Shift pressed with key:', e.key, 'code:', e.code, 'keyCode:', e.keyCode);
}
// Option+Shift+T (Mac) or Alt+Shift+T (Windows/Linux)
if (e.altKey && e.shiftKey && (e.key === 'T' || e.key === 't' || e.code === 'KeyT')) {
e.preventDefault();
console.log('🏷️ Tag picker shortcut triggered!');
this.show();
}
// ESC to close
if (e.key === 'Escape' && this.modal && this.modal.classList.contains('show')) {
this.hide();
}
});
console.log('🏷️ Tag Picker: Keyboard listener registered (Option+Shift+T)');
// Load tags on init
this.loadTags();
}
createModal() {
const modalHTML = `
<div class="modal fade" id="tagPickerModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content" style="border-radius: 16px; border: none; box-shadow: 0 10px 40px rgba(0,0,0,0.15);">
<div class="modal-header border-0 pb-2" style="background: linear-gradient(135deg, #0f4c75 0%, #3d8bfd 100%); color: white; border-radius: 16px 16px 0 0; padding: 1.5rem;">
<div class="w-100">
<div class="d-flex align-items-center mb-3">
<i class="bi bi-tags me-2" style="font-size: 1.8rem;"></i>
<h5 class="modal-title mb-0" style="font-weight: 600; font-size: 1.3rem;">Tilføj Tag</h5>
<button type="button" class="btn-close btn-close-white ms-auto" data-bs-dismiss="modal"></button>
</div>
<input
type="text"
class="form-control form-control-lg"
id="tagPickerSearch"
placeholder="🔍 Søg efter tag..."
autocomplete="off"
style="border: none; border-radius: 12px; padding: 0.75rem 1rem; font-size: 1rem; box-shadow: 0 4px 12px rgba(0,0,0,0.1);"
>
<div class="mt-2" style="font-size: 0.85rem; opacity: 0.9;">
<kbd style="background: rgba(255,255,255,0.2); color: white; padding: 0.25rem 0.5rem; border-radius: 4px;"></kbd>
<kbd style="background: rgba(255,255,255,0.2); color: white; padding: 0.25rem 0.5rem; border-radius: 4px;"></kbd>
<kbd style="background: rgba(255,255,255,0.2); color: white; padding: 0.25rem 0.5rem; border-radius: 4px;">T</kbd>
for at åbne
<kbd style="background: rgba(255,255,255,0.2); color: white; padding: 0.25rem 0.5rem; border-radius: 4px;"></kbd>
<kbd style="background: rgba(255,255,255,0.2); color: white; padding: 0.25rem 0.5rem; border-radius: 4px;"></kbd>
for at navigere
<kbd style="background: rgba(255,255,255,0.2); color: white; padding: 0.25rem 0.5rem; border-radius: 4px;">Enter</kbd>
for at vælge
</div>
</div>
</div>
<div class="modal-body pt-3" style="max-height: 500px; overflow-y: auto; padding: 1.5rem;">
<div id="tagPickerResults"></div>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
this.modal = document.getElementById('tagPickerModal');
this.searchInput = document.getElementById('tagPickerSearch');
this.resultsContainer = document.getElementById('tagPickerResults');
// Setup search input
this.searchInput.addEventListener('input', () => {
this.filterTags(this.searchInput.value);
});
// Keyboard navigation
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredTags.length - 1);
this.renderResults();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.renderResults();
} else if (e.key === 'Enter') {
e.preventDefault();
this.selectTag(this.filteredTags[this.selectedIndex]);
}
});
// Reset on modal close
this.modal.addEventListener('hidden.bs.modal', () => {
this.searchInput.value = '';
this.selectedIndex = 0;
this.contextType = null;
this.contextId = null;
this.onSelectCallback = null;
});
}
async loadTags() {
try {
console.log('🏷️ Loading tags from API...');
const response = await fetch('/api/v1/tags?is_active=true');
if (!response.ok) throw new Error('Failed to load tags');
this.allTags = await response.json();
console.log('🏷️ Loaded tags:', this.allTags.length);
this.filteredTags = [...this.allTags];
this.renderResults();
} catch (error) {
console.error('🏷️ Error loading tags:', error);
}
}
filterTags(query) {
if (!query.trim()) {
this.filteredTags = [...this.allTags];
} else {
const lowerQuery = query.toLowerCase();
this.filteredTags = this.allTags.filter(tag =>
tag.name.toLowerCase().includes(lowerQuery) ||
(tag.description && tag.description.toLowerCase().includes(lowerQuery))
);
}
this.selectedIndex = 0;
this.renderResults();
}
renderResults() {
if (this.filteredTags.length === 0) {
this.resultsContainer.innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi bi-search" style="font-size: 2rem;"></i>
<p class="mt-2">Ingen tags fundet</p>
</div>
`;
return;
}
// Group by type
const grouped = this.filteredTags.reduce((acc, tag) => {
if (!acc[tag.type]) acc[tag.type] = [];
acc[tag.type].push(tag);
return acc;
}, {});
const typeLabels = {
'workflow': '⚡ Workflow - Automatisering',
'status': '📊 Status - Tilstand',
'category': '📁 Kategori - Emne',
'priority': '🔥 Prioritet - Hastighed',
'billing': '💰 Fakturering - Økonomi'
};
const typeOrder = ['workflow', 'status', 'category', 'priority', 'billing'];
let html = '';
typeOrder.forEach(type => {
if (grouped[type]) {
html += `<div class="mb-4">
<div class="text-uppercase fw-bold mb-2" style="font-size: 0.75rem; letter-spacing: 1px; color: #6c757d;">${typeLabels[type]}</div>
<div class="list-group" style="border-radius: 12px; overflow: hidden; border: 1px solid #e9ecef;">`;
grouped[type].forEach((tag, index) => {
const globalIndex = this.filteredTags.indexOf(tag);
const isSelected = globalIndex === this.selectedIndex;
html += `
<a href="#"
class="list-group-item list-group-item-action d-flex align-items-center ${isSelected ? 'active' : ''}"
data-tag-id="${tag.id}"
style="border: none; border-bottom: 1px solid #f0f0f0; padding: 1rem; transition: all 0.2s; ${isSelected ? 'background: linear-gradient(135deg, #0f4c75 0%, #3d8bfd 100%); color: white;' : ''}"
onclick="window.tagPicker.selectTag(${JSON.stringify(tag).replace(/"/g, '&quot;')}); return false;">
<span class="badge me-3" style="background-color: ${tag.color}; min-width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1.1rem;">
${tag.icon ? `<i class="bi ${tag.icon}" style="color: white;"></i>` : ''}
</span>
<div class="flex-grow-1">
<div class="fw-bold" style="font-size: 0.95rem;">${tag.name}</div>
${tag.description ? `<small class="${isSelected ? 'text-white-50' : 'text-muted'}" style="font-size: 0.8rem;">${tag.description}</small>` : ''}
</div>
${!isSelected ? `<i class="bi bi-plus-circle text-primary" style="font-size: 1.2rem;"></i>` : ''}
</a>
`;
});
html += `</div></div>`;
}
});
this.resultsContainer.innerHTML = html;
// Scroll selected into view
const selected = this.resultsContainer.querySelector('.active');
if (selected) {
selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
async selectTag(tag) {
if (!tag) return;
// If context provided, add tag to entity
if (this.contextType && this.contextId) {
try {
const response = await fetch('/api/v1/tags/entity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_type: this.contextType,
entity_id: this.contextId,
tag_id: tag.id
})
});
if (!response.ok) {
const error = await response.json();
if (error.detail.includes('already exists')) {
alert('Dette tag er allerede tilføjet');
return;
}
throw new Error(error.detail);
}
// Show success feedback
this.showSuccess(tag.name);
} catch (error) {
console.error('Error adding tag:', error);
alert('Fejl ved tilføjelse af tag: ' + error.message);
return;
}
}
// Call callback if provided
if (this.onSelectCallback) {
this.onSelectCallback(tag);
}
this.hide();
}
show(contextType = null, contextId = null, onSelect = null) {
this.contextType = contextType;
this.contextId = contextId;
this.onSelectCallback = onSelect;
const modalInstance = new bootstrap.Modal(this.modal);
modalInstance.show();
// Focus search input after modal is shown
this.modal.addEventListener('shown.bs.modal', () => {
this.searchInput.focus();
}, { once: true });
}
hide() {
const modalInstance = bootstrap.Modal.getInstance(this.modal);
if (modalInstance) {
modalInstance.hide();
}
}
showSuccess(tagName) {
// Create toast notification
const toastHTML = `
<div class="toast-container position-fixed top-0 end-0 p-3">
<div class="toast show" role="alert">
<div class="toast-header bg-success text-white">
<i class="bi bi-check-circle me-2"></i>
<strong class="me-auto">Tag tilføjet</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">
Tag "${tagName}" er tilføjet
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', toastHTML);
const toastEl = document.querySelector('.toast-container .toast');
setTimeout(() => toastEl.remove(), 3000);
}
}
// Initialize global tag picker
window.tagPicker = new TagPicker();
// Helper function to show tag picker with context
window.showTagPicker = function(entityType, entityId, onSelect = null) {
window.tagPicker.show(entityType, entityId, onSelect);
};
// Helper function to render tags for an entity
window.renderEntityTags = async function(entityType, entityId, containerId) {
try {
const response = await fetch(`/api/v1/tags/entity/${entityType}/${entityId}`);
if (!response.ok) return;
const tags = await response.json();
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = tags.map(tag => `
<span class="badge me-1 mb-1" style="background-color: ${tag.color};">
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${tag.name}
<button type="button" class="btn-close btn-close-white btn-sm ms-1"
onclick="removeEntityTag('${entityType}', ${entityId}, ${tag.id}, '${containerId}')"
style="font-size: 0.6rem; vertical-align: middle;"></button>
</span>
`).join('');
} catch (error) {
console.error('Error rendering tags:', error);
}
};
// Helper function to remove tag from entity
window.removeEntityTag = async function(entityType, entityId, tagId, containerId) {
if (!confirm('Fjern dette tag?')) return;
try {
const response = await fetch(`/api/v1/tags/entity?entity_type=${entityType}&entity_id=${entityId}&tag_id=${tagId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to remove tag');
// Refresh tags display
await window.renderEntityTags(entityType, entityId, containerId);
} catch (error) {
console.error('Error removing tag:', error);
alert('Fejl ved fjernelse af tag');
}
};