feat: Implement DEV Portal with Kanban board, idea management, and workflow editor

- Added backend routes for DEV Portal dashboard and workflow editor
- Created frontend templates for portal and editor using Jinja2
- Integrated draw.io for workflow diagram editing and saving
- Developed API endpoints for features, ideas, and workflows management
- Established database schema for features, ideas, and workflows
- Documented DEV Portal functionality, API endpoints, and database structure
This commit is contained in:
Christian 2025-12-06 21:27:47 +01:00
parent 3dfc5086c0
commit 974876ac67
10 changed files with 1867 additions and 44 deletions

View File

@ -71,11 +71,20 @@ def execute_query(query: str, params: tuple = None, fetchone: bool = False):
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor: with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params or ()) cursor.execute(query, params or ())
# Check if this is a write operation (INSERT, UPDATE, DELETE)
query_upper = query.strip().upper()
is_write = any(query_upper.startswith(cmd) for cmd in ['INSERT', 'UPDATE', 'DELETE'])
if fetchone: if fetchone:
row = cursor.fetchone() row = cursor.fetchone()
if is_write:
conn.commit()
return dict(row) if row else None return dict(row) if row else None
else: else:
rows = cursor.fetchall() rows = cursor.fetchall()
if is_write:
conn.commit()
return [dict(row) for row in rows] return [dict(row) for row in rows]
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()

View File

@ -122,3 +122,102 @@ async def global_search(q: str):
except Exception as e: except Exception as e:
logger.error(f"❌ Error performing global search: {e}", exc_info=True) logger.error(f"❌ Error performing global search: {e}", exc_info=True)
return {"customers": [], "contacts": [], "vendors": []} return {"customers": [], "contacts": [], "vendors": []}
@router.get("/live-stats", response_model=Dict[str, Any])
async def get_live_stats():
"""
Get live statistics for the three live boxes: Sales, Support, Økonomi
"""
try:
# Sales Stats (placeholder - replace with real data when tables exist)
sales_stats = {
"active_orders": 0,
"monthly_sales": 0,
"open_quotes": 0
}
# Support Stats (placeholder)
support_stats = {
"open_tickets": 0,
"avg_response_time": 0,
"today_tickets": 0
}
# Finance Stats (placeholder)
finance_stats = {
"unpaid_invoices_count": 0,
"unpaid_invoices_amount": 0,
"overdue_invoices": 0,
"today_payments": 0
}
# Try to get real customer count as a demo
try:
customer_count = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL", fetchone=True)
sales_stats["active_orders"] = customer_count.get('count', 0) if customer_count else 0
except:
pass
return {
"sales": sales_stats,
"support": support_stats,
"finance": finance_stats
}
except Exception as e:
logger.error(f"❌ Error fetching live stats: {e}", exc_info=True)
return {
"sales": {"active_orders": 0, "monthly_sales": 0, "open_quotes": 0},
"support": {"open_tickets": 0, "avg_response_time": 0, "today_tickets": 0},
"finance": {"unpaid_invoices_count": 0, "unpaid_invoices_amount": 0, "overdue_invoices": 0, "today_payments": 0}
}
@router.get("/recent-activity", response_model=List[Dict[str, Any]])
async def get_recent_activity():
"""
Get recent activity across the system for the sidebar
"""
try:
activities = []
# Recent customers
recent_customers = execute_query("""
SELECT id, name, created_at, 'customer' as activity_type, 'bi-building' as icon, 'primary' as color
FROM customers
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 3
""")
# Recent contacts
recent_contacts = execute_query("""
SELECT id, first_name || ' ' || last_name as name, created_at, 'contact' as activity_type, 'bi-person' as icon, 'success' as color
FROM contacts
ORDER BY created_at DESC
LIMIT 3
""")
# Recent vendors
recent_vendors = execute_query("""
SELECT id, name, created_at, 'vendor' as activity_type, 'bi-shop' as icon, 'info' as color
FROM vendors
ORDER BY created_at DESC
LIMIT 2
""")
# Combine all activities
if recent_customers:
activities.extend(recent_customers)
if recent_contacts:
activities.extend(recent_contacts)
if recent_vendors:
activities.extend(recent_vendors)
# Sort by created_at and limit
activities.sort(key=lambda x: x.get('created_at', ''), reverse=True)
return activities[:10]
except Exception as e:
logger.error(f"❌ Error fetching recent activity: {e}", exc_info=True)
return []

View File

@ -0,0 +1,292 @@
from fastapi import APIRouter, HTTPException
from app.core.database import execute_query
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from datetime import date, datetime
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
# Pydantic Models
class Feature(BaseModel):
id: Optional[int] = None
title: str
description: Optional[str] = None
version: Optional[str] = None
status: str = 'planlagt'
priority: int = 50
expected_date: Optional[date] = None
completed_date: Optional[date] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class FeatureCreate(BaseModel):
title: str
description: Optional[str] = None
version: Optional[str] = None
status: str = 'planlagt'
priority: int = 50
expected_date: Optional[date] = None
class Idea(BaseModel):
id: Optional[int] = None
title: str
description: Optional[str] = None
category: Optional[str] = None
votes: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class IdeaCreate(BaseModel):
title: str
description: Optional[str] = None
category: Optional[str] = None
class Workflow(BaseModel):
id: Optional[int] = None
title: str
description: Optional[str] = None
category: Optional[str] = None
diagram_xml: str
thumbnail_url: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class WorkflowCreate(BaseModel):
title: str
description: Optional[str] = None
category: Optional[str] = None
diagram_xml: str
# Features/Roadmap Endpoints
@router.get("/features", response_model=List[Feature])
async def get_features(version: Optional[str] = None, status: Optional[str] = None):
"""Get all roadmap features with optional filters"""
query = "SELECT * FROM dev_features WHERE 1=1"
params = []
if version:
query += " AND version = %s"
params.append(version)
if status:
query += " AND status = %s"
params.append(status)
query += " ORDER BY priority DESC, expected_date ASC"
result = execute_query(query, tuple(params) if params else None)
return result or []
@router.get("/features/{feature_id}", response_model=Feature)
async def get_feature(feature_id: int):
"""Get a specific feature"""
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,), fetchone=True)
if not result:
raise HTTPException(status_code=404, detail="Feature not found")
return result
@router.post("/features", response_model=Feature)
async def create_feature(feature: FeatureCreate):
"""Create a new roadmap feature"""
query = """
INSERT INTO dev_features (title, description, version, status, priority, expected_date)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query(query, (
feature.title, feature.description, feature.version,
feature.status, feature.priority, feature.expected_date
), fetchone=True)
logger.info(f"✅ Created feature: {feature.title}")
return result
@router.put("/features/{feature_id}", response_model=Feature)
async def update_feature(feature_id: int, feature: FeatureCreate):
"""Update a roadmap feature"""
query = """
UPDATE dev_features
SET title = %s, description = %s, version = %s, status = %s,
priority = %s, expected_date = %s
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (
feature.title, feature.description, feature.version,
feature.status, feature.priority, feature.expected_date, feature_id
), fetchone=True)
if not result:
raise HTTPException(status_code=404, detail="Feature not found")
logger.info(f"✅ Updated feature: {feature_id}")
return result
@router.delete("/features/{feature_id}")
async def delete_feature(feature_id: int):
"""Delete a roadmap feature"""
result = execute_query("DELETE FROM dev_features WHERE id = %s RETURNING id", (feature_id,), fetchone=True)
if not result:
raise HTTPException(status_code=404, detail="Feature not found")
logger.info(f"✅ Deleted feature: {feature_id}")
return {"message": "Feature deleted successfully"}
# Ideas Endpoints
@router.get("/ideas", response_model=List[Idea])
async def get_ideas(category: Optional[str] = None):
"""Get all ideas with optional category filter"""
query = "SELECT * FROM dev_ideas WHERE 1=1"
params = []
if category:
query += " AND category = %s"
params.append(category)
query += " ORDER BY votes DESC, created_at DESC"
result = execute_query(query, tuple(params) if params else None)
return result or []
@router.post("/ideas", response_model=Idea)
async def create_idea(idea: IdeaCreate):
"""Create a new idea"""
query = """
INSERT INTO dev_ideas (title, description, category)
VALUES (%s, %s, %s)
RETURNING *
"""
result = execute_query(query, (idea.title, idea.description, idea.category), fetchone=True)
logger.info(f"✅ Created idea: {idea.title}")
return result
@router.post("/ideas/{idea_id}/vote")
async def vote_idea(idea_id: int):
"""Increment vote count for an idea"""
query = """
UPDATE dev_ideas
SET votes = votes + 1
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (idea_id,), fetchone=True)
if not result:
raise HTTPException(status_code=404, detail="Idea not found")
return result
@router.delete("/ideas/{idea_id}")
async def delete_idea(idea_id: int):
"""Delete an idea"""
result = execute_query("DELETE FROM dev_ideas WHERE id = %s RETURNING id", (idea_id,), fetchone=True)
if not result:
raise HTTPException(status_code=404, detail="Idea not found")
logger.info(f"✅ Deleted idea: {idea_id}")
return {"message": "Idea deleted successfully"}
# Workflows Endpoints
@router.get("/workflows", response_model=List[Workflow])
async def get_workflows(category: Optional[str] = None):
"""Get all workflows with optional category filter"""
query = "SELECT * FROM dev_workflows WHERE 1=1"
params = []
if category:
query += " AND category = %s"
params.append(category)
query += " ORDER BY created_at DESC"
result = execute_query(query, tuple(params) if params else None)
return result or []
@router.get("/workflows/{workflow_id}", response_model=Workflow)
async def get_workflow(workflow_id: int):
"""Get a specific workflow"""
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,), fetchone=True)
if not result:
raise HTTPException(status_code=404, detail="Workflow not found")
return result
@router.post("/workflows", response_model=Workflow)
async def create_workflow(workflow: WorkflowCreate):
"""Create a new workflow diagram"""
query = """
INSERT INTO dev_workflows (title, description, category, diagram_xml)
VALUES (%s, %s, %s, %s)
RETURNING *
"""
result = execute_query(query, (
workflow.title, workflow.description, workflow.category, workflow.diagram_xml
), fetchone=True)
logger.info(f"✅ Created workflow: {workflow.title}")
return result
@router.put("/workflows/{workflow_id}", response_model=Workflow)
async def update_workflow(workflow_id: int, workflow: WorkflowCreate):
"""Update a workflow diagram"""
query = """
UPDATE dev_workflows
SET title = %s, description = %s, category = %s, diagram_xml = %s
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (
workflow.title, workflow.description, workflow.category,
workflow.diagram_xml, workflow_id
), fetchone=True)
if not result:
raise HTTPException(status_code=404, detail="Workflow not found")
logger.info(f"✅ Updated workflow: {workflow_id}")
return result
@router.delete("/workflows/{workflow_id}")
async def delete_workflow(workflow_id: int):
"""Delete a workflow"""
result = execute_query("DELETE FROM dev_workflows WHERE id = %s RETURNING id", (workflow_id,), fetchone=True)
if not result:
raise HTTPException(status_code=404, detail="Workflow not found")
logger.info(f"✅ Deleted workflow: {workflow_id}")
return {"message": "Workflow deleted successfully"}
# Stats endpoint
@router.get("/stats")
async def get_devportal_stats():
"""Get DEV Portal statistics"""
features_count = execute_query("SELECT COUNT(*) as count FROM dev_features", fetchone=True)
ideas_count = execute_query("SELECT COUNT(*) as count FROM dev_ideas", fetchone=True)
workflows_count = execute_query("SELECT COUNT(*) as count FROM dev_workflows", fetchone=True)
features_by_status = execute_query("""
SELECT status, COUNT(*) as count
FROM dev_features
GROUP BY status
""")
return {
"features_count": features_count['count'] if features_count else 0,
"ideas_count": ideas_count['count'] if ideas_count else 0,
"workflows_count": workflows_count['count'] if workflows_count else 0,
"features_by_status": features_by_status or []
}

View File

@ -0,0 +1,19 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/devportal", response_class=HTMLResponse)
async def devportal_dashboard(request: Request):
"""Render the DEV Portal dashboard"""
return templates.TemplateResponse("devportal/frontend/portal.html", {"request": request})
@router.get("/devportal/editor", response_class=HTMLResponse)
async def workflow_editor(request: Request, id: int = None):
"""Render the workflow editor with draw.io integration"""
return templates.TemplateResponse("devportal/frontend/editor.html", {
"request": request,
"workflow_id": id
})

View File

@ -0,0 +1,214 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Workflow Editor - DEV Portal{% endblock %}
{% block extra_css %}
<style>
#diagramContainer {
width: 100%;
height: calc(100vh - 200px);
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.editor-toolbar {
background: var(--bg-card);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-4">
<a href="/devportal" class="btn btn-light">
<i class="bi bi-arrow-left me-2"></i>Tilbage til DEV Portal
</a>
</div>
<div class="editor-toolbar">
<div class="row align-items-center">
<div class="col-md-4">
<input type="text" class="form-control" id="workflowTitle" placeholder="Workflow titel...">
</div>
<div class="col-md-3">
<input type="text" class="form-control" id="workflowDescription" placeholder="Beskrivelse...">
</div>
<div class="col-md-2">
<select class="form-select" id="workflowCategory">
<option value="flowchart">Flowchart</option>
<option value="process">Proces</option>
<option value="system_diagram">System Diagram</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-md-3 text-end">
<button class="btn btn-success" onclick="saveWorkflow()">
<i class="bi bi-save me-2"></i>Gem Workflow
</button>
</div>
</div>
</div>
<div id="diagramContainer">
<iframe id="diagramFrame" frameborder="0" style="width: 100%; height: 100%;"></iframe>
</div>
{% endblock %}
{% block extra_js %}
<script>
let currentWorkflowId = {{ workflow_id if workflow_id else 'null' }};
let diagramXml = null;
function initEditor() {
const iframe = document.getElementById('diagramFrame');
// Build draw.io embed URL with parameters
const params = new URLSearchParams({
embed: '1',
ui: 'kennedy',
spin: '1',
proto: 'json',
configure: '1',
noSaveBtn: '1',
noExitBtn: '1',
libraries: '1',
saveAndExit: '0'
});
iframe.src = `https://embed.diagrams.net/?${params.toString()}`;
// Listen for messages from draw.io
window.addEventListener('message', function(evt) {
if (evt.data.length > 0) {
try {
const msg = JSON.parse(evt.data);
// When editor is ready
if (msg.event === 'init') {
iframe.contentWindow.postMessage(JSON.stringify({
action: 'load',
autosave: 1,
xml: diagramXml || '' // Load existing diagram if editing
}), '*');
}
// When diagram is exported
if (msg.event === 'export') {
diagramXml = msg.xml;
}
// Auto-save on every change
if (msg.event === 'autosave') {
diagramXml = msg.xml;
}
// Request export when saving
if (msg.event === 'save') {
iframe.contentWindow.postMessage(JSON.stringify({
action: 'export',
format: 'xml'
}), '*');
}
} catch (e) {
// Ignore non-JSON messages
}
}
});
}
async function loadWorkflow() {
if (!currentWorkflowId) return;
try {
const response = await fetch(`/api/v1/devportal/workflows/${currentWorkflowId}`);
const workflow = await response.json();
document.getElementById('workflowTitle').value = workflow.title;
document.getElementById('workflowDescription').value = workflow.description || '';
document.getElementById('workflowCategory').value = workflow.category || 'flowchart';
diagramXml = workflow.diagram_xml;
// Reinitialize editor with loaded data
initEditor();
} catch (error) {
console.error('Error loading workflow:', error);
}
}
async function saveWorkflow() {
const title = document.getElementById('workflowTitle').value;
const description = document.getElementById('workflowDescription').value;
const category = document.getElementById('workflowCategory').value;
if (!title) {
alert('Indtast venligst en titel');
return;
}
// Request export from draw.io
const iframe = document.getElementById('diagramFrame');
iframe.contentWindow.postMessage(JSON.stringify({
action: 'export',
format: 'xml'
}), '*');
// Wait a bit for export to complete
setTimeout(async () => {
if (!diagramXml) {
alert('Kunne ikke eksportere diagram. Prøv igen.');
return;
}
const workflow = {
title,
description,
category,
diagram_xml: diagramXml
};
try {
let response;
if (currentWorkflowId) {
// Update existing
response = await fetch(`/api/v1/devportal/workflows/${currentWorkflowId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflow)
});
} else {
// Create new
response = await fetch('/api/v1/devportal/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflow)
});
}
if (response.ok) {
const result = await response.json();
alert('Workflow gemt!');
window.location.href = '/devportal';
} else {
alert('Fejl ved gemning af workflow');
}
} catch (error) {
console.error('Error saving workflow:', error);
alert('Fejl ved gemning af workflow');
}
}, 500);
}
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
if (currentWorkflowId) {
loadWorkflow();
} else {
initEditor();
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,621 @@
{% extends "shared/frontend/base.html" %}
{% block title %}DEV Portal - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.nav-pills .nav-link {
color: var(--text-secondary);
border-radius: 8px;
padding: 0.75rem 1.5rem;
margin-bottom: 0.5rem;
}
.nav-pills .nav-link.active {
background: var(--accent);
color: white;
}
.feature-card {
border-left: 4px solid;
transition: transform 0.2s, box-shadow 0.2s;
}
.feature-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.status-planlagt { border-color: #6c757d; }
.status-i-gang { border-color: #0d6efd; }
.status-færdig { border-color: #198754; }
.status-sat-på-pause { border-color: #ffc107; }
.idea-card {
transition: transform 0.2s;
}
.idea-card:hover {
transform: scale(1.02);
}
.vote-button {
cursor: pointer;
transition: all 0.2s;
}
.vote-button:hover {
transform: scale(1.1);
color: var(--accent) !important;
}
.workflow-thumbnail {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 8px;
background: #f8f9fa;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-5">
<div>
<h2 class="fw-bold mb-1"><i class="bi bi-code-square me-2"></i>DEV Portal</h2>
<p class="text-muted mb-0">Roadmap, idéer og workflow dokumentation</p>
</div>
<div class="d-flex gap-2" id="actionButtons">
<!-- Dynamic buttons based on active tab -->
</div>
</div>
<!-- Stats Cards -->
<div class="row g-4 mb-4" id="statsCards">
<div class="col-md-3">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1">Features</p>
<h3 class="mb-0" id="featuresCount">-</h3>
</div>
<i class="bi bi-flag text-primary" style="font-size: 2rem;"></i>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1">Idéer</p>
<h3 class="mb-0" id="ideasCount">-</h3>
</div>
<i class="bi bi-lightbulb text-warning" style="font-size: 2rem;"></i>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1">Workflows</p>
<h3 class="mb-0" id="workflowsCount">-</h3>
</div>
<i class="bi bi-diagram-3 text-success" style="font-size: 2rem;"></i>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1">I Gang</p>
<h3 class="mb-0" id="inProgressCount">-</h3>
</div>
<i class="bi bi-gear text-info" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
<!-- Navigation Tabs -->
<ul class="nav nav-pills mb-4" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="pill" href="#roadmap">
<i class="bi bi-calendar3 me-2"></i>Roadmap
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#ideas">
<i class="bi bi-lightbulb me-2"></i>Idéer & Brainstorm
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#workflows">
<i class="bi bi-diagram-3 me-2"></i>Workflows
</a>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content">
<!-- Roadmap Tab -->
<div class="tab-pane fade show active" id="roadmap">
<!-- Version Filter -->
<div class="mb-4">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary active" onclick="filterByVersion(null)">Alle</button>
<button type="button" class="btn btn-outline-primary" onclick="filterByVersion('V1')">V1</button>
<button type="button" class="btn btn-outline-primary" onclick="filterByVersion('V2')">V2</button>
<button type="button" class="btn btn-outline-primary" onclick="filterByVersion('V3')">V3</button>
</div>
</div>
<!-- Kanban Board -->
<div class="row g-4">
<div class="col-md-3">
<div class="card p-3">
<h6 class="fw-bold mb-3 text-secondary">📋 Planlagt</h6>
<div id="planlagt-features" class="feature-column"></div>
</div>
</div>
<div class="col-md-3">
<div class="card p-3">
<h6 class="fw-bold mb-3 text-primary">⚙️ I Gang</h6>
<div id="i-gang-features" class="feature-column"></div>
</div>
</div>
<div class="col-md-3">
<div class="card p-3">
<h6 class="fw-bold mb-3 text-success">✅ Færdig</h6>
<div id="færdig-features" class="feature-column"></div>
</div>
</div>
<div class="col-md-3">
<div class="card p-3">
<h6 class="fw-bold mb-3 text-warning">⏸️ På Pause</h6>
<div id="sat-på-pause-features" class="feature-column"></div>
</div>
</div>
</div>
</div>
<!-- Ideas Tab -->
<div class="tab-pane fade" id="ideas">
<div class="row g-4" id="ideasGrid">
<!-- Dynamic ideas cards -->
</div>
</div>
<!-- Workflows Tab -->
<div class="tab-pane fade" id="workflows">
<div class="row g-4" id="workflowsGrid">
<!-- Dynamic workflow cards -->
</div>
</div>
</div>
<!-- Create Feature Modal -->
<div class="modal fade" id="createFeatureModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ny Feature</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createFeatureForm">
<div class="mb-3">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="featureTitle" required>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="featureDescription" rows="3"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Version</label>
<select class="form-select" id="featureVersion">
<option value="">Vælg version</option>
<option value="V1">V1</option>
<option value="V2">V2</option>
<option value="V3">V3</option>
<option value="V4">V4</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Status</label>
<select class="form-select" id="featureStatus">
<option value="planlagt">Planlagt</option>
<option value="i gang">I Gang</option>
<option value="færdig">Færdig</option>
<option value="sat på pause">På Pause</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Prioritet (0-100)</label>
<input type="number" class="form-control" id="featurePriority" value="50" min="0" max="100">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Forventet Dato</label>
<input type="date" class="form-control" id="featureDate">
</div>
</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" onclick="createFeature()">Opret Feature</button>
</div>
</div>
</div>
</div>
<!-- Create Idea Modal -->
<div class="modal fade" id="createIdeaModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ny Idé</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createIdeaForm">
<div class="mb-3">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="ideaTitle" required>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="ideaDescription" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Kategori</label>
<select class="form-select" id="ideaCategory">
<option value="feature">Feature</option>
<option value="improvement">Forbedring</option>
<option value="bugfix">Bugfix</option>
<option value="research">Research</option>
</select>
</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" onclick="createIdea()">Opret Idé</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let allFeatures = [];
let currentVersionFilter = null;
// Helper functions to open modals
function openCreateFeatureModal() {
const modal = new bootstrap.Modal(document.getElementById('createFeatureModal'));
modal.show();
}
function openCreateIdeaModal() {
const modal = new bootstrap.Modal(document.getElementById('createIdeaModal'));
modal.show();
}
async function loadStats() {
try {
const response = await fetch('/api/v1/devportal/stats');
if (!response.ok) throw new Error('Kunne ikke hente statistik');
const data = await response.json();
document.getElementById('featuresCount').textContent = data.features_count;
document.getElementById('ideasCount').textContent = data.ideas_count;
document.getElementById('workflowsCount').textContent = data.workflows_count;
const inProgress = data.features_by_status.find(s => s.status === 'i gang');
document.getElementById('inProgressCount').textContent = inProgress ? inProgress.count : 0;
} catch (error) {
console.error('Error loading stats:', error);
}
}
async function loadFeatures() {
try {
const response = await fetch('/api/v1/devportal/features');
if (!response.ok) throw new Error('Kunne ikke hente features');
allFeatures = await response.json();
console.log(`📊 Loaded ${allFeatures.length} features`);
displayFeatures();
} catch (error) {
console.error('Error loading features:', error);
alert('Fejl ved indlæsning af features: ' + error.message);
}
}
function displayFeatures() {
const features = currentVersionFilter
? allFeatures.filter(f => f.version === currentVersionFilter)
: allFeatures;
console.log(`🎯 Displaying ${features.length} features (filter: ${currentVersionFilter || 'none'})`);
// Clear columns
['planlagt', 'i gang', 'færdig', 'sat på pause'].forEach(status => {
const column = document.getElementById(`${status}-features`);
if (column) column.innerHTML = '';
});
features.forEach(feature => {
const column = document.getElementById(`${feature.status}-features`);
if (!column) return;
const card = document.createElement('div');
card.className = `card feature-card status-${feature.status} p-3 mb-2`;
card.innerHTML = `
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="fw-bold mb-0">${feature.title}</h6>
<div>
${feature.version ? `<span class="badge bg-secondary me-1">${feature.version}</span>` : ''}
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteFeature(${feature.id})" title="Slet">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
${feature.description ? `<p class="small text-muted mb-2">${feature.description}</p>` : ''}
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">Prioritet: ${feature.priority}</small>
${feature.expected_date ? `<small class="text-muted">${new Date(feature.expected_date).toLocaleDateString('da-DK')}</small>` : ''}
</div>
`;
column.appendChild(card);
});
}
function filterByVersion(version) {
currentVersionFilter = version;
// Update active button
document.querySelectorAll('.btn-group .btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
displayFeatures();
}
async function loadIdeas() {
try {
const response = await fetch('/api/v1/devportal/ideas');
if (!response.ok) throw new Error('Kunne ikke hente idéer');
const ideas = await response.json();
const grid = document.getElementById('ideasGrid');
grid.innerHTML = ideas.map(idea => `
<div class="col-md-4">
<div class="card idea-card p-3 h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="fw-bold">${idea.title}</h6>
<span class="badge bg-primary">${idea.category || 'general'}</span>
</div>
${idea.description ? `<p class="text-muted small mb-3">${idea.description}</p>` : ''}
<div class="mt-auto d-flex justify-content-between align-items-center">
<div class="vote-button" onclick="voteIdea(${idea.id})">
<i class="bi bi-hand-thumbs-up me-1"></i>
<span>${idea.votes}</span>
</div>
<button class="btn btn-sm btn-outline-danger" onclick="deleteIdea(${idea.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading ideas:', error);
alert('Fejl ved indlæsning af idéer: ' + error.message);
}
}
async function loadWorkflows() {
try {
const response = await fetch('/api/v1/devportal/workflows');
if (!response.ok) throw new Error('Kunne ikke hente workflows');
const workflows = await response.json();
const grid = document.getElementById('workflowsGrid');
grid.innerHTML = workflows.map(wf => `
<div class="col-md-4">
<div class="card p-3">
<div class="workflow-thumbnail mb-3 d-flex align-items-center justify-content-center">
<i class="bi bi-diagram-3" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
<h6 class="fw-bold">${wf.title}</h6>
${wf.description ? `<p class="text-muted small mb-3">${wf.description}</p>` : ''}
<div class="d-flex gap-2">
<a href="/devportal/editor?id=${wf.id}" class="btn btn-sm btn-primary flex-grow-1">
<i class="bi bi-pencil me-1"></i>Rediger
</a>
<button class="btn btn-sm btn-outline-danger" onclick="deleteWorkflow(${wf.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading workflows:', error);
alert('Fejl ved indlæsning af workflows: ' + error.message);
}
}
async function createFeature() {
const feature = {
title: document.getElementById('featureTitle').value,
description: document.getElementById('featureDescription').value,
version: document.getElementById('featureVersion').value,
status: document.getElementById('featureStatus').value,
priority: parseInt(document.getElementById('featurePriority').value),
expected_date: document.getElementById('featureDate').value || null
};
if (!feature.title) {
alert('Titel er påkrævet');
return;
}
try {
const response = await fetch('/api/v1/devportal/features', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feature)
});
if (!response.ok) {
throw new Error('Kunne ikke oprette feature');
}
const modal = bootstrap.Modal.getInstance(document.getElementById('createFeatureModal'));
if (modal) modal.hide();
document.getElementById('createFeatureForm').reset();
await loadFeatures();
await loadStats();
console.log('✅ Feature created successfully');
} catch (error) {
console.error('Error creating feature:', error);
alert('Fejl ved oprettelse af feature: ' + error.message);
}
}
async function createIdea() {
const idea = {
title: document.getElementById('ideaTitle').value,
description: document.getElementById('ideaDescription').value,
category: document.getElementById('ideaCategory').value
};
if (!idea.title) {
alert('Titel er påkrævet');
return;
}
try {
const response = await fetch('/api/v1/devportal/ideas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(idea)
});
if (!response.ok) {
throw new Error('Kunne ikke oprette idé');
}
const modal = bootstrap.Modal.getInstance(document.getElementById('createIdeaModal'));
if (modal) modal.hide();
document.getElementById('createIdeaForm').reset();
await loadIdeas();
await loadStats();
console.log('✅ Idea created successfully');
} catch (error) {
console.error('Error creating idea:', error);
alert('Fejl ved oprettelse af idé: ' + error.message);
}
}
async function voteIdea(id) {
try {
const response = await fetch(`/api/v1/devportal/ideas/${id}/vote`, { method: 'POST' });
if (!response.ok) {
throw new Error('Kunne ikke stemme');
}
await loadIdeas();
} catch (error) {
console.error('Error voting:', error);
alert('Fejl ved stemning: ' + error.message);
}
}
async function deleteIdea(id) {
if (!confirm('Er du sikker på at du vil slette denne idé?')) return;
try {
const response = await fetch(`/api/v1/devportal/ideas/${id}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Kunne ikke slette idé');
}
await loadIdeas();
await loadStats();
} catch (error) {
console.error('Error deleting idea:', error);
alert('Fejl ved sletning: ' + error.message);
}
}
async function deleteFeature(id) {
if (!confirm('Er du sikker på at du vil slette denne feature?')) return;
try {
const response = await fetch(`/api/v1/devportal/features/${id}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Kunne ikke slette feature');
}
await loadFeatures();
await loadStats();
} catch (error) {
console.error('Error deleting feature:', error);
alert('Fejl ved sletning: ' + error.message);
}
}
async function deleteWorkflow(id) {
if (!confirm('Er du sikker på at du vil slette denne workflow?')) return;
try {
const response = await fetch(`/api/v1/devportal/workflows/${id}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Kunne ikke slette workflow');
}
await loadWorkflows();
await loadStats();
} catch (error) {
console.error('Error deleting workflow:', error);
alert('Fejl ved sletning: ' + error.message);
}
}
// Tab change handling
document.querySelectorAll('a[data-bs-toggle="pill"]').forEach(tab => {
tab.addEventListener('shown.bs.tab', (e) => {
const target = e.target.getAttribute('href');
const buttons = document.getElementById('actionButtons');
if (target === '#roadmap') {
buttons.innerHTML = '<button class="btn btn-primary" onclick="openCreateFeatureModal()"><i class="bi bi-plus-lg me-2"></i>Ny Feature</button>';
} else if (target === '#ideas') {
buttons.innerHTML = '<button class="btn btn-primary" onclick="openCreateIdeaModal()"><i class="bi bi-plus-lg me-2"></i>Ny Idé</button>';
} else if (target === '#workflows') {
buttons.innerHTML = '<a href="/devportal/editor" class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Workflow</a>';
}
});
});
// Initial load
document.addEventListener('DOMContentLoaded', () => {
console.log('DEV Portal loaded - functions available:', {
openCreateFeatureModal: typeof openCreateFeatureModal,
openCreateIdeaModal: typeof openCreateIdeaModal,
createFeature: typeof createFeature,
createIdea: typeof createIdea
});
loadStats();
loadFeatures();
loadIdeas();
loadWorkflows();
// Set initial button
document.getElementById('actionButtons').innerHTML = '<button class="btn btn-primary" onclick="openCreateFeatureModal()"><i class="bi bi-plus-lg me-2"></i>Ny Feature</button>';
});
</script>
{% endblock %}

View File

@ -225,6 +225,7 @@
<ul class="dropdown-menu dropdown-menu-end mt-2"> <ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="#">Profil</a></li> <li><a class="dropdown-item py-2" href="#">Profil</a></li>
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li> <li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li> <li><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li>
</ul> </ul>
@ -259,13 +260,23 @@
</div> </div>
<div class="row g-0 flex-grow-1" style="overflow-y: auto;"> <div class="row g-0 flex-grow-1" style="overflow-y: auto;">
<!-- Search Results Area (3/4 width) --> <!-- Search Results & Workflows (3/4 width) -->
<div class="col-lg-9 p-4" style="border-right: 1px solid rgba(0,0,0,0.1);"> <div class="col-lg-9 p-4" style="border-right: 1px solid rgba(0,0,0,0.1);">
<!-- Contextual Workflows Section -->
<div id="workflowActions" style="display: none;" class="mb-4">
<h6 class="text-muted text-uppercase small fw-bold mb-3">
<i class="bi bi-lightning-charge me-2"></i>Hurtige Handlinger
</h6>
<div id="workflowButtons" class="d-flex flex-wrap gap-2">
<!-- Dynamic workflow buttons -->
</div>
</div>
<div id="searchResults"> <div id="searchResults">
<!-- Empty State --> <!-- Empty State -->
<div id="emptyState" class="text-center py-5"> <div id="emptyState" class="text-center py-5">
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i> <i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
<p class="text-muted mt-3">Begynd at skrive for at søge...</p> <p class="text-muted mt-3">Tryk <kbd>⌘K</kbd> eller begynd at skrive...</p>
</div> </div>
<!-- CRM Results --> <!-- CRM Results -->
@ -330,46 +341,76 @@
</div> </div>
</div> </div>
<!-- Activity Sidebar (1/4 width) --> <!-- Live Boxes Sidebar (1/4 width) -->
<div class="col-lg-3 p-4" style="background: var(--accent-light);"> <div class="col-lg-3 p-3" style="background: var(--bg-body); overflow-y: auto;">
<h6 class="text-uppercase small fw-bold mb-3" style="color: var(--accent);"> <!-- Recent Activity Section -->
<div class="mb-4">
<h6 class="text-uppercase small fw-bold mb-3" style="color: var(--text-primary);">
<i class="bi bi-clock-history me-2"></i>Seneste Aktivitet <i class="bi bi-clock-history me-2"></i>Seneste Aktivitet
</h6> </h6>
<div id="recentActivity"> <div id="recentActivityList">
<div class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);"> <!-- Dynamic activity items -->
<div class="d-flex align-items-start"> </div>
<i class="bi bi-person-circle text-primary me-2" style="font-size: 1.2rem;"></i> </div>
<div class="flex-grow-1">
<p class="mb-0 small fw-bold">Advokatgruppen A/S</p> <hr class="my-3">
<p class="mb-0 small text-muted">Opdateret for 2 min siden</p>
<!-- Sales Box -->
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="text-uppercase small fw-bold mb-0">
<i class="bi bi-cart3 me-2"></i>Sales
</h6>
<i class="bi bi-arrow-up-right"></i>
</div>
<div id="salesBox">
<div class="mb-2">
<p class="mb-0 small opacity-75">Aktive ordrer</p>
<h4 class="mb-0 fw-bold">-</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Månedens salg</p>
<h5 class="mb-0 fw-bold">- kr</h5>
</div> </div>
</div> </div>
</div> </div>
<div class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
<div class="d-flex align-items-start"> <!-- Support Box -->
<i class="bi bi-ticket-detailed text-warning me-2" style="font-size: 1.2rem;"></i> <div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white;">
<div class="flex-grow-1"> <div class="d-flex align-items-center justify-content-between mb-2">
<p class="mb-0 small fw-bold">Sag #1234 lukket</p> <h6 class="text-uppercase small fw-bold mb-0">
<p class="mb-0 small text-muted">For 15 min siden</p> <i class="bi bi-headset me-2"></i>Support
</h6>
<i class="bi bi-arrow-up-right"></i>
</div>
<div id="supportBox">
<div class="mb-2">
<p class="mb-0 small opacity-75">Åbne sager</p>
<h4 class="mb-0 fw-bold">-</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Gns. svartid</p>
<h5 class="mb-0 fw-bold">- min</h5>
</div> </div>
</div> </div>
</div> </div>
<div class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
<div class="d-flex align-items-start"> <!-- Økonomi Box -->
<i class="bi bi-receipt text-success me-2" style="font-size: 1.2rem;"></i> <div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white;">
<div class="flex-grow-1"> <div class="d-flex align-items-center justify-content-between mb-2">
<p class="mb-0 small fw-bold">Faktura #5678 betalt</p> <h6 class="text-uppercase small fw-bold mb-0">
<p class="mb-0 small text-muted">I dag kl. 14:30</p> <i class="bi bi-currency-dollar me-2"></i>Økonomi
</h6>
<i class="bi bi-arrow-up-right"></i>
</div> </div>
<div id="financeBox">
<div class="mb-2">
<p class="mb-0 small opacity-75">Ubetalte fakturaer</p>
<h4 class="mb-0 fw-bold">-</h4>
</div> </div>
</div> <div class="mb-2">
<div class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);"> <p class="mb-0 small opacity-75">Samlet beløb</p>
<div class="d-flex align-items-start"> <h5 class="mb-0 fw-bold">- kr</h5>
<i class="bi bi-box-seam text-info me-2" style="font-size: 1.2rem;"></i>
<div class="flex-grow-1">
<p class="mb-0 small fw-bold">Ny ordre oprettet</p>
<p class="mb-0 small text-muted">I går kl. 16:45</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -418,7 +459,11 @@
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault(); e.preventDefault();
searchModal.show(); searchModal.show();
setTimeout(() => searchInput.focus(), 300); setTimeout(() => {
searchInput.focus();
loadLiveStats();
loadRecentActivity();
}, 300);
} }
// ESC to close // ESC to close
@ -427,7 +472,180 @@
} }
}); });
// Load live statistics for the three boxes
async function loadLiveStats() {
try {
const response = await fetch('/api/v1/dashboard/live-stats');
const data = await response.json();
// Update Sales Box
const salesBox = document.getElementById('salesBox');
salesBox.innerHTML = `
<div class="mb-2">
<p class="mb-0 small opacity-75">Aktive ordrer</p>
<h4 class="mb-0 fw-bold">${data.sales.active_orders}</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Månedens salg</p>
<h5 class="mb-0 fw-bold">${data.sales.monthly_sales.toLocaleString('da-DK')} kr</h5>
</div>
`;
// Update Support Box
const supportBox = document.getElementById('supportBox');
supportBox.innerHTML = `
<div class="mb-2">
<p class="mb-0 small opacity-75">Åbne sager</p>
<h4 class="mb-0 fw-bold">${data.support.open_tickets}</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Gns. svartid</p>
<h5 class="mb-0 fw-bold">${data.support.avg_response_time} min</h5>
</div>
`;
// Update Finance Box
const financeBox = document.getElementById('financeBox');
financeBox.innerHTML = `
<div class="mb-2">
<p class="mb-0 small opacity-75">Ubetalte fakturaer</p>
<h4 class="mb-0 fw-bold">${data.finance.unpaid_invoices_count}</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Samlet beløb</p>
<h5 class="mb-0 fw-bold">${data.finance.unpaid_invoices_amount.toLocaleString('da-DK')} kr</h5>
</div>
`;
} catch (error) {
console.error('Error loading live stats:', error);
}
}
// Load recent activity
async function loadRecentActivity() {
try {
const response = await fetch('/api/v1/dashboard/recent-activity');
const activities = await response.json();
const activityList = document.getElementById('recentActivityList');
if (activities.length === 0) {
activityList.innerHTML = '<p class="small text-muted">Ingen nylig aktivitet</p>';
return;
}
activityList.innerHTML = activities.map(activity => {
const timeAgo = getTimeAgo(new Date(activity.created_at));
const label = activity.activity_type === 'customer' ? 'Kunde' :
activity.activity_type === 'contact' ? 'Kontakt' : 'Leverandør';
return `
<div class="activity-item mb-2 p-2 rounded" style="background: var(--bg-card); border-left: 3px solid var(--accent);">
<div class="d-flex align-items-start">
<i class="${activity.icon} text-${activity.color} me-2" style="font-size: 1.1rem;"></i>
<div class="flex-grow-1">
<p class="mb-0 small fw-bold" style="color: var(--text-primary);">${activity.name}</p>
<p class="mb-0 small text-muted">${label} • ${timeAgo}</p>
</div>
</div>
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading recent activity:', error);
}
}
// Helper function to format time ago
function getTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
let interval = seconds / 31536000;
if (interval > 1) return Math.floor(interval) + ' år siden';
interval = seconds / 2592000;
if (interval > 1) return Math.floor(interval) + ' mdr siden';
interval = seconds / 86400;
if (interval > 1) return Math.floor(interval) + ' dage siden';
interval = seconds / 3600;
if (interval > 1) return Math.floor(interval) + ' timer siden';
interval = seconds / 60;
if (interval > 1) return Math.floor(interval) + ' min siden';
return 'Lige nu';
}
let searchTimeout; let searchTimeout;
let selectedEntity = null;
// Workflow definitions per entity type
const workflows = {
customer: [
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?customer=${data.id}` },
{ label: 'Opret sag', icon: 'ticket-detailed', action: (data) => window.location.href = `/tickets/new?customer=${data.id}` },
{ label: 'Ring til kontakt', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.phone || 'Intet telefonnummer')) },
{ label: 'Vis kunde', icon: 'eye', action: (data) => window.location.href = `/customers/${data.id}` }
],
contact: [
{ label: 'Ring op', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.mobile_phone || data.phone || 'Intet telefonnummer')) },
{ label: 'Send email', icon: 'envelope', action: (data) => window.location.href = `mailto:${data.email}` },
{ label: 'Opret møde', icon: 'calendar-event', action: (data) => alert('Opret møde funktionalitet kommer snart') },
{ label: 'Vis kontakt', icon: 'eye', action: (data) => window.location.href = `/contacts/${data.id}` }
],
vendor: [
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?vendor=${data.id}` },
{ label: 'Se produkter', icon: 'box-seam', action: (data) => window.location.href = `/vendors/${data.id}/products` },
{ label: 'Vis leverandør', icon: 'eye', action: (data) => window.location.href = `/vendors/${data.id}` }
],
invoice: [
{ label: 'Vis faktura', icon: 'eye', action: (data) => window.location.href = `/invoices/${data.id}` },
{ label: 'Udskriv faktura', icon: 'printer', action: (data) => window.print() },
{ label: 'Opret kassekladde', icon: 'journal-text', action: (data) => alert('Kassekladde funktionalitet kommer snart') },
{ label: 'Opret kreditnota', icon: 'file-earmark-minus', action: (data) => window.location.href = `/invoices/${data.id}/credit-note` }
],
ticket: [
{ label: 'Åbn sag', icon: 'folder2-open', action: (data) => window.location.href = `/tickets/${data.id}` },
{ label: 'Luk sag', icon: 'check-circle', action: (data) => alert('Luk sag funktionalitet kommer snart') },
{ label: 'Tildel medarbejder', icon: 'person-plus', action: (data) => alert('Tildel funktionalitet kommer snart') }
],
rodekasse: [
{ label: 'Behandle', icon: 'pencil-square', action: (data) => window.location.href = `/rodekasse/${data.id}` },
{ label: 'Arkiver', icon: 'archive', action: (data) => alert('Arkiver funktionalitet kommer snart') },
{ label: 'Slet', icon: 'trash', action: (data) => confirm('Er du sikker?') && alert('Slet funktionalitet kommer snart') }
]
};
// Show contextual workflows based on entity
function showWorkflows(entityType, entityData) {
selectedEntity = entityData;
const workflowSection = document.getElementById('workflowActions');
const workflowButtons = document.getElementById('workflowButtons');
const entityWorkflows = workflows[entityType];
if (!entityWorkflows) {
workflowSection.style.display = 'none';
return;
}
workflowButtons.innerHTML = entityWorkflows.map(wf => `
<button class="btn btn-outline-primary" onclick="executeWorkflow('${entityType}', '${wf.label}')">
<i class="bi bi-${wf.icon} me-2"></i>${wf.label}
</button>
`).join('');
workflowSection.style.display = 'block';
}
// Execute workflow action
window.executeWorkflow = function(entityType, label) {
const workflow = workflows[entityType].find(w => w.label === label);
if (workflow && selectedEntity) {
workflow.action(selectedEntity);
}
};
// Search function // Search function
searchInput.addEventListener('input', (e) => { searchInput.addEventListener('input', (e) => {
@ -444,10 +662,12 @@
if (query.length < 2) { if (query.length < 2) {
emptyState.style.display = 'block'; emptyState.style.display = 'block';
document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none'; document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none'; document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none'; if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none'; if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
selectedEntity = null;
return; return;
} }
@ -461,15 +681,16 @@
// CRM Results (Customers + Contacts + Vendors) // CRM Results (Customers + Contacts + Vendors)
const crmSection = document.getElementById('crmResults'); const crmSection = document.getElementById('crmResults');
const allResults = [ const allResults = [
...(data.customers || []).map(c => ({...c, url: `/customers/${c.id}`, icon: 'building'})), ...(data.customers || []).map(c => ({...c, entityType: 'customer', url: `/customers/${c.id}`, icon: 'building'})),
...(data.contacts || []).map(c => ({...c, url: `/contacts/${c.id}`, icon: 'person'})), ...(data.contacts || []).map(c => ({...c, entityType: 'contact', url: `/contacts/${c.id}`, icon: 'person'})),
...(data.vendors || []).map(c => ({...c, url: `/vendors/${c.id}`, icon: 'shop'})) ...(data.vendors || []).map(c => ({...c, entityType: 'vendor', url: `/vendors/${c.id}`, icon: 'shop'}))
]; ];
if (allResults.length > 0) { if (allResults.length > 0) {
crmSection.style.display = 'block'; crmSection.style.display = 'block';
crmSection.querySelector('.result-items').innerHTML = allResults.map(item => ` crmSection.querySelector('.result-items').innerHTML = allResults.map(item => `
<a href="${item.url}" class="result-item d-block p-3 mb-2 rounded text-decoration-none" style="background: var(--bg-card); border: 1px solid transparent; transition: all 0.2s;"> <div class="result-item p-3 mb-2 rounded" style="background: var(--bg-card); border: 1px solid transparent; transition: all 0.2s; cursor: pointer;"
onclick='showWorkflows("${item.entityType}", ${JSON.stringify(item).replace(/'/g, "&apos;")})'>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;"> <div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
@ -480,10 +701,15 @@
<p class="mb-0 small text-muted">${item.type} ${item.email ? '• ' + item.email : ''}</p> <p class="mb-0 small text-muted">${item.type} ${item.email ? '• ' + item.email : ''}</p>
</div> </div>
</div> </div>
<i class="bi bi-arrow-right" style="color: var(--accent);"></i> <i class="bi bi-chevron-right" style="color: var(--accent);"></i>
</div>
</div> </div>
</a>
`).join(''); `).join('');
// Auto-select first result
if (allResults.length > 0) {
showWorkflows(allResults[0].entityType, allResults[0]);
}
} else { } else {
crmSection.style.display = 'none'; crmSection.style.display = 'none';
emptyState.style.display = 'block'; emptyState.style.display = 'block';
@ -519,7 +745,9 @@
// Reset search when modal is closed // Reset search when modal is closed
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => { document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
searchInput.value = ''; searchInput.value = '';
selectedEntity = null;
document.getElementById('emptyState').style.display = 'block'; document.getElementById('emptyState').style.display = 'block';
document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none'; document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none'; document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none'; if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';

229
docs/DEV_PORTAL.md Normal file
View File

@ -0,0 +1,229 @@
# DEV Portal - Dokumentation
## Oversigt
DEV Portal er en selvstændig app i BMC Hub til at planlægge og dokumentere udviklingsarbejde.
**URL**: [http://localhost:8001/devportal](http://localhost:8001/devportal)
**Adgang**: Via bruger dropdown menu → "DEV Portal"
## Funktioner
### 1. Roadmap (Kanban Board)
- **Version Tagging**: V1, V2, V3, osv.
- **Statuser**:
- ✅ Planlagt
- ⏳ I Gang
- ✅ Færdig
- ⏸️ Sat på Pause
- **Prioritering**: 1-100 (lav-høj)
- **Datoer**: Forventet dato + afsluttet dato
- **Filtering**: Filtrer efter version (V1/V2/V3)
### 2. Idéer (Brainstorming)
- Opret nye idéer med titel, beskrivelse og kategori
- **Voting System**: Stem på gode idéer (thumbs up)
- **Kategorier**: UI/UX, Integration, Automatisering, Sikkerhed, Andet
- Sortering: Automatisk efter antal stemmer
### 3. Workflows (Diagram Editor)
- **Draw.io Integration**: Embedded diagram editor
- **Typer**: Flowchart, Proces, System Diagram
- **Lagring**: XML gemmes direkte i database
- **Redigering**: Klik "Rediger" for at åbne eksisterende workflow
## Database Struktur
### dev_features
```sql
- id (SERIAL PRIMARY KEY)
- title (VARCHAR(255))
- description (TEXT)
- version (VARCHAR(50)) -- V1, V2, V3, etc.
- status (VARCHAR(50)) -- planlagt, i gang, færdig, sat på pause
- priority (INTEGER) -- 1-100
- expected_date (DATE)
- completed_date (DATE)
- created_at, updated_at (TIMESTAMP)
```
### dev_ideas
```sql
- id (SERIAL PRIMARY KEY)
- title (VARCHAR(255))
- description (TEXT)
- category (VARCHAR(50))
- votes (INTEGER) -- voting system
- created_at, updated_at (TIMESTAMP)
```
### dev_workflows
```sql
- id (SERIAL PRIMARY KEY)
- title (VARCHAR(255))
- description (TEXT)
- category (VARCHAR(50))
- diagram_xml (TEXT) -- draw.io XML format
- thumbnail_url (VARCHAR(500))
- created_at, updated_at (TIMESTAMP)
```
## API Endpoints
### Features
- `GET /api/v1/devportal/features` - List alle features
- Query params: `?version=V1&status=færdig`
- `GET /api/v1/devportal/features/{id}` - Hent specifik feature
- `POST /api/v1/devportal/features` - Opret ny feature
- `PUT /api/v1/devportal/features/{id}` - Opdater feature
- `DELETE /api/v1/devportal/features/{id}` - Slet feature
### Ideas
- `GET /api/v1/devportal/ideas` - List alle idéer
- Query params: `?category=integration`
- `POST /api/v1/devportal/ideas` - Opret ny idé
- `POST /api/v1/devportal/ideas/{id}/vote` - Stem på idé
- `DELETE /api/v1/devportal/ideas/{id}` - Slet idé
### Workflows
- `GET /api/v1/devportal/workflows` - List alle workflows
- Query params: `?category=process`
- `GET /api/v1/devportal/workflows/{id}` - Hent specifik workflow
- `POST /api/v1/devportal/workflows` - Opret ny workflow
- `PUT /api/v1/devportal/workflows/{id}` - Opdater workflow
- `DELETE /api/v1/devportal/workflows/{id}` - Slet workflow
### Stats
- `GET /api/v1/devportal/stats` - Hent statistik
```json
{
"features_count": 6,
"ideas_count": 4,
"workflows_count": 0,
"features_by_status": [
{"status": "færdig", "count": 3},
{"status": "planlagt", "count": 3}
]
}
```
## Frontend Routes
- `GET /devportal` - Hovedside med Kanban board, idéer og workflows
- `GET /devportal/editor?id={id}` - Workflow editor (draw.io)
## Draw.io Integration
### Embed URL
```
https://embed.diagrams.net/?embed=1&ui=kennedy&spin=1&proto=json
```
### postMessage API
```javascript
// Load existing diagram
iframe.contentWindow.postMessage(JSON.stringify({
action: 'load',
autosave: 1,
xml: '<mxfile>...</mxfile>'
}), '*');
// Export diagram
iframe.contentWindow.postMessage(JSON.stringify({
action: 'export',
format: 'xml'
}), '*');
```
### Events
- `init` - Editor is ready
- `autosave` - Diagram changed (auto-save)
- `export` - Export completed (returns XML)
- `save` - User clicked save
## Eksempel Data
### Features (6 stk.)
1. **Dashboard Forbedringer** (V1, færdig)
2. **Global Søgning** (V1, færdig)
3. **Settings & Brugerstyring** (V1, færdig)
4. **vTiger CRM Integration** (V2, planlagt)
5. **e-conomic Integration** (V2, planlagt)
6. **Rapport Generator** (V3, planlagt)
### Ideas (4 stk.)
1. **Eksport til Excel** (15 votes)
2. **Mobile app** (12 votes)
3. **AI Assistent** (8 votes)
4. **Dark mode forbedringer** (5 votes)
## Fjernelse af DEV Portal
Da dette er en selvstændig app, kan den nemt fjernes:
1. **Fjern fra main.py**:
```python
# Remove imports
from app.devportal.backend import router as devportal_api
from app.devportal.backend import views as devportal_views
# Remove router registrations
app.include_router(devportal_api.router, ...)
app.include_router(devportal_views.router, ...)
```
2. **Fjern menu link** fra `app/shared/frontend/base.html`:
```html
<li><a class="dropdown-item py-2" href="/devportal">...</a></li>
```
3. **Slet filer**:
```bash
rm -rf app/devportal/
```
4. **Valgfri: Drop database tables**:
```sql
DROP TABLE IF EXISTS dev_workflows CASCADE;
DROP TABLE IF EXISTS dev_ideas CASCADE;
DROP TABLE IF EXISTS dev_features CASCADE;
```
## Fremtidige Forbedringer
- [ ] Drag-and-drop i Kanban board (flyt features mellem kolonner)
- [ ] Workflow thumbnails (PNG preview fra XML)
- [ ] Export roadmap til PDF eller Excel
- [ ] GitHub/Gitea integration (link features til commits/PRs)
- [ ] Kommentarer på features og idéer
- [ ] Notifikationer ved status ændringer
- [ ] Tidsregistrering per feature
- [ ] Sprint planning funktionalitet
- [ ] Access control (admin-only vs alle brugere)
## Teknisk Implementation
### Filer Oprettet
```
migrations/007_dev_portal.sql # Database schema
app/devportal/backend/router.py # API endpoints (17 stk.)
app/devportal/backend/views.py # Frontend routes (2 stk.)
app/devportal/frontend/portal.html # Hovedside (Kanban + Ideas + Workflows)
app/devportal/frontend/editor.html # Draw.io editor
```
### Dependencies
- Bootstrap 5.3.2 (UI komponenter)
- Draw.io Embed (workflow editor)
- Fetch API (AJAX requests)
- Jinja2 (template rendering)
### Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
## Support
For problemer eller spørgsmål, kontakt udviklingsteamet eller opret en ny idé i DEV Portal.

View File

@ -29,6 +29,8 @@ from app.billing.backend import router as billing_api
from app.system.backend import router as system_api from app.system.backend import router as system_api
from app.dashboard.backend import views as dashboard_views from app.dashboard.backend import views as dashboard_views
from app.dashboard.backend import router as dashboard_api from app.dashboard.backend import router as dashboard_api
from app.devportal.backend import router as devportal_api
from app.devportal.backend import views as devportal_views
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -99,6 +101,7 @@ app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"])
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"]) app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"]) app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV Portal"])
# Frontend Routers # Frontend Routers
app.include_router(auth_views.router, tags=["Frontend"]) app.include_router(auth_views.router, tags=["Frontend"])
@ -107,6 +110,7 @@ app.include_router(customers_views.router, tags=["Frontend"])
app.include_router(contacts_views.router, tags=["Frontend"]) app.include_router(contacts_views.router, tags=["Frontend"])
app.include_router(vendors_views.router, tags=["Frontend"]) app.include_router(vendors_views.router, tags=["Frontend"])
app.include_router(settings_views.router, tags=["Frontend"]) app.include_router(settings_views.router, tags=["Frontend"])
app.include_router(devportal_views.router, tags=["Frontend"])
# Serve static files (UI) # Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static") app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -0,0 +1,108 @@
-- DEV Portal Migration
-- Features roadmap and workflow diagrams
-- Features/Roadmap table
CREATE TABLE IF NOT EXISTS dev_features (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
version VARCHAR(50), -- V1, V2, V3, etc.
status VARCHAR(50) DEFAULT 'planlagt', -- planlagt, i gang, færdig, sat på pause
priority INTEGER DEFAULT 50,
expected_date DATE,
completed_date DATE,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Brainstorm/Ideas table
CREATE TABLE IF NOT EXISTS dev_ideas (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100), -- feature, improvement, bugfix, etc.
votes INTEGER DEFAULT 0,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Workflow diagrams table (storing draw.io XML)
CREATE TABLE IF NOT EXISTS dev_workflows (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100), -- flowchart, process, system_diagram, etc.
diagram_xml TEXT NOT NULL, -- draw.io XML format
thumbnail_url TEXT, -- Preview image URL (optional)
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_dev_features_version ON dev_features(version);
CREATE INDEX IF NOT EXISTS idx_dev_features_status ON dev_features(status);
CREATE INDEX IF NOT EXISTS idx_dev_workflows_category ON dev_workflows(category);
-- Update timestamp trigger for dev_features
CREATE OR REPLACE FUNCTION update_dev_features_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER dev_features_updated_at_trigger
BEFORE UPDATE ON dev_features
FOR EACH ROW
EXECUTE FUNCTION update_dev_features_updated_at();
-- Update timestamp trigger for dev_ideas
CREATE OR REPLACE FUNCTION update_dev_ideas_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER dev_ideas_updated_at_trigger
BEFORE UPDATE ON dev_ideas
FOR EACH ROW
EXECUTE FUNCTION update_dev_ideas_updated_at();
-- Update timestamp trigger for dev_workflows
CREATE OR REPLACE FUNCTION update_dev_workflows_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER dev_workflows_updated_at_trigger
BEFORE UPDATE ON dev_workflows
FOR EACH ROW
EXECUTE FUNCTION update_dev_workflows_updated_at();
-- Sample data
INSERT INTO dev_features (title, description, version, status, priority, expected_date) VALUES
('Dashboard Forbedringer', 'Live data og bedre statistik visualisering', 'V1', 'færdig', 90, '2025-12-01'),
('Global Søgning', 'Søg på tværs af kunder, kontakter, leverandører med CVR/telefon', 'V1', 'færdig', 95, '2025-12-06'),
('Settings & Brugerstyring', 'Konfiguration og user management', 'V1', 'færdig', 85, '2025-12-06'),
('vTiger Integration', 'Synkronisering med vTiger CRM', 'V2', 'planlagt', 80, '2026-01-15'),
('e-conomic Integration', 'Fakturering og økonomi sync', 'V2', 'planlagt', 75, '2026-02-01'),
('Rapport Generator', 'PDF rapporter for kunder', 'V3', 'planlagt', 60, '2026-03-01');
INSERT INTO dev_ideas (title, description, category, votes) VALUES
('Dark mode forbedringer', 'Bedre kontrast i dark mode', 'improvement', 5),
('Mobile app', 'Native iOS/Android app', 'feature', 12),
('AI Assistent', 'ChatGPT integration til kundesupport', 'feature', 8),
('Eksport til Excel', 'Eksporter alle lister til Excel', 'feature', 15);
COMMENT ON TABLE dev_features IS 'Development roadmap features with versioning and status tracking';
COMMENT ON TABLE dev_ideas IS 'Brainstorm and idea collection for future development';
COMMENT ON TABLE dev_workflows IS 'Workflow diagrams created with draw.io, stored as XML';