bmc_hub/app/customers/frontend/pipeline.html

308 lines
11 KiB
HTML
Raw Normal View History

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() {
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;
}
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">
<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 || '-')}
· ${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)">
<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('');
};
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
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>
${renderCards(items, stage)}
2026-01-28 07:48:10 +01:00
</div>
`);
});
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) {
const response = await fetch(`/api/v1/sag/${opportunityId}/pipeline`, {
2026-01-28 07:48:10 +01:00
method: 'PATCH',
credentials: 'include',
2026-01-28 07:48:10 +01:00
headers: { 'Content-Type': 'application/json' },
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',
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;
}
const createdCase = await response.json();
2026-01-28 07:48:10 +01:00
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
await loadOpportunities();
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) {
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 %}