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:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
cursor.execute(query, params or ())
|
cursor.execute(query, params or ())
|
||||||
|
|
||||||
|
# Check if this is a write operation (INSERT, UPDATE, DELETE)
|
||||||
|
query_upper = query.strip().upper()
|
||||||
|
is_write = any(query_upper.startswith(cmd) for cmd in ['INSERT', 'UPDATE', 'DELETE'])
|
||||||
|
|
||||||
if fetchone:
|
if fetchone:
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
if is_write:
|
||||||
|
conn.commit()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
else:
|
else:
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
|
if is_write:
|
||||||
|
conn.commit()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
|
|||||||
@ -122,3 +122,102 @@ async def global_search(q: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error performing global search: {e}", exc_info=True)
|
logger.error(f"❌ Error performing global search: {e}", exc_info=True)
|
||||||
return {"customers": [], "contacts": [], "vendors": []}
|
return {"customers": [], "contacts": [], "vendors": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/live-stats", response_model=Dict[str, Any])
|
||||||
|
async def get_live_stats():
|
||||||
|
"""
|
||||||
|
Get live statistics for the three live boxes: Sales, Support, Økonomi
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Sales Stats (placeholder - replace with real data when tables exist)
|
||||||
|
sales_stats = {
|
||||||
|
"active_orders": 0,
|
||||||
|
"monthly_sales": 0,
|
||||||
|
"open_quotes": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Support Stats (placeholder)
|
||||||
|
support_stats = {
|
||||||
|
"open_tickets": 0,
|
||||||
|
"avg_response_time": 0,
|
||||||
|
"today_tickets": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Finance Stats (placeholder)
|
||||||
|
finance_stats = {
|
||||||
|
"unpaid_invoices_count": 0,
|
||||||
|
"unpaid_invoices_amount": 0,
|
||||||
|
"overdue_invoices": 0,
|
||||||
|
"today_payments": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to get real customer count as a demo
|
||||||
|
try:
|
||||||
|
customer_count = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL", fetchone=True)
|
||||||
|
sales_stats["active_orders"] = customer_count.get('count', 0) if customer_count else 0
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sales": sales_stats,
|
||||||
|
"support": support_stats,
|
||||||
|
"finance": finance_stats
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching live stats: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
"sales": {"active_orders": 0, "monthly_sales": 0, "open_quotes": 0},
|
||||||
|
"support": {"open_tickets": 0, "avg_response_time": 0, "today_tickets": 0},
|
||||||
|
"finance": {"unpaid_invoices_count": 0, "unpaid_invoices_amount": 0, "overdue_invoices": 0, "today_payments": 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-activity", response_model=List[Dict[str, Any]])
|
||||||
|
async def get_recent_activity():
|
||||||
|
"""
|
||||||
|
Get recent activity across the system for the sidebar
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
activities = []
|
||||||
|
|
||||||
|
# Recent customers
|
||||||
|
recent_customers = execute_query("""
|
||||||
|
SELECT id, name, created_at, 'customer' as activity_type, 'bi-building' as icon, 'primary' as color
|
||||||
|
FROM customers
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Recent contacts
|
||||||
|
recent_contacts = execute_query("""
|
||||||
|
SELECT id, first_name || ' ' || last_name as name, created_at, 'contact' as activity_type, 'bi-person' as icon, 'success' as color
|
||||||
|
FROM contacts
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Recent vendors
|
||||||
|
recent_vendors = execute_query("""
|
||||||
|
SELECT id, name, created_at, 'vendor' as activity_type, 'bi-shop' as icon, 'info' as color
|
||||||
|
FROM vendors
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 2
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Combine all activities
|
||||||
|
if recent_customers:
|
||||||
|
activities.extend(recent_customers)
|
||||||
|
if recent_contacts:
|
||||||
|
activities.extend(recent_contacts)
|
||||||
|
if recent_vendors:
|
||||||
|
activities.extend(recent_vendors)
|
||||||
|
|
||||||
|
# Sort by created_at and limit
|
||||||
|
activities.sort(key=lambda x: x.get('created_at', ''), reverse=True)
|
||||||
|
|
||||||
|
return activities[:10]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching recent activity: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|||||||
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">
|
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
||||||
<li><a class="dropdown-item py-2" href="#">Profil</a></li>
|
<li><a class="dropdown-item py-2" href="#">Profil</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
|
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
|
||||||
|
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li>
|
<li><a class="dropdown-item py-2 text-danger" href="#">Log ud</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -259,13 +260,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0 flex-grow-1" style="overflow-y: auto;">
|
<div class="row g-0 flex-grow-1" style="overflow-y: auto;">
|
||||||
<!-- Search Results Area (3/4 width) -->
|
<!-- Search Results & Workflows (3/4 width) -->
|
||||||
<div class="col-lg-9 p-4" style="border-right: 1px solid rgba(0,0,0,0.1);">
|
<div class="col-lg-9 p-4" style="border-right: 1px solid rgba(0,0,0,0.1);">
|
||||||
|
<!-- Contextual Workflows Section -->
|
||||||
|
<div id="workflowActions" style="display: none;" class="mb-4">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold mb-3">
|
||||||
|
<i class="bi bi-lightning-charge me-2"></i>Hurtige Handlinger
|
||||||
|
</h6>
|
||||||
|
<div id="workflowButtons" class="d-flex flex-wrap gap-2">
|
||||||
|
<!-- Dynamic workflow buttons -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="searchResults">
|
<div id="searchResults">
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div id="emptyState" class="text-center py-5">
|
<div id="emptyState" class="text-center py-5">
|
||||||
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
||||||
<p class="text-muted mt-3">Begynd at skrive for at søge...</p>
|
<p class="text-muted mt-3">Tryk <kbd>⌘K</kbd> eller begynd at skrive...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CRM Results -->
|
<!-- CRM Results -->
|
||||||
@ -330,46 +341,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity Sidebar (1/4 width) -->
|
<!-- Live Boxes Sidebar (1/4 width) -->
|
||||||
<div class="col-lg-3 p-4" style="background: var(--accent-light);">
|
<div class="col-lg-3 p-3" style="background: var(--bg-body); overflow-y: auto;">
|
||||||
<h6 class="text-uppercase small fw-bold mb-3" style="color: var(--accent);">
|
<!-- Recent Activity Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h6 class="text-uppercase small fw-bold mb-3" style="color: var(--text-primary);">
|
||||||
<i class="bi bi-clock-history me-2"></i>Seneste Aktivitet
|
<i class="bi bi-clock-history me-2"></i>Seneste Aktivitet
|
||||||
</h6>
|
</h6>
|
||||||
<div id="recentActivity">
|
<div id="recentActivityList">
|
||||||
<div class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
|
<!-- Dynamic activity items -->
|
||||||
<div class="d-flex align-items-start">
|
</div>
|
||||||
<i class="bi bi-person-circle text-primary me-2" style="font-size: 1.2rem;"></i>
|
</div>
|
||||||
<div class="flex-grow-1">
|
|
||||||
<p class="mb-0 small fw-bold">Advokatgruppen A/S</p>
|
<hr class="my-3">
|
||||||
<p class="mb-0 small text-muted">Opdateret for 2 min siden</p>
|
|
||||||
|
<!-- Sales Box -->
|
||||||
|
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<h6 class="text-uppercase small fw-bold mb-0">
|
||||||
|
<i class="bi bi-cart3 me-2"></i>Sales
|
||||||
|
</h6>
|
||||||
|
<i class="bi bi-arrow-up-right"></i>
|
||||||
|
</div>
|
||||||
|
<div id="salesBox">
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Aktive ordrer</p>
|
||||||
|
<h4 class="mb-0 fw-bold">-</h4>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Månedens salg</p>
|
||||||
|
<h5 class="mb-0 fw-bold">- kr</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
|
|
||||||
<div class="d-flex align-items-start">
|
<!-- Support Box -->
|
||||||
<i class="bi bi-ticket-detailed text-warning me-2" style="font-size: 1.2rem;"></i>
|
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white;">
|
||||||
<div class="flex-grow-1">
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
<p class="mb-0 small fw-bold">Sag #1234 lukket</p>
|
<h6 class="text-uppercase small fw-bold mb-0">
|
||||||
<p class="mb-0 small text-muted">For 15 min siden</p>
|
<i class="bi bi-headset me-2"></i>Support
|
||||||
|
</h6>
|
||||||
|
<i class="bi bi-arrow-up-right"></i>
|
||||||
|
</div>
|
||||||
|
<div id="supportBox">
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Åbne sager</p>
|
||||||
|
<h4 class="mb-0 fw-bold">-</h4>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Gns. svartid</p>
|
||||||
|
<h5 class="mb-0 fw-bold">- min</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
|
|
||||||
<div class="d-flex align-items-start">
|
<!-- Økonomi Box -->
|
||||||
<i class="bi bi-receipt text-success me-2" style="font-size: 1.2rem;"></i>
|
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white;">
|
||||||
<div class="flex-grow-1">
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
<p class="mb-0 small fw-bold">Faktura #5678 betalt</p>
|
<h6 class="text-uppercase small fw-bold mb-0">
|
||||||
<p class="mb-0 small text-muted">I dag kl. 14:30</p>
|
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
||||||
|
</h6>
|
||||||
|
<i class="bi bi-arrow-up-right"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="financeBox">
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Ubetalte fakturaer</p>
|
||||||
|
<h4 class="mb-0 fw-bold">-</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mb-2">
|
||||||
<div class="activity-item mb-3 p-2 rounded" style="background: var(--bg-card);">
|
<p class="mb-0 small opacity-75">Samlet beløb</p>
|
||||||
<div class="d-flex align-items-start">
|
<h5 class="mb-0 fw-bold">- kr</h5>
|
||||||
<i class="bi bi-box-seam text-info me-2" style="font-size: 1.2rem;"></i>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<p class="mb-0 small fw-bold">Ny ordre oprettet</p>
|
|
||||||
<p class="mb-0 small text-muted">I går kl. 16:45</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -418,7 +459,11 @@
|
|||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
searchModal.show();
|
searchModal.show();
|
||||||
setTimeout(() => searchInput.focus(), 300);
|
setTimeout(() => {
|
||||||
|
searchInput.focus();
|
||||||
|
loadLiveStats();
|
||||||
|
loadRecentActivity();
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESC to close
|
// ESC to close
|
||||||
@ -427,7 +472,180 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load live statistics for the three boxes
|
||||||
|
async function loadLiveStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/dashboard/live-stats');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update Sales Box
|
||||||
|
const salesBox = document.getElementById('salesBox');
|
||||||
|
salesBox.innerHTML = `
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Aktive ordrer</p>
|
||||||
|
<h4 class="mb-0 fw-bold">${data.sales.active_orders}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Månedens salg</p>
|
||||||
|
<h5 class="mb-0 fw-bold">${data.sales.monthly_sales.toLocaleString('da-DK')} kr</h5>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Update Support Box
|
||||||
|
const supportBox = document.getElementById('supportBox');
|
||||||
|
supportBox.innerHTML = `
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Åbne sager</p>
|
||||||
|
<h4 class="mb-0 fw-bold">${data.support.open_tickets}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Gns. svartid</p>
|
||||||
|
<h5 class="mb-0 fw-bold">${data.support.avg_response_time} min</h5>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Update Finance Box
|
||||||
|
const financeBox = document.getElementById('financeBox');
|
||||||
|
financeBox.innerHTML = `
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Ubetalte fakturaer</p>
|
||||||
|
<h4 class="mb-0 fw-bold">${data.finance.unpaid_invoices_count}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<p class="mb-0 small opacity-75">Samlet beløb</p>
|
||||||
|
<h5 class="mb-0 fw-bold">${data.finance.unpaid_invoices_amount.toLocaleString('da-DK')} kr</h5>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading live stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent activity
|
||||||
|
async function loadRecentActivity() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/dashboard/recent-activity');
|
||||||
|
const activities = await response.json();
|
||||||
|
|
||||||
|
const activityList = document.getElementById('recentActivityList');
|
||||||
|
|
||||||
|
if (activities.length === 0) {
|
||||||
|
activityList.innerHTML = '<p class="small text-muted">Ingen nylig aktivitet</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activityList.innerHTML = activities.map(activity => {
|
||||||
|
const timeAgo = getTimeAgo(new Date(activity.created_at));
|
||||||
|
const label = activity.activity_type === 'customer' ? 'Kunde' :
|
||||||
|
activity.activity_type === 'contact' ? 'Kontakt' : 'Leverandør';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="activity-item mb-2 p-2 rounded" style="background: var(--bg-card); border-left: 3px solid var(--accent);">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="${activity.icon} text-${activity.color} me-2" style="font-size: 1.1rem;"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<p class="mb-0 small fw-bold" style="color: var(--text-primary);">${activity.name}</p>
|
||||||
|
<p class="mb-0 small text-muted">${label} • ${timeAgo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent activity:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format time ago
|
||||||
|
function getTimeAgo(date) {
|
||||||
|
const seconds = Math.floor((new Date() - date) / 1000);
|
||||||
|
|
||||||
|
let interval = seconds / 31536000;
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' år siden';
|
||||||
|
|
||||||
|
interval = seconds / 2592000;
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' mdr siden';
|
||||||
|
|
||||||
|
interval = seconds / 86400;
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' dage siden';
|
||||||
|
|
||||||
|
interval = seconds / 3600;
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' timer siden';
|
||||||
|
|
||||||
|
interval = seconds / 60;
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' min siden';
|
||||||
|
|
||||||
|
return 'Lige nu';
|
||||||
|
}
|
||||||
|
|
||||||
let searchTimeout;
|
let searchTimeout;
|
||||||
|
let selectedEntity = null;
|
||||||
|
|
||||||
|
// Workflow definitions per entity type
|
||||||
|
const workflows = {
|
||||||
|
customer: [
|
||||||
|
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?customer=${data.id}` },
|
||||||
|
{ label: 'Opret sag', icon: 'ticket-detailed', action: (data) => window.location.href = `/tickets/new?customer=${data.id}` },
|
||||||
|
{ label: 'Ring til kontakt', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.phone || 'Intet telefonnummer')) },
|
||||||
|
{ label: 'Vis kunde', icon: 'eye', action: (data) => window.location.href = `/customers/${data.id}` }
|
||||||
|
],
|
||||||
|
contact: [
|
||||||
|
{ label: 'Ring op', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.mobile_phone || data.phone || 'Intet telefonnummer')) },
|
||||||
|
{ label: 'Send email', icon: 'envelope', action: (data) => window.location.href = `mailto:${data.email}` },
|
||||||
|
{ label: 'Opret møde', icon: 'calendar-event', action: (data) => alert('Opret møde funktionalitet kommer snart') },
|
||||||
|
{ label: 'Vis kontakt', icon: 'eye', action: (data) => window.location.href = `/contacts/${data.id}` }
|
||||||
|
],
|
||||||
|
vendor: [
|
||||||
|
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?vendor=${data.id}` },
|
||||||
|
{ label: 'Se produkter', icon: 'box-seam', action: (data) => window.location.href = `/vendors/${data.id}/products` },
|
||||||
|
{ label: 'Vis leverandør', icon: 'eye', action: (data) => window.location.href = `/vendors/${data.id}` }
|
||||||
|
],
|
||||||
|
invoice: [
|
||||||
|
{ label: 'Vis faktura', icon: 'eye', action: (data) => window.location.href = `/invoices/${data.id}` },
|
||||||
|
{ label: 'Udskriv faktura', icon: 'printer', action: (data) => window.print() },
|
||||||
|
{ label: 'Opret kassekladde', icon: 'journal-text', action: (data) => alert('Kassekladde funktionalitet kommer snart') },
|
||||||
|
{ label: 'Opret kreditnota', icon: 'file-earmark-minus', action: (data) => window.location.href = `/invoices/${data.id}/credit-note` }
|
||||||
|
],
|
||||||
|
ticket: [
|
||||||
|
{ label: 'Åbn sag', icon: 'folder2-open', action: (data) => window.location.href = `/tickets/${data.id}` },
|
||||||
|
{ label: 'Luk sag', icon: 'check-circle', action: (data) => alert('Luk sag funktionalitet kommer snart') },
|
||||||
|
{ label: 'Tildel medarbejder', icon: 'person-plus', action: (data) => alert('Tildel funktionalitet kommer snart') }
|
||||||
|
],
|
||||||
|
rodekasse: [
|
||||||
|
{ label: 'Behandle', icon: 'pencil-square', action: (data) => window.location.href = `/rodekasse/${data.id}` },
|
||||||
|
{ label: 'Arkiver', icon: 'archive', action: (data) => alert('Arkiver funktionalitet kommer snart') },
|
||||||
|
{ label: 'Slet', icon: 'trash', action: (data) => confirm('Er du sikker?') && alert('Slet funktionalitet kommer snart') }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show contextual workflows based on entity
|
||||||
|
function showWorkflows(entityType, entityData) {
|
||||||
|
selectedEntity = entityData;
|
||||||
|
const workflowSection = document.getElementById('workflowActions');
|
||||||
|
const workflowButtons = document.getElementById('workflowButtons');
|
||||||
|
|
||||||
|
const entityWorkflows = workflows[entityType];
|
||||||
|
if (!entityWorkflows) {
|
||||||
|
workflowSection.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowButtons.innerHTML = entityWorkflows.map(wf => `
|
||||||
|
<button class="btn btn-outline-primary" onclick="executeWorkflow('${entityType}', '${wf.label}')">
|
||||||
|
<i class="bi bi-${wf.icon} me-2"></i>${wf.label}
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
workflowSection.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute workflow action
|
||||||
|
window.executeWorkflow = function(entityType, label) {
|
||||||
|
const workflow = workflows[entityType].find(w => w.label === label);
|
||||||
|
if (workflow && selectedEntity) {
|
||||||
|
workflow.action(selectedEntity);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Search function
|
// Search function
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
@ -444,10 +662,12 @@
|
|||||||
|
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
emptyState.style.display = 'block';
|
emptyState.style.display = 'block';
|
||||||
|
document.getElementById('workflowActions').style.display = 'none';
|
||||||
document.getElementById('crmResults').style.display = 'none';
|
document.getElementById('crmResults').style.display = 'none';
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
document.getElementById('supportResults').style.display = 'none';
|
||||||
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
||||||
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
||||||
|
selectedEntity = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -461,15 +681,16 @@
|
|||||||
// CRM Results (Customers + Contacts + Vendors)
|
// CRM Results (Customers + Contacts + Vendors)
|
||||||
const crmSection = document.getElementById('crmResults');
|
const crmSection = document.getElementById('crmResults');
|
||||||
const allResults = [
|
const allResults = [
|
||||||
...(data.customers || []).map(c => ({...c, url: `/customers/${c.id}`, icon: 'building'})),
|
...(data.customers || []).map(c => ({...c, entityType: 'customer', url: `/customers/${c.id}`, icon: 'building'})),
|
||||||
...(data.contacts || []).map(c => ({...c, url: `/contacts/${c.id}`, icon: 'person'})),
|
...(data.contacts || []).map(c => ({...c, entityType: 'contact', url: `/contacts/${c.id}`, icon: 'person'})),
|
||||||
...(data.vendors || []).map(c => ({...c, url: `/vendors/${c.id}`, icon: 'shop'}))
|
...(data.vendors || []).map(c => ({...c, entityType: 'vendor', url: `/vendors/${c.id}`, icon: 'shop'}))
|
||||||
];
|
];
|
||||||
|
|
||||||
if (allResults.length > 0) {
|
if (allResults.length > 0) {
|
||||||
crmSection.style.display = 'block';
|
crmSection.style.display = 'block';
|
||||||
crmSection.querySelector('.result-items').innerHTML = allResults.map(item => `
|
crmSection.querySelector('.result-items').innerHTML = allResults.map(item => `
|
||||||
<a href="${item.url}" class="result-item d-block p-3 mb-2 rounded text-decoration-none" style="background: var(--bg-card); border: 1px solid transparent; transition: all 0.2s;">
|
<div class="result-item p-3 mb-2 rounded" style="background: var(--bg-card); border: 1px solid transparent; transition: all 0.2s; cursor: pointer;"
|
||||||
|
onclick='showWorkflows("${item.entityType}", ${JSON.stringify(item).replace(/'/g, "'")})'>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
|
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
|
||||||
@ -480,10 +701,15 @@
|
|||||||
<p class="mb-0 small text-muted">${item.type} ${item.email ? '• ' + item.email : ''}</p>
|
<p class="mb-0 small text-muted">${item.type} ${item.email ? '• ' + item.email : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-arrow-right" style="color: var(--accent);"></i>
|
<i class="bi bi-chevron-right" style="color: var(--accent);"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
// Auto-select first result
|
||||||
|
if (allResults.length > 0) {
|
||||||
|
showWorkflows(allResults[0].entityType, allResults[0]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
crmSection.style.display = 'none';
|
crmSection.style.display = 'none';
|
||||||
emptyState.style.display = 'block';
|
emptyState.style.display = 'block';
|
||||||
@ -519,7 +745,9 @@
|
|||||||
// Reset search when modal is closed
|
// Reset search when modal is closed
|
||||||
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
|
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
|
selectedEntity = null;
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
document.getElementById('emptyState').style.display = 'block';
|
||||||
|
document.getElementById('workflowActions').style.display = 'none';
|
||||||
document.getElementById('crmResults').style.display = 'none';
|
document.getElementById('crmResults').style.display = 'none';
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
document.getElementById('supportResults').style.display = 'none';
|
||||||
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
||||||
|
|||||||
229
docs/DEV_PORTAL.md
Normal file
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.system.backend import router as system_api
|
||||||
from app.dashboard.backend import views as dashboard_views
|
from app.dashboard.backend import views as dashboard_views
|
||||||
from app.dashboard.backend import router as dashboard_api
|
from app.dashboard.backend import router as dashboard_api
|
||||||
|
from app.devportal.backend import router as devportal_api
|
||||||
|
from app.devportal.backend import views as devportal_views
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -99,6 +101,7 @@ app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"])
|
|||||||
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
||||||
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
||||||
app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
|
app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
|
||||||
|
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV Portal"])
|
||||||
|
|
||||||
# Frontend Routers
|
# Frontend Routers
|
||||||
app.include_router(auth_views.router, tags=["Frontend"])
|
app.include_router(auth_views.router, tags=["Frontend"])
|
||||||
@ -107,6 +110,7 @@ app.include_router(customers_views.router, tags=["Frontend"])
|
|||||||
app.include_router(contacts_views.router, tags=["Frontend"])
|
app.include_router(contacts_views.router, tags=["Frontend"])
|
||||||
app.include_router(vendors_views.router, tags=["Frontend"])
|
app.include_router(vendors_views.router, tags=["Frontend"])
|
||||||
app.include_router(settings_views.router, tags=["Frontend"])
|
app.include_router(settings_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(devportal_views.router, tags=["Frontend"])
|
||||||
|
|
||||||
# Serve static files (UI)
|
# Serve static files (UI)
|
||||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||||
|
|||||||
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