bmc_hub/app/opportunities/frontend/opportunities.html

304 lines
11 KiB
HTML
Raw Normal View History

2026-01-28 07:48:10 +01:00
{% 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 () => {
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';
}
2026-01-28 07:48:10 +01:00
});
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');
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();
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 => `
2026-01-29 00:36:32 +01:00
<tr class="opportunity-row" style="cursor:pointer" onclick="goToDetail(${o.id})">
2026-01-28 07:48:10 +01:00
<td class="fw-semibold">${escapeHtml(o.title)}</td>
2026-01-29 00:36:32 +01:00
<td>${escapeHtml(o.customer_name || '-')}</td>
2026-01-28 07:48:10 +01:00
<td>${formatCurrency(o.amount, o.currency)}</td>
2026-01-29 00:36:32 +01:00
<td>${o.expected_close_date ? formatDate(o.expected_close_date) : '<span class=\"text-muted\">-</span>'}</td>
2026-01-28 07:48:10 +01:00
<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">
2026-01-29 00:36:32 +01:00
<i class="bi bi-arrow-right"></i>
2026-01-28 07:48:10 +01:00
</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 %}