diff --git a/app/core/database.py b/app/core/database.py
index e8c22c8..5323d43 100644
--- a/app/core/database.py
+++ b/app/core/database.py
@@ -71,11 +71,20 @@ def execute_query(query: str, params: tuple = None, fetchone: bool = False):
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
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:
row = cursor.fetchone()
+ if is_write:
+ conn.commit()
return dict(row) if row else None
else:
rows = cursor.fetchall()
+ if is_write:
+ conn.commit()
return [dict(row) for row in rows]
except Exception as e:
conn.rollback()
diff --git a/app/dashboard/backend/router.py b/app/dashboard/backend/router.py
index 37c47a5..ac3c2b4 100644
--- a/app/dashboard/backend/router.py
+++ b/app/dashboard/backend/router.py
@@ -122,3 +122,102 @@ async def global_search(q: str):
except Exception as e:
logger.error(f"❌ Error performing global search: {e}", exc_info=True)
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 []
diff --git a/app/devportal/backend/router.py b/app/devportal/backend/router.py
new file mode 100644
index 0000000..4912e64
--- /dev/null
+++ b/app/devportal/backend/router.py
@@ -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 []
+ }
diff --git a/app/devportal/backend/views.py b/app/devportal/backend/views.py
new file mode 100644
index 0000000..0c584d4
--- /dev/null
+++ b/app/devportal/backend/views.py
@@ -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
+ })
diff --git a/app/devportal/frontend/editor.html b/app/devportal/frontend/editor.html
new file mode 100644
index 0000000..8331178
--- /dev/null
+++ b/app/devportal/frontend/editor.html
@@ -0,0 +1,214 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Workflow Editor - DEV Portal{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/app/devportal/frontend/portal.html b/app/devportal/frontend/portal.html
new file mode 100644
index 0000000..3a930ab
--- /dev/null
+++ b/app/devportal/frontend/portal.html
@@ -0,0 +1,621 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}DEV Portal - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
DEV Portal
+
Roadmap, idéer og workflow dokumentation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index e968dc9..cdaad6a 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -225,6 +225,7 @@
@@ -259,13 +260,23 @@
-
+
+
+
+
+ Hurtige Handlinger
+
+
+
+
+
+
-
Begynd at skrive for at søge...
+
Tryk ⌘K eller begynd at skrive...
@@ -330,46 +341,76 @@
-
-
-
- Seneste Aktivitet
-
-
-
-
-
-
-
Advokatgruppen A/S
-
Opdateret for 2 min siden
-
+
+
+
+
+
+ Seneste Aktivitet
+
+
+
+
+
+
+
+
+
+
+
+
+ Sales
+
+
+
+
-
-
-
-
-
Sag #1234 lukket
-
For 15 min siden
-
+
+
+
+
+
+
+ Support
+
+
+
+
-
-
-
-
-
Faktura #5678 betalt
-
I dag kl. 14:30
-
-
+
+
+
+
+
+
+ Økonomi
+
+
-
-
-
-
-
Ny ordre oprettet
-
I går kl. 16:45
-
+
+
+
Ubetalte fakturaer
+
-
+
+
@@ -418,7 +459,11 @@
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
searchModal.show();
- setTimeout(() => searchInput.focus(), 300);
+ setTimeout(() => {
+ searchInput.focus();
+ loadLiveStats();
+ loadRecentActivity();
+ }, 300);
}
// 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 = `
+
+
Aktive ordrer
+
${data.sales.active_orders}
+
+
+
Månedens salg
+
${data.sales.monthly_sales.toLocaleString('da-DK')} kr
+
+ `;
+
+ // Update Support Box
+ const supportBox = document.getElementById('supportBox');
+ supportBox.innerHTML = `
+
+
Åbne sager
+
${data.support.open_tickets}
+
+
+
Gns. svartid
+
${data.support.avg_response_time} min
+
+ `;
+
+ // Update Finance Box
+ const financeBox = document.getElementById('financeBox');
+ financeBox.innerHTML = `
+
+
Ubetalte fakturaer
+
${data.finance.unpaid_invoices_count}
+
+
+
Samlet beløb
+
${data.finance.unpaid_invoices_amount.toLocaleString('da-DK')} kr
+
+ `;
+ } 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 = '
Ingen nylig aktivitet
';
+ 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 `
+
+
+
+
+
${activity.name}
+
${label} • ${timeAgo}
+
+
+
+ `;
+ }).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 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 => `
+
+ `).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
searchInput.addEventListener('input', (e) => {
@@ -444,10 +662,12 @@
if (query.length < 2) {
emptyState.style.display = 'block';
+ document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
+ selectedEntity = null;
return;
}
@@ -461,15 +681,16 @@
// CRM Results (Customers + Contacts + Vendors)
const crmSection = document.getElementById('crmResults');
const allResults = [
- ...(data.customers || []).map(c => ({...c, url: `/customers/${c.id}`, icon: 'building'})),
- ...(data.contacts || []).map(c => ({...c, url: `/contacts/${c.id}`, icon: 'person'})),
- ...(data.vendors || []).map(c => ({...c, url: `/vendors/${c.id}`, icon: 'shop'}))
+ ...(data.customers || []).map(c => ({...c, entityType: 'customer', url: `/customers/${c.id}`, icon: 'building'})),
+ ...(data.contacts || []).map(c => ({...c, entityType: 'contact', url: `/contacts/${c.id}`, icon: 'person'})),
+ ...(data.vendors || []).map(c => ({...c, entityType: 'vendor', url: `/vendors/${c.id}`, icon: 'shop'}))
];
if (allResults.length > 0) {
crmSection.style.display = 'block';
crmSection.querySelector('.result-items').innerHTML = allResults.map(item => `
-
+
@@ -480,10 +701,15 @@
${item.type} ${item.email ? '• ' + item.email : ''}
-
+
-
+
`).join('');
+
+ // Auto-select first result
+ if (allResults.length > 0) {
+ showWorkflows(allResults[0].entityType, allResults[0]);
+ }
} else {
crmSection.style.display = 'none';
emptyState.style.display = 'block';
@@ -519,7 +745,9 @@
// Reset search when modal is closed
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
searchInput.value = '';
+ selectedEntity = null;
document.getElementById('emptyState').style.display = 'block';
+ document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
diff --git a/docs/DEV_PORTAL.md b/docs/DEV_PORTAL.md
new file mode 100644
index 0000000..1164034
--- /dev/null
+++ b/docs/DEV_PORTAL.md
@@ -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: '...'
+}), '*');
+
+// 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
+ ...
+ ```
+
+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.
diff --git a/main.py b/main.py
index 8eb0183..93fb1aa 100644
--- a/main.py
+++ b/main.py
@@ -29,6 +29,8 @@ from app.billing.backend import router as billing_api
from app.system.backend import router as system_api
from app.dashboard.backend import views as dashboard_views
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
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(system_api.router, prefix="/api/v1", tags=["System"])
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
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(vendors_views.router, tags=["Frontend"])
app.include_router(settings_views.router, tags=["Frontend"])
+app.include_router(devportal_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
diff --git a/migrations/007_dev_portal.sql b/migrations/007_dev_portal.sql
new file mode 100644
index 0000000..69a68f2
--- /dev/null
+++ b/migrations/007_dev_portal.sql
@@ -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';