304 lines
11 KiB
HTML
304 lines
11 KiB
HTML
{% 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">Hub‑lokal 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 () => {
|
||
try {
|
||
await loadStages();
|
||
} catch (error) {
|
||
console.error('Error loading stages:', error);
|
||
}
|
||
|
||
try {
|
||
await loadCustomers();
|
||
} catch (error) {
|
||
console.error('Error loading customers:', error);
|
||
}
|
||
|
||
try {
|
||
await loadOpportunities();
|
||
} catch (error) {
|
||
console.error('Error loading opportunities:', error);
|
||
const tbody = document.getElementById('opportunitiesTable');
|
||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-5">Fejl ved indlæsning af muligheder</td></tr>';
|
||
document.getElementById('countLabel').textContent = '0 muligheder';
|
||
}
|
||
});
|
||
|
||
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=1000');
|
||
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 class="opportunity-row" style="cursor:pointer" onclick="goToDetail(${o.id})">
|
||
<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">
|
||
<i class="bi bi-arrow-right"></i>
|
||
</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 %}
|