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:
parent
3dfc5086c0
commit
974876ac67
@ -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()
|
||||
|
||||
@ -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 []
|
||||
|
||||
292
app/devportal/backend/router.py
Normal file
292
app/devportal/backend/router.py
Normal 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 []
|
||||
}
|
||||
19
app/devportal/backend/views.py
Normal file
19
app/devportal/backend/views.py
Normal 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
|
||||
})
|
||||
214
app/devportal/frontend/editor.html
Normal file
214
app/devportal/frontend/editor.html
Normal 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 %}
|
||||
621
app/devportal/frontend/portal.html
Normal file
621
app/devportal/frontend/portal.html
Normal 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 %}
|
||||
@ -225,6 +225,7 @@
|
||||
<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="/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><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li>
|
||||
</ul>
|
||||
@ -259,13 +260,23 @@
|
||||
</div>
|
||||
|
||||
<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);">
|
||||
<!-- 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">
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="text-center py-5">
|
||||
<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>
|
||||
|
||||
<!-- CRM Results -->
|
||||
@ -330,46 +341,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Sidebar (1/4 width) -->
|
||||
<div class="col-lg-3 p-4" style="background: var(--accent-light);">
|
||||
<h6 class="text-uppercase small fw-bold mb-3" style="color: var(--accent);">
|
||||
<!-- Live Boxes Sidebar (1/4 width) -->
|
||||
<div class="col-lg-3 p-3" style="background: var(--bg-body); overflow-y: auto;">
|
||||
<!-- 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
|
||||
</h6>
|
||||
<div id="recentActivity">
|
||||
<div class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-person-circle text-primary me-2" style="font-size: 1.2rem;"></i>
|
||||
<div class="flex-grow-1">
|
||||
<p class="mb-0 small fw-bold">Advokatgruppen A/S</p>
|
||||
<p class="mb-0 small text-muted">Opdateret for 2 min siden</p>
|
||||
<div id="recentActivityList">
|
||||
<!-- Dynamic activity items -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<!-- 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 class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-ticket-detailed text-warning me-2" style="font-size: 1.2rem;"></i>
|
||||
<div class="flex-grow-1">
|
||||
<p class="mb-0 small fw-bold">Sag #1234 lukket</p>
|
||||
<p class="mb-0 small text-muted">For 15 min siden</p>
|
||||
|
||||
<!-- Support Box -->
|
||||
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 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-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 class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-receipt text-success me-2" style="font-size: 1.2rem;"></i>
|
||||
<div class="flex-grow-1">
|
||||
<p class="mb-0 small fw-bold">Faktura #5678 betalt</p>
|
||||
<p class="mb-0 small text-muted">I dag kl. 14:30</p>
|
||||
|
||||
<!-- Økonomi Box -->
|
||||
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 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-currency-dollar me-2"></i>Økonomi
|
||||
</h6>
|
||||
<i class="bi bi-arrow-up-right"></i>
|
||||
</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 class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
|
||||
<div class="d-flex align-items-start">
|
||||
<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 class="mb-2">
|
||||
<p class="mb-0 small opacity-75">Samlet beløb</p>
|
||||
<h5 class="mb-0 fw-bold">- kr</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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 = `
|
||||
<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 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
|
||||
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 => `
|
||||
<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, "'")})'>
|
||||
<div class="d-flex justify-content-between 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;">
|
||||
@ -480,10 +701,15 @@
|
||||
<p class="mb-0 small text-muted">${item.type} ${item.email ? '• ' + item.email : ''}</p>
|
||||
</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>
|
||||
</a>
|
||||
`).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';
|
||||
|
||||
229
docs/DEV_PORTAL.md
Normal file
229
docs/DEV_PORTAL.md
Normal 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.
|
||||
4
main.py
4
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")
|
||||
|
||||
108
migrations/007_dev_portal.sql
Normal file
108
migrations/007_dev_portal.sql
Normal 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';
|
||||
Loading…
Reference in New Issue
Block a user