From 974876ac67ff5f8c003335db1b848434293c6f67 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 6 Dec 2025 21:27:47 +0100 Subject: [PATCH] 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 --- app/core/database.py | 9 + app/dashboard/backend/router.py | 99 +++++ app/devportal/backend/router.py | 292 ++++++++++++++ app/devportal/backend/views.py | 19 + app/devportal/frontend/editor.html | 214 ++++++++++ app/devportal/frontend/portal.html | 621 +++++++++++++++++++++++++++++ app/shared/frontend/base.html | 316 +++++++++++++-- docs/DEV_PORTAL.md | 229 +++++++++++ main.py | 4 + migrations/007_dev_portal.sql | 108 +++++ 10 files changed, 1867 insertions(+), 44 deletions(-) create mode 100644 app/devportal/backend/router.py create mode 100644 app/devportal/backend/views.py create mode 100644 app/devportal/frontend/editor.html create mode 100644 app/devportal/frontend/portal.html create mode 100644 docs/DEV_PORTAL.md create mode 100644 migrations/007_dev_portal.sql 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 %} +
+ + Tilbage til DEV Portal + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+ +{% 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

+
+
+ +
+
+ + +
+
+
+
+
+

Features

+

-

+
+ +
+
+
+
+
+
+
+

Idéer

+

-

+
+ +
+
+
+
+
+
+
+

Workflows

+

-

+
+ +
+
+
+
+
+
+
+

I Gang

+

-

+
+ +
+
+
+
+ + + + + +
+ +
+ +
+
+ + + + +
+
+ + +
+
+
+
📋 Planlagt
+
+
+
+
+
+
⚙️ I Gang
+
+
+
+
+
+
✅ Færdig
+
+
+
+
+
+
⏸️ På Pause
+
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+
+ + + + + + + +{% 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 @@
- +
+ + +
-

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 +
+ +
+
+
+

Aktive ordrer

+

-

+
+
+

Månedens salg

+
- kr
-
-
- -
-

Sag #1234 lukket

-

For 15 min siden

-
+
+ + +
+
+
+ Support +
+ +
+
+
+

Åbne sager

+

-

+
+
+

Gns. svartid

+
- min
-
-
- -
-

Faktura #5678 betalt

-

I dag kl. 14:30

-
-
+
+ + +
+
+
+ Økonomi +
+
-
-
- -
-

Ny ordre oprettet

-

I går kl. 16:45

-
+
+
+

Ubetalte fakturaer

+

-

+
+
+

Samlet beløb

+
- kr
@@ -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 => ` - + `).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';