bmc_hub/app/customers/frontend/pipeline.html
2026-01-28 07:48:10 +01:00

264 lines
9.3 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Pipeline - BMC Hub{% endblock %}
{% 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 %}
{% 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>
{% 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 %}