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:
parent
fadf7258de
commit
0502a7b080
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 []
|
||||
|
||||
86
app/tags/backend/models.py
Normal file
86
app/tags/backend/models.py
Normal 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
226
app/tags/backend/router.py
Normal 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
14
app/tags/backend/views.py
Normal 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})
|
||||
378
app/tags/frontend/tags_admin.html
Normal file
378
app/tags/frontend/tags_admin.html
Normal 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>
|
||||
@ -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 %}
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`:
|
||||
|
||||
4
main.py
4
main.py
@ -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")
|
||||
|
||||
79
migrations/027_tag_system.sql
Normal file
79
migrations/027_tag_system.sql
Normal 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';
|
||||
84
migrations/028_auto_link_tmodule_customers.sql
Normal file
84
migrations/028_auto_link_tmodule_customers.sql
Normal 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
379
static/js/tag-picker.js
Normal 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, '"')}); 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');
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user