2026-01-28 01:41:57 +01:00
|
|
|
{% extends "shared/frontend/base.html" %}
|
|
|
|
|
|
|
|
|
|
{% block title %}Pipeline - BMC Hub{% endblock %}
|
|
|
|
|
|
2026-01-28 07:48:10 +01:00
|
|
|
{% block extra_css %}
|
|
|
|
|
<style>
|
|
|
|
|
.pipeline-board {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pipeline-column {
|
|
|
|
|
background: var(--bg-card);
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|
|
|
|
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 %}
|
|
|
|
|
|
2026-01-28 01:41:57 +01:00
|
|
|
{% block content %}
|
2026-01-28 07:48:10 +01:00
|
|
|
<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>
|
2026-01-28 01:41:57 +01:00
|
|
|
</div>
|
2026-01-28 07:48:10 +01:00
|
|
|
</div>
|
2026-01-28 01:41:57 +01:00
|
|
|
|
2026-01-28 07:48:10 +01:00
|
|
|
<!-- 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>
|
2026-01-28 01:41:57 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{% endblock %}
|
2026-01-28 07:48:10 +01:00
|
|
|
|
|
|
|
|
{% 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() {
|
2026-02-17 08:29:05 +01:00
|
|
|
const response = await fetch('/api/v1/customers?limit=1000');
|
2026-01-28 07:48:10 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
const renderCards = (items, stage) => {
|
|
|
|
|
return items.map(o => `
|
2026-01-28 07:48:10 +01:00
|
|
|
<div class="pipeline-card">
|
|
|
|
|
<div class="d-flex justify-content-between align-items-start">
|
2026-02-17 08:29:05 +01:00
|
|
|
<h6>${escapeHtml(o.titel || '')}</h6>
|
|
|
|
|
<span class="badge" style="background:${(stage && stage.color) || '#6c757d'}; color: white;">${o.pipeline_probability || 0}%</span>
|
2026-01-28 07:48:10 +01:00
|
|
|
</div>
|
|
|
|
|
<div class="pipeline-meta">${escapeHtml(o.customer_name || '-')}
|
2026-02-17 08:29:05 +01:00
|
|
|
· ${formatCurrency(o.pipeline_amount, 'DKK')}
|
2026-01-28 07:48:10 +01:00
|
|
|
</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)">
|
2026-02-17 08:29:05 +01:00
|
|
|
<option value="">Ikke sat</option>
|
|
|
|
|
${stages.map(s => `<option value="${s.id}" ${Number(s.id) === Number(o.pipeline_stage_id) ? 'selected' : ''}>${s.name}</option>`).join('')}
|
2026-01-28 07:48:10 +01:00
|
|
|
</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('');
|
2026-02-17 08:29:05 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const unassignedItems = opportunities.filter(o => !o.pipeline_stage_id);
|
|
|
|
|
const columns = [];
|
|
|
|
|
|
|
|
|
|
if (unassignedItems.length > 0) {
|
|
|
|
|
columns.push(`
|
|
|
|
|
<div class="pipeline-column">
|
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
|
|
|
<strong>Ikke sat</strong>
|
|
|
|
|
<span class="small text-muted">${unassignedItems.length}</span>
|
|
|
|
|
</div>
|
|
|
|
|
${renderCards(unassignedItems, null)}
|
|
|
|
|
</div>
|
|
|
|
|
`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stages.forEach(stage => {
|
|
|
|
|
const items = opportunities.filter(o => Number(o.pipeline_stage_id) === Number(stage.id));
|
|
|
|
|
if (!items.length) return;
|
2026-01-28 07:48:10 +01:00
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
columns.push(`
|
2026-01-28 07:48:10 +01:00
|
|
|
<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>
|
2026-02-17 08:29:05 +01:00
|
|
|
${renderCards(items, stage)}
|
2026-01-28 07:48:10 +01:00
|
|
|
</div>
|
2026-02-17 08:29:05 +01:00
|
|
|
`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!columns.length) {
|
|
|
|
|
board.innerHTML = '<div class="pipeline-column"><div class="text-muted small">Ingen muligheder i pipeline endnu</div></div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
board.innerHTML = columns.join('');
|
2026-01-28 07:48:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function changeStage(opportunityId, stageId) {
|
2026-02-17 08:29:05 +01:00
|
|
|
const response = await fetch(`/api/v1/sag/${opportunityId}/pipeline`, {
|
2026-01-28 07:48:10 +01:00
|
|
|
method: 'PATCH',
|
2026-02-17 08:29:05 +01:00
|
|
|
credentials: 'include',
|
2026-01-28 07:48:10 +01:00
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-02-17 08:29:05 +01:00
|
|
|
body: JSON.stringify({ stage_id: stageId ? parseInt(stageId, 10) : null })
|
2026-01-28 07:48:10 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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',
|
2026-02-17 08:29:05 +01:00
|
|
|
credentials: 'include',
|
2026-01-28 07:48:10 +01:00
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
alert('Kunne ikke oprette mulighed');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
const createdCase = await response.json();
|
|
|
|
|
|
2026-01-28 07:48:10 +01:00
|
|
|
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
|
|
|
|
|
await loadOpportunities();
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
if (createdCase?.id && (payload.stage_id || payload.amount)) {
|
|
|
|
|
await fetch(`/api/v1/sag/${createdCase.id}/pipeline`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
stage_id: payload.stage_id || null,
|
|
|
|
|
amount: payload.amount || null
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
await loadOpportunities();
|
|
|
|
|
}
|
2026-01-28 07:48:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function goToDetail(id) {
|
2026-02-17 08:29:05 +01:00
|
|
|
window.location.href = `/sag/${id}`;
|
2026-01-28 07:48:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 %}
|