Add local pipeline opportunities

This commit is contained in:
Christian 2026-01-28 07:48:10 +01:00
parent 262fa80aef
commit c2a265d5f9
16 changed files with 1591 additions and 20 deletions

View File

@ -1 +1 @@
1.3.151 1.3.152

View File

@ -500,6 +500,41 @@
</div> </div>
</div> </div>
<!-- Pipeline Tab -->
<div class="tab-pane fade" id="pipeline">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Kunde pipeline</h5>
<small class="text-muted">Muligheder knyttet til kunden</small>
</div>
<button class="btn btn-primary btn-sm" onclick="openCustomerOpportunityModal()">
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Titel</th>
<th>Beløb</th>
<th>Stage</th>
<th>Sandsynlighed</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody id="customerOpportunitiesTable">
<tr>
<td colspan="5" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Billing Matrix Tab --> <!-- Billing Matrix Tab -->
<div class="tab-pane fade" id="billing-matrix"> <div class="tab-pane fade" id="billing-matrix">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
@ -767,12 +802,63 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Customer Opportunity Modal -->
<div class="modal fade" id="customerOpportunityModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret mulighed</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="customerOpportunityForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="customerOpportunityTitle" required>
</div>
<div class="col-md-6">
<label class="form-label">Stage</label>
<select class="form-select" id="customerOpportunityStage"></select>
</div>
<div class="col-md-6">
<label class="form-label">Beløb</label>
<input type="number" step="0.01" class="form-control" id="customerOpportunityAmount">
</div>
<div class="col-md-6">
<label class="form-label">Valuta</label>
<select class="form-select" id="customerOpportunityCurrency">
<option value="DKK">DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Forventet lukning</label>
<input type="date" class="form-control" id="customerOpportunityCloseDate">
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="customerOpportunityDescription" rows="3"></textarea>
</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="createCustomerOpportunity()">Gem</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
const customerId = parseInt(window.location.pathname.split('/').pop()); const customerId = parseInt(window.location.pathname.split('/').pop());
let customerData = null; let customerData = null;
let pipelineStages = [];
let eventListenersAdded = false; let eventListenersAdded = false;
@ -800,6 +886,14 @@ document.addEventListener('DOMContentLoaded', () => {
loadInternalComment(); loadInternalComment();
}, { once: false }); }, { once: false });
} }
// Load pipeline when tab is shown
const pipelineTab = document.querySelector('a[href="#pipeline"]');
if (pipelineTab) {
pipelineTab.addEventListener('shown.bs.tab', () => {
loadCustomerPipeline();
}, { once: false });
}
// Load activity when tab is shown // Load activity when tab is shown
const activityTab = document.querySelector('a[href="#activity"]'); const activityTab = document.querySelector('a[href="#activity"]');
@ -1014,6 +1108,87 @@ async function loadSubscriptions() {
} }
} }
async function loadPipelineStages() {
if (pipelineStages.length > 0) return;
const response = await fetch('/api/v1/pipeline/stages');
pipelineStages = await response.json();
const select = document.getElementById('customerOpportunityStage');
if (select) {
select.innerHTML = pipelineStages.map(stage => `<option value="${stage.id}">${stage.name}</option>`).join('');
}
}
async function loadCustomerPipeline() {
await loadPipelineStages();
const response = await fetch(`/api/v1/opportunities?customer_id=${customerId}`);
const opportunities = await response.json();
renderCustomerPipeline(opportunities);
}
function renderCustomerPipeline(opportunities) {
const tbody = document.getElementById('customerOpportunitiesTable');
if (!opportunities || opportunities.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Ingen muligheder endnu</td></tr>';
return;
}
tbody.innerHTML = opportunities.map(o => `
<tr>
<td class="fw-semibold">${escapeHtml(o.title)}</td>
<td>${formatCurrency(o.amount, o.currency)}</td>
<td>
<span class="badge" style="background:${o.stage_color || '#0f4c75'}; color: white;">${escapeHtml(o.stage_name || '-')}</span>
</td>
<td>${o.probability || 0}%</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="window.location.href='/opportunities/${o.id}'">
<i class="bi bi-arrow-right"></i>
</button>
</td>
</tr>
`).join('');
}
function openCustomerOpportunityModal() {
const form = document.getElementById('customerOpportunityForm');
if (form) form.reset();
loadPipelineStages();
const modal = new bootstrap.Modal(document.getElementById('customerOpportunityModal'));
modal.show();
}
async function createCustomerOpportunity() {
const payload = {
customer_id: customerId,
title: document.getElementById('customerOpportunityTitle').value,
description: document.getElementById('customerOpportunityDescription').value || null,
amount: parseFloat(document.getElementById('customerOpportunityAmount').value || 0),
currency: document.getElementById('customerOpportunityCurrency').value || 'DKK',
stage_id: parseInt(document.getElementById('customerOpportunityStage').value || 0) || null,
expected_close_date: document.getElementById('customerOpportunityCloseDate').value || null
};
if (!payload.title) {
alert('Titel er påkrævet');
return;
}
const response = await fetch('/api/v1/opportunities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
alert('Kunne ikke oprette mulighed');
return;
}
bootstrap.Modal.getInstance(document.getElementById('customerOpportunityModal')).hide();
loadCustomerPipeline();
}
function displaySubscriptions(data) { function displaySubscriptions(data) {
const container = document.getElementById('subscriptionsContainer'); const container = document.getElementById('subscriptionsContainer');
const { recurring_orders, sales_orders, subscriptions, expired_subscriptions, bmc_office_subscriptions } = data; const { recurring_orders, sales_orders, subscriptions, expired_subscriptions, bmc_office_subscriptions } = data;
@ -1060,13 +1235,6 @@ function displaySubscriptions(data) {
</div> </div>
</div> </div>
<!-- Pipeline Tab -->
<div class="tab-pane fade" id="pipeline">
<h5 class="fw-bold mb-4">Kunde pipeline</h5>
<div class="text-muted text-center py-5">
Kunde pipeline kommer snart...
</div>
</div>
`; `;
// Column 2: Sales Orders // Column 2: Sales Orders
@ -1488,6 +1656,11 @@ function formatDate(dateStr) {
} }
} }
function formatCurrency(value, currency) {
const num = parseFloat(value || 0);
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
}
async function loadActivity() { async function loadActivity() {
const container = document.getElementById('activityContainer'); const container = document.getElementById('activityContainer');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>'; container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';

View File

@ -2,21 +2,262 @@
{% block title %}Pipeline - BMC Hub{% endblock %} {% block title %}Pipeline - BMC Hub{% endblock %}
{% block content %} {% block extra_css %}
<div class="container-fluid"> <style>
<div class="d-flex justify-content-between align-items-center mb-4"> .pipeline-board {
<div> display: grid;
<h2 class="fw-bold mb-1">Pipeline</h2> grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
<p class="text-muted mb-0">Salgspipeline pr. kunde</p> gap: 1rem;
</div> }
</div>
<div class="card shadow-sm"> .pipeline-column {
<div class="card-body text-center py-5"> background: var(--bg-card);
<div class="text-muted"> border: 1px solid rgba(0,0,0,0.06);
Pipeline kommer snart... border-radius: 12px;
padding: 0.75rem;
min-height: 300px;
}
.pipeline-card {
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
padding: 0.75rem;
background: white;
margin-bottom: 0.75rem;
}
.pipeline-card h6 {
margin-bottom: 0.25rem;
font-weight: 700;
}
.pipeline-meta {
font-size: 0.85rem;
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="fw-bold mb-1">Pipeline</h2>
<p class="text-muted mb-0">Salgspipeline pr. kunde</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary" href="/opportunities">
<i class="bi bi-table me-2"></i>Listevisning
</a>
<button class="btn btn-primary" onclick="openCreateOpportunityModal()">
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
</button>
</div>
</div>
<div class="pipeline-board" id="pipelineBoard">
<div class="text-center py-5">
<div class="spinner-border text-primary"></div>
</div>
</div>
<!-- Create Opportunity Modal -->
<div class="modal fade" id="opportunityModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret mulighed</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="opportunityForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde *</label>
<select class="form-select" id="customerId" required></select>
</div>
<div class="col-md-6">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="title" required>
</div>
<div class="col-md-6">
<label class="form-label">Beløb</label>
<input type="number" step="0.01" class="form-control" id="amount">
</div>
<div class="col-md-6">
<label class="form-label">Valuta</label>
<select class="form-select" id="currency">
<option value="DKK">DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Stage</label>
<select class="form-select" id="stageId"></select>
</div>
<div class="col-md-6">
<label class="form-label">Forventet lukning</label>
<input type="date" class="form-control" id="expectedCloseDate">
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="description" rows="3"></textarea>
</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="createOpportunity()">Gem</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
let opportunities = [];
let stages = [];
let customers = [];
document.addEventListener('DOMContentLoaded', async () => {
await loadStages();
await loadCustomers();
await loadOpportunities();
});
async function loadStages() {
const response = await fetch('/api/v1/pipeline/stages');
stages = await response.json();
const stageSelect = document.getElementById('stageId');
stageSelect.innerHTML = stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
}
async function loadCustomers() {
const response = await fetch('/api/v1/customers?limit=10000');
const data = await response.json();
customers = Array.isArray(data) ? data : (data.customers || []);
const select = document.getElementById('customerId');
select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
}
async function loadOpportunities() {
const response = await fetch('/api/v1/opportunities');
opportunities = await response.json();
renderBoard();
}
function renderBoard() {
const board = document.getElementById('pipelineBoard');
if (!stages.length) {
board.innerHTML = '<div class="text-muted">Ingen stages fundet</div>';
return;
}
board.innerHTML = stages.map(stage => {
const items = opportunities.filter(o => o.stage_id === stage.id);
const cards = items.map(o => `
<div class="pipeline-card">
<div class="d-flex justify-content-between align-items-start">
<h6>${escapeHtml(o.title)}</h6>
<span class="badge" style="background:${stage.color}; color: white;">${o.probability || 0}%</span>
</div>
<div class="pipeline-meta">${escapeHtml(o.customer_name || '-')}
· ${formatCurrency(o.amount, o.currency)}
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<select class="form-select form-select-sm" onchange="changeStage(${o.id}, this.value)">
${stages.map(s => `<option value="${s.id}" ${s.id === o.stage_id ? 'selected' : ''}>${s.name}</option>`).join('')}
</select>
<button class="btn btn-sm btn-outline-primary ms-2" onclick="goToDetail(${o.id})">
<i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
`).join('');
return `
<div class="pipeline-column">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>${stage.name}</strong>
<span class="small text-muted">${items.length}</span>
</div>
${cards || '<div class="text-muted small">Ingen muligheder</div>'}
</div>
`;
}).join('');
}
async function changeStage(opportunityId, stageId) {
const response = await fetch(`/api/v1/opportunities/${opportunityId}/stage`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_id: parseInt(stageId) })
});
if (!response.ok) {
alert('Kunne ikke opdatere stage');
return;
}
await loadOpportunities();
}
function openCreateOpportunityModal() {
document.getElementById('opportunityForm').reset();
const modal = new bootstrap.Modal(document.getElementById('opportunityModal'));
modal.show();
}
async function createOpportunity() {
const payload = {
customer_id: parseInt(document.getElementById('customerId').value),
title: document.getElementById('title').value,
description: document.getElementById('description').value || null,
amount: parseFloat(document.getElementById('amount').value || 0),
currency: document.getElementById('currency').value,
stage_id: parseInt(document.getElementById('stageId').value || 0) || null,
expected_close_date: document.getElementById('expectedCloseDate').value || null
};
if (!payload.customer_id || !payload.title) {
alert('Kunde og titel er påkrævet');
return;
}
const response = await fetch('/api/v1/opportunities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
alert('Kunne ikke oprette mulighed');
return;
}
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
await loadOpportunities();
}
function goToDetail(id) {
window.location.href = `/opportunities/${id}`;
}
function formatCurrency(value, currency) {
const num = parseFloat(value || 0);
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
{% endblock %}

View File

View File

View File

@ -0,0 +1,307 @@
"""
Opportunities (Pipeline) Router
Hub-local sales pipeline
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List
from datetime import date
import logging
from app.core.database import execute_query, execute_query_single, execute_update
from app.services.opportunity_service import handle_stage_change
logger = logging.getLogger(__name__)
router = APIRouter()
class PipelineStageBase(BaseModel):
name: str
description: Optional[str] = None
sort_order: int = 0
default_probability: int = 0
color: Optional[str] = "#0f4c75"
is_won: bool = False
is_lost: bool = False
is_active: bool = True
class PipelineStageCreate(PipelineStageBase):
pass
class PipelineStageUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
sort_order: Optional[int] = None
default_probability: Optional[int] = None
color: Optional[str] = None
is_won: Optional[bool] = None
is_lost: Optional[bool] = None
is_active: Optional[bool] = None
class OpportunityBase(BaseModel):
customer_id: int
title: str
description: Optional[str] = None
amount: Optional[float] = 0
currency: Optional[str] = "DKK"
expected_close_date: Optional[date] = None
stage_id: Optional[int] = None
owner_user_id: Optional[int] = None
class OpportunityCreate(OpportunityBase):
pass
class OpportunityUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
amount: Optional[float] = None
currency: Optional[str] = None
expected_close_date: Optional[date] = None
stage_id: Optional[int] = None
owner_user_id: Optional[int] = None
is_active: Optional[bool] = None
class OpportunityStageUpdate(BaseModel):
stage_id: int
note: Optional[str] = None
user_id: Optional[int] = None
def _get_stage(stage_id: int):
stage = execute_query_single(
"SELECT * FROM pipeline_stages WHERE id = %s AND is_active = TRUE",
(stage_id,)
)
if not stage:
raise HTTPException(status_code=404, detail="Stage not found")
return stage
def _get_default_stage():
stage = execute_query_single(
"SELECT * FROM pipeline_stages WHERE is_active = TRUE ORDER BY sort_order ASC LIMIT 1"
)
if not stage:
raise HTTPException(status_code=400, detail="No active stages configured")
return stage
def _get_opportunity(opportunity_id: int):
query = """
SELECT o.*, c.name AS customer_name,
s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost
FROM pipeline_opportunities o
JOIN customers c ON c.id = o.customer_id
JOIN pipeline_stages s ON s.id = o.stage_id
WHERE o.id = %s
"""
opportunity = execute_query_single(query, (opportunity_id,))
if not opportunity:
raise HTTPException(status_code=404, detail="Opportunity not found")
return opportunity
def _insert_stage_history(opportunity_id: int, from_stage_id: Optional[int], to_stage_id: int,
user_id: Optional[int] = None, note: Optional[str] = None):
execute_query(
"""
INSERT INTO pipeline_stage_history (opportunity_id, from_stage_id, to_stage_id, changed_by_user_id, note)
VALUES (%s, %s, %s, %s, %s)
""",
(opportunity_id, from_stage_id, to_stage_id, user_id, note)
)
# ============================
# Pipeline Stages
# ============================
@router.get("/pipeline/stages", tags=["Pipeline Stages"])
async def list_stages():
query = "SELECT * FROM pipeline_stages WHERE is_active = TRUE ORDER BY sort_order ASC"
return execute_query(query) or []
@router.post("/pipeline/stages", tags=["Pipeline Stages"])
async def create_stage(stage: PipelineStageCreate):
query = """
INSERT INTO pipeline_stages
(name, description, sort_order, default_probability, color, is_won, is_lost, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query(query, (
stage.name,
stage.description,
stage.sort_order,
stage.default_probability,
stage.color,
stage.is_won,
stage.is_lost,
stage.is_active
))
logger.info("✅ Created pipeline stage: %s", stage.name)
return result[0] if result else None
@router.put("/pipeline/stages/{stage_id}", tags=["Pipeline Stages"])
async def update_stage(stage_id: int, stage: PipelineStageUpdate):
updates = []
params = []
for field, value in stage.dict(exclude_unset=True).items():
updates.append(f"{field} = %s")
params.append(value)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(stage_id)
query = f"UPDATE pipeline_stages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING *"
result = execute_query(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Stage not found")
logger.info("✅ Updated pipeline stage: %s", stage_id)
return result[0]
@router.delete("/pipeline/stages/{stage_id}", tags=["Pipeline Stages"])
async def deactivate_stage(stage_id: int):
affected = execute_update(
"UPDATE pipeline_stages SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(stage_id,)
)
if not affected:
raise HTTPException(status_code=404, detail="Stage not found")
logger.info("⚠️ Deactivated pipeline stage: %s", stage_id)
return {"status": "success", "stage_id": stage_id}
# ============================
# Opportunities
# ============================
@router.get("/opportunities", tags=["Opportunities"])
async def list_opportunities(customer_id: Optional[int] = None, stage_id: Optional[int] = None):
query = """
SELECT o.*, c.name AS customer_name,
s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost
FROM pipeline_opportunities o
JOIN customers c ON c.id = o.customer_id
JOIN pipeline_stages s ON s.id = o.stage_id
WHERE o.is_active = TRUE
"""
params: List = []
if customer_id is not None:
query += " AND o.customer_id = %s"
params.append(customer_id)
if stage_id is not None:
query += " AND o.stage_id = %s"
params.append(stage_id)
query += " ORDER BY o.updated_at DESC NULLS LAST, o.created_at DESC"
if params:
return execute_query(query, tuple(params)) or []
return execute_query(query) or []
@router.get("/opportunities/{opportunity_id}", tags=["Opportunities"])
async def get_opportunity(opportunity_id: int):
return _get_opportunity(opportunity_id)
@router.post("/opportunities", tags=["Opportunities"])
async def create_opportunity(opportunity: OpportunityCreate):
stage = _get_stage(opportunity.stage_id) if opportunity.stage_id else _get_default_stage()
probability = stage["default_probability"]
query = """
INSERT INTO pipeline_opportunities
(customer_id, title, description, amount, currency, expected_close_date, stage_id, probability, owner_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
result = execute_query(query, (
opportunity.customer_id,
opportunity.title,
opportunity.description,
opportunity.amount or 0,
opportunity.currency or "DKK",
opportunity.expected_close_date,
stage["id"],
probability,
opportunity.owner_user_id
))
if not result:
raise HTTPException(status_code=500, detail="Failed to create opportunity")
opportunity_id = result[0]["id"]
_insert_stage_history(opportunity_id, None, stage["id"], opportunity.owner_user_id, "Oprettet")
logger.info("✅ Created opportunity %s", opportunity_id)
return _get_opportunity(opportunity_id)
@router.put("/opportunities/{opportunity_id}", tags=["Opportunities"])
async def update_opportunity(opportunity_id: int, update: OpportunityUpdate):
existing = _get_opportunity(opportunity_id)
updates = []
params = []
update_dict = update.dict(exclude_unset=True)
stage_changed = False
new_stage = None
if "stage_id" in update_dict:
new_stage = _get_stage(update_dict["stage_id"])
update_dict["probability"] = new_stage["default_probability"]
stage_changed = new_stage["id"] != existing["stage_id"]
for field, value in update_dict.items():
updates.append(f"{field} = %s")
params.append(value)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(opportunity_id)
query = f"UPDATE pipeline_opportunities SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
execute_update(query, tuple(params))
if stage_changed and new_stage:
_insert_stage_history(opportunity_id, existing["stage_id"], new_stage["id"], update.owner_user_id, "Stage ændret")
updated = _get_opportunity(opportunity_id)
handle_stage_change(updated, new_stage)
return _get_opportunity(opportunity_id)
@router.patch("/opportunities/{opportunity_id}/stage", tags=["Opportunities"])
async def update_opportunity_stage(opportunity_id: int, update: OpportunityStageUpdate):
existing = _get_opportunity(opportunity_id)
new_stage = _get_stage(update.stage_id)
execute_update(
"""
UPDATE pipeline_opportunities
SET stage_id = %s,
probability = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(new_stage["id"], new_stage["default_probability"], opportunity_id)
)
_insert_stage_history(opportunity_id, existing["stage_id"], new_stage["id"], update.user_id, update.note)
updated = _get_opportunity(opportunity_id)
handle_stage_change(updated, new_stage)
return updated

View File

View File

@ -0,0 +1,289 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Muligheder - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.stage-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
background: rgba(15, 76, 117, 0.1);
color: var(--accent);
}
.stage-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="fw-bold mb-1">Muligheder</h2>
<p class="text-muted mb-0">Hublokal salgspipeline</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary" href="/pipeline">
<i class="bi bi-kanban me-2"></i>Sales Board
</a>
<button class="btn btn-primary" onclick="openCreateOpportunityModal()">
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
</button>
</div>
</div>
<div class="card p-3 mb-4">
<div class="row g-2 align-items-center">
<div class="col-md-4">
<input type="text" class="form-control" id="searchInput" placeholder="Søg titel eller kunde..." oninput="renderOpportunities()">
</div>
<div class="col-md-3">
<select class="form-select" id="stageFilter" onchange="renderOpportunities()"></select>
</div>
<div class="col-md-3">
<select class="form-select" id="statusFilter" onchange="renderOpportunities()">
<option value="all">Alle status</option>
<option value="open">Åbne</option>
<option value="won">Vundet</option>
<option value="lost">Tabt</option>
</select>
</div>
<div class="col-md-2 text-end">
<span class="text-muted small" id="countLabel">0 muligheder</span>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Titel</th>
<th>Kunde</th>
<th>Beløb</th>
<th>Lukningsdato</th>
<th>Stage</th>
<th>Sandsynlighed</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody id="opportunitiesTable">
<tr>
<td colspan="7" class="text-center py-5">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Opportunity Modal -->
<div class="modal fade" id="opportunityModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret mulighed</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="opportunityForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde *</label>
<select class="form-select" id="customerId" required></select>
</div>
<div class="col-md-6">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="title" required>
</div>
<div class="col-md-6">
<label class="form-label">Beløb</label>
<input type="number" step="0.01" class="form-control" id="amount">
</div>
<div class="col-md-6">
<label class="form-label">Valuta</label>
<select class="form-select" id="currency">
<option value="DKK">DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Stage</label>
<select class="form-select" id="stageId"></select>
</div>
<div class="col-md-6">
<label class="form-label">Forventet lukning</label>
<input type="date" class="form-control" id="expectedCloseDate">
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="description" rows="3"></textarea>
</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="createOpportunity()">Gem</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let opportunities = [];
let stages = [];
let customers = [];
document.addEventListener('DOMContentLoaded', async () => {
await loadStages();
await loadCustomers();
await loadOpportunities();
});
async function loadStages() {
const response = await fetch('/api/v1/pipeline/stages');
stages = await response.json();
const stageFilter = document.getElementById('stageFilter');
stageFilter.innerHTML = '<option value="all">Alle stages</option>' +
stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
const stageSelect = document.getElementById('stageId');
stageSelect.innerHTML = stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
}
async function loadCustomers() {
const response = await fetch('/api/v1/customers?limit=10000');
const data = await response.json();
customers = Array.isArray(data) ? data : (data.customers || []);
const select = document.getElementById('customerId');
select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
}
async function loadOpportunities() {
const response = await fetch('/api/v1/opportunities');
opportunities = await response.json();
renderOpportunities();
}
function renderOpportunities() {
const search = document.getElementById('searchInput').value.toLowerCase();
const stageFilter = document.getElementById('stageFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
const filtered = opportunities.filter(o => {
const text = `${o.title} ${o.customer_name}`.toLowerCase();
if (search && !text.includes(search)) return false;
if (stageFilter !== 'all' && parseInt(stageFilter) !== o.stage_id) return false;
if (statusFilter === 'won' && !o.is_won) return false;
if (statusFilter === 'lost' && !o.is_lost) return false;
if (statusFilter === 'open' && (o.is_won || o.is_lost)) return false;
return true;
});
const tbody = document.getElementById('opportunitiesTable');
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-5">Ingen muligheder fundet</td></tr>';
document.getElementById('countLabel').textContent = '0 muligheder';
return;
}
tbody.innerHTML = filtered.map(o => `
<tr>
<td class="fw-semibold">${escapeHtml(o.title)}</td>
<td>${escapeHtml(o.customer_name || '-')}
</td>
<td>${formatCurrency(o.amount, o.currency)}</td>
<td>${o.expected_close_date ? formatDate(o.expected_close_date) : '<span class="text-muted">-</span>'}</td>
<td>
<span class="stage-pill">
<span class="stage-dot" style="background:${o.stage_color || '#0f4c75'}"></span>
${escapeHtml(o.stage_name || '-')}
</span>
</td>
<td>${o.probability || 0}%</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="goToDetail(${o.id})">
<i class="bi bi-arrow-right"></i>
</button>
</td>
</tr>
`).join('');
document.getElementById('countLabel').textContent = `${filtered.length} muligheder`;
}
function openCreateOpportunityModal() {
document.getElementById('opportunityForm').reset();
const modal = new bootstrap.Modal(document.getElementById('opportunityModal'));
modal.show();
}
async function createOpportunity() {
const payload = {
customer_id: parseInt(document.getElementById('customerId').value),
title: document.getElementById('title').value,
description: document.getElementById('description').value || null,
amount: parseFloat(document.getElementById('amount').value || 0),
currency: document.getElementById('currency').value,
stage_id: parseInt(document.getElementById('stageId').value || 0) || null,
expected_close_date: document.getElementById('expectedCloseDate').value || null
};
if (!payload.customer_id || !payload.title) {
alert('Kunde og titel er påkrævet');
return;
}
const response = await fetch('/api/v1/opportunities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
alert('Kunne ikke oprette mulighed');
return;
}
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
await loadOpportunities();
}
function goToDetail(id) {
window.location.href = `/opportunities/${id}`;
}
function formatCurrency(value, currency) {
const num = parseFloat(value || 0);
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
}
function formatDate(value) {
return new Date(value).toLocaleDateString('da-DK');
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
{% endblock %}

View File

@ -0,0 +1,216 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Mulighed - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.detail-grid {
display: grid;
grid-template-columns: 1.2fr 1.2fr 0.8fr;
gap: 1.5rem;
}
.sticky-panel {
position: sticky;
top: 90px;
}
.section-card {
border: 1px solid rgba(0,0,0,0.06);
border-radius: 12px;
padding: 1.25rem;
background: var(--bg-card);
}
.section-title {
font-weight: 700;
margin-bottom: 1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="fw-bold mb-1" id="pageTitle">Mulighed</h2>
<p class="text-muted mb-0">Detaljeret pipelinevisning</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="/opportunities">
<i class="bi bi-arrow-left me-2"></i>Tilbage
</a>
<button class="btn btn-primary" onclick="saveOpportunity()">
<i class="bi bi-check-lg me-2"></i>Gem
</button>
</div>
</div>
<div class="detail-grid">
<div class="d-flex flex-column gap-3">
<div class="section-card">
<div class="section-title">Grundoplysninger</div>
<div class="mb-3">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="title" required>
</div>
<div class="mb-3">
<label class="form-label">Kunde</label>
<input type="text" class="form-control" id="customerName" disabled>
</div>
<div class="mb-0">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="description" rows="4"></textarea>
</div>
</div>
<div class="section-card">
<div class="section-title">Salgsstatus</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Stage</label>
<select class="form-select" id="stageId"></select>
</div>
<div class="col-md-6">
<label class="form-label">Sandsynlighed</label>
<input type="text" class="form-control" id="probability" disabled>
</div>
<div class="col-md-6">
<label class="form-label">Forventet lukning</label>
<input type="date" class="form-control" id="expectedCloseDate">
</div>
</div>
</div>
</div>
<div class="d-flex flex-column gap-3">
<div class="section-card">
<div class="section-title">Løsning & Salgsdetaljer</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Beløb</label>
<input type="number" step="0.01" class="form-control" id="amount">
</div>
<div class="col-md-6">
<label class="form-label">Valuta</label>
<select class="form-select" id="currency">
<option value="DKK">DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
</div>
</div>
<div class="section-card">
<div class="section-title">Tilbud & Kontrakt</div>
<div class="text-muted small">Felt til dokumentlink og kontraktstatus kommer i næste version.</div>
</div>
</div>
<div class="sticky-panel d-flex flex-column gap-3">
<div class="section-card">
<div class="section-title">Pipelinestatus</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Kunde</span>
<span id="customerNameBadge">-</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Stage</span>
<span id="stageBadge">-</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Værdi</span>
<span id="amountBadge">-</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-muted">Sandsynlighed</span>
<span id="probabilityBadge">-</span>
</div>
</div>
<div class="section-card">
<div class="section-title">Næste aktivitet</div>
<div class="text-muted small">Aktivitetsmodul kommer senere.</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const opportunityId = parseInt(window.location.pathname.split('/').pop());
let stages = [];
let opportunity = null;
document.addEventListener('DOMContentLoaded', async () => {
await loadStages();
await loadOpportunity();
});
async function loadStages() {
const response = await fetch('/api/v1/pipeline/stages');
stages = await response.json();
const select = document.getElementById('stageId');
select.innerHTML = stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
}
async function loadOpportunity() {
const response = await fetch(`/api/v1/opportunities/${opportunityId}`);
if (!response.ok) {
alert('Mulighed ikke fundet');
window.location.href = '/opportunities';
return;
}
opportunity = await response.json();
renderOpportunity();
}
function renderOpportunity() {
document.getElementById('pageTitle').textContent = opportunity.title;
document.getElementById('title').value = opportunity.title;
document.getElementById('customerName').value = opportunity.customer_name || '-';
document.getElementById('description').value = opportunity.description || '';
document.getElementById('amount').value = opportunity.amount || 0;
document.getElementById('currency').value = opportunity.currency || 'DKK';
document.getElementById('expectedCloseDate').value = opportunity.expected_close_date || '';
document.getElementById('stageId').value = opportunity.stage_id;
document.getElementById('probability').value = `${opportunity.probability || 0}%`;
document.getElementById('customerNameBadge').textContent = opportunity.customer_name || '-';
document.getElementById('stageBadge').textContent = opportunity.stage_name || '-';
document.getElementById('amountBadge').textContent = formatCurrency(opportunity.amount, opportunity.currency);
document.getElementById('probabilityBadge').textContent = `${opportunity.probability || 0}%`;
}
async function saveOpportunity() {
const payload = {
title: document.getElementById('title').value,
description: document.getElementById('description').value || null,
amount: parseFloat(document.getElementById('amount').value || 0),
currency: document.getElementById('currency').value,
expected_close_date: document.getElementById('expectedCloseDate').value || null,
stage_id: parseInt(document.getElementById('stageId').value)
};
const response = await fetch(`/api/v1/opportunities/${opportunityId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
alert('Kunne ikke gemme mulighed');
return;
}
opportunity = await response.json();
renderOpportunity();
}
function formatCurrency(value, currency) {
const num = parseFloat(value || 0);
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
}
</script>
{% endblock %}

View File

@ -0,0 +1,19 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/opportunities", response_class=HTMLResponse)
async def opportunities_page(request: Request):
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request})
@router.get("/opportunities/{opportunity_id}", response_class=HTMLResponse)
async def opportunity_detail_page(request: Request, opportunity_id: int):
return templates.TemplateResponse("opportunities/frontend/opportunity_detail.html", {
"request": request,
"opportunity_id": opportunity_id
})

View File

@ -0,0 +1,22 @@
import logging
from typing import Dict
logger = logging.getLogger(__name__)
def handle_stage_change(opportunity: Dict, stage: Dict) -> None:
"""Handle side-effects for stage changes (Hub-local)."""
if stage.get("is_won"):
logger.info("✅ Opportunity won (id=%s, customer_id=%s)", opportunity.get("id"), opportunity.get("customer_id"))
_create_local_order_placeholder(opportunity)
elif stage.get("is_lost"):
logger.info("⚠️ Opportunity lost (id=%s, customer_id=%s)", opportunity.get("id"), opportunity.get("customer_id"))
def _create_local_order_placeholder(opportunity: Dict) -> None:
"""Placeholder for local order creation (next version)."""
logger.info(
"🧩 Local order hook pending for opportunity %s (customer_id=%s)",
opportunity.get("id"),
opportunity.get("customer_id")
)

View File

@ -92,6 +92,9 @@
<a class="nav-link" href="#tags" data-tab="tags"> <a class="nav-link" href="#tags" data-tab="tags">
<i class="bi bi-tags me-2"></i>Tags <i class="bi bi-tags me-2"></i>Tags
</a> </a>
<a class="nav-link" href="#pipeline" data-tab="pipeline">
<i class="bi bi-diagram-3 me-2"></i>Pipeline
</a>
<a class="nav-link" href="#sync" data-tab="sync"> <a class="nav-link" href="#sync" data-tab="sync">
<i class="bi bi-arrow-repeat me-2"></i>Sync <i class="bi bi-arrow-repeat me-2"></i>Sync
</a> </a>
@ -304,6 +307,42 @@
</div> </div>
</div> </div>
<!-- Pipeline Settings -->
<div class="tab-pane fade" id="pipeline">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-1">Pipeline Stages</h5>
<p class="text-muted mb-0">Administrer faser i salgspipelinen</p>
</div>
<button class="btn btn-primary" onclick="openStageModal()">
<i class="bi bi-plus-lg me-2"></i>Opret stage
</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Sortering</th>
<th>Standard %</th>
<th>Status</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody id="stagesTableBody">
<tr>
<td colspan="5" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Sync Integration --> <!-- Sync Integration -->
<div class="tab-pane fade" id="sync"> <div class="tab-pane fade" id="sync">
<div class="mb-4"> <div class="mb-4">
@ -742,11 +781,66 @@ async def scan_document(file_path: str):
</div> </div>
</div> </div>
<!-- Pipeline Stage Modal -->
<div class="modal fade" id="stageModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="stageModalTitle">Opret stage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="stageForm">
<input type="hidden" id="stageId">
<div class="mb-3">
<label class="form-label">Navn *</label>
<input type="text" class="form-control" id="stageName" required>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="stageDescription" rows="2"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Sortering</label>
<input type="number" class="form-control" id="stageSortOrder" value="0">
</div>
<div class="mb-3">
<label class="form-label">Standard sandsynlighed (%)</label>
<input type="number" class="form-control" id="stageProbability" value="0" min="0" max="100">
</div>
<div class="mb-3">
<label class="form-label">Farve</label>
<input type="color" class="form-control form-control-color" id="stageColor" value="#0f4c75">
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="stageIsWon">
<label class="form-check-label" for="stageIsWon">Vundet</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="stageIsLost">
<label class="form-check-label" for="stageIsLost">Tabt</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="stageIsActive" checked>
<label class="form-check-label" for="stageIsActive">Aktiv</label>
</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="saveStage()">Gem</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
let allSettings = []; let allSettings = [];
let pipelineStagesCache = [];
async function loadSettings() { async function loadSettings() {
try { try {
@ -1788,11 +1882,133 @@ function createToastContainer() {
return container; return container;
} }
// Pipeline stages
async function loadPipelineStages() {
try {
const response = await fetch('/api/v1/pipeline/stages');
const stages = await response.json();
pipelineStagesCache = stages || [];
renderPipelineStages(pipelineStagesCache);
} catch (error) {
console.error('Error loading pipeline stages:', error);
}
}
function renderPipelineStages(stages) {
const tbody = document.getElementById('stagesTableBody');
if (!stages || stages.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Ingen stages</td></tr>';
return;
}
tbody.innerHTML = stages.map(stage => `
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<span class="badge" style="background:${stage.color}; color: white;">${stage.name}</span>
<span class="text-muted small">${stage.description || ''}</span>
</div>
</td>
<td>${stage.sort_order}</td>
<td>${stage.default_probability}%</td>
<td>
${stage.is_won ? '<span class="badge bg-success">Vundet</span>' : ''}
${stage.is_lost ? '<span class="badge bg-danger">Tabt</span>' : ''}
${(!stage.is_won && !stage.is_lost) ? '<span class="badge bg-secondary">Åben</span>' : ''}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-light" onclick="editStageById(${stage.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-light text-danger" onclick="deactivateStage(${stage.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`).join('');
}
function openStageModal() {
document.getElementById('stageModalTitle').textContent = 'Opret stage';
document.getElementById('stageForm').reset();
document.getElementById('stageIsActive').checked = true;
document.getElementById('stageId').value = '';
const modal = new bootstrap.Modal(document.getElementById('stageModal'));
modal.show();
}
function editStageById(stageId) {
const stage = pipelineStagesCache.find(s => s.id === stageId);
if (!stage) return;
document.getElementById('stageModalTitle').textContent = 'Rediger stage';
document.getElementById('stageId').value = stage.id;
document.getElementById('stageName').value = stage.name;
document.getElementById('stageDescription').value = stage.description || '';
document.getElementById('stageSortOrder').value = stage.sort_order || 0;
document.getElementById('stageProbability').value = stage.default_probability || 0;
document.getElementById('stageColor').value = stage.color || '#0f4c75';
document.getElementById('stageIsWon').checked = !!stage.is_won;
document.getElementById('stageIsLost').checked = !!stage.is_lost;
document.getElementById('stageIsActive').checked = !!stage.is_active;
const modal = new bootstrap.Modal(document.getElementById('stageModal'));
modal.show();
}
async function saveStage() {
const stageId = document.getElementById('stageId').value;
const payload = {
name: document.getElementById('stageName').value,
description: document.getElementById('stageDescription').value || null,
sort_order: parseInt(document.getElementById('stageSortOrder').value || 0),
default_probability: parseInt(document.getElementById('stageProbability').value || 0),
color: document.getElementById('stageColor').value,
is_won: document.getElementById('stageIsWon').checked,
is_lost: document.getElementById('stageIsLost').checked,
is_active: document.getElementById('stageIsActive').checked
};
if (!payload.name) {
alert('Navn er påkrævet');
return;
}
const url = stageId ? `/api/v1/pipeline/stages/${stageId}` : '/api/v1/pipeline/stages';
const method = stageId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
alert('Kunne ikke gemme stage');
return;
}
bootstrap.Modal.getInstance(document.getElementById('stageModal')).hide();
loadPipelineStages();
}
async function deactivateStage(stageId) {
if (!confirm('Er du sikker på at du vil deaktivere denne stage?')) return;
const response = await fetch(`/api/v1/pipeline/stages/${stageId}`, { method: 'DELETE' });
if (!response.ok) {
alert('Kunne ikke deaktivere stage');
return;
}
loadPipelineStages();
}
// Load on page ready // Load on page ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadSettings(); loadSettings();
loadUsers(); loadUsers();
setupTagModalListeners(); setupTagModalListeners();
loadPipelineStages();
}); });
</script> </script>

View File

@ -253,6 +253,7 @@
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li> <li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
<li><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li> <li><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
</ul> </ul>
</li> </li>

View File

@ -53,6 +53,8 @@ from app.backups.frontend import views as backups_views
from app.backups.backend.scheduler import backup_scheduler from app.backups.backend.scheduler import backup_scheduler
from app.conversations.backend import router as conversations_api from app.conversations.backend import router as conversations_api
from app.conversations.frontend import views as conversations_views from app.conversations.frontend import views as conversations_views
from app.opportunities.backend import router as opportunities_api
from app.opportunities.frontend import views as opportunities_views
# Modules # Modules
from app.modules.webshop.backend import router as webshop_api from app.modules.webshop.backend import router as webshop_api
@ -134,6 +136,7 @@ app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"]) app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"]) app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"]) app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"])
# Module Routers # Module Routers
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"]) app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
@ -153,6 +156,7 @@ app.include_router(emails_views.router, tags=["Frontend"])
app.include_router(backups_views.router, tags=["Frontend"]) app.include_router(backups_views.router, tags=["Frontend"])
app.include_router(conversations_views.router, tags=["Frontend"]) app.include_router(conversations_views.router, tags=["Frontend"])
app.include_router(webshop_views.router, tags=["Frontend"]) app.include_router(webshop_views.router, tags=["Frontend"])
app.include_router(opportunities_views.router, tags=["Frontend"])
# Serve static files (UI) # Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static") app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -0,0 +1,59 @@
-- =========================================================================
-- Migration 016: Pipeline Opportunities (Hub-local)
-- =========================================================================
CREATE TABLE IF NOT EXISTS pipeline_stages (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
default_probability INTEGER NOT NULL DEFAULT 0,
color VARCHAR(20) DEFAULT '#0f4c75',
is_won BOOLEAN DEFAULT FALSE,
is_lost BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pipeline_opportunities (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
amount NUMERIC(12, 2) DEFAULT 0,
currency VARCHAR(10) DEFAULT 'DKK',
expected_close_date DATE,
stage_id INTEGER NOT NULL REFERENCES pipeline_stages(id),
probability INTEGER NOT NULL DEFAULT 0,
owner_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pipeline_stage_history (
id SERIAL PRIMARY KEY,
opportunity_id INTEGER NOT NULL REFERENCES pipeline_opportunities(id) ON DELETE CASCADE,
from_stage_id INTEGER REFERENCES pipeline_stages(id),
to_stage_id INTEGER NOT NULL REFERENCES pipeline_stages(id),
changed_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_pipeline_opportunities_customer_id ON pipeline_opportunities(customer_id);
CREATE INDEX IF NOT EXISTS idx_pipeline_opportunities_stage_id ON pipeline_opportunities(stage_id);
CREATE INDEX IF NOT EXISTS idx_pipeline_stage_history_opportunity_id ON pipeline_stage_history(opportunity_id);
-- Seed default stages (Option B)
INSERT INTO pipeline_stages (name, description, sort_order, default_probability, color, is_won, is_lost)
SELECT * FROM (VALUES
('Ny', 'Ny mulighed', 1, 10, '#0f4c75', FALSE, FALSE),
('Afklaring', 'Kvalificering og behov', 2, 25, '#1b6ca8', FALSE, FALSE),
('Tilbud', 'Tilbud er sendt', 3, 50, '#3282b8', FALSE, FALSE),
('Commit', 'Forhandling og commit', 4, 75, '#5b8c5a', FALSE, FALSE),
('Vundet', 'Lukket som vundet', 5, 100, '#2f9e44', TRUE, FALSE),
('Tabt', 'Lukket som tabt', 6, 0, '#d9480f', FALSE, TRUE)
) AS v(name, description, sort_order, default_probability, color, is_won, is_lost)
WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages);

View File

@ -40,6 +40,11 @@ if [ ! -f ".env" ]; then
exit 1 exit 1
fi fi
# Load environment variables (DB credentials)
set -a
source .env
set +a
# Update RELEASE_VERSION in .env # Update RELEASE_VERSION in .env
echo "📝 Opdaterer .env med version $VERSION..." echo "📝 Opdaterer .env med version $VERSION..."
if grep -q "^RELEASE_VERSION=" .env; then if grep -q "^RELEASE_VERSION=" .env; then
@ -79,6 +84,25 @@ echo ""
echo "⏳ Venter på container startup..." echo "⏳ Venter på container startup..."
sleep 5 sleep 5
# Run database migration
echo ""
echo "🧱 Kører database migrationer..."
if [ -z "$POSTGRES_USER" ] || [ -z "$POSTGRES_DB" ]; then
echo "❌ Fejl: POSTGRES_USER/POSTGRES_DB mangler i .env"
exit 1
fi
for i in {1..10}; do
if podman exec bmc-hub-postgres-prod pg_isready -U "$POSTGRES_USER" >/dev/null 2>&1; then
break
fi
echo "⏳ Venter på postgres... ($i/10)"
sleep 2
done
podman exec -i bmc-hub-postgres-prod psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /docker-entrypoint-initdb.d/016_opportunities.sql
echo "✅ Migration 016_opportunities.sql kørt"
# Show logs # Show logs
echo "" echo ""
echo "📋 Logs fra startup:" echo "📋 Logs fra startup:"