bmc_hub/app/opportunities/frontend/opportunities.html
Christian 891180f3f0 Refactor opportunities and settings management
- Removed opportunity detail page route from views.py.
- Deleted opportunity_service.py as it is no longer needed.
- Updated router.py to seed new setting for case_type_module_defaults.
- Enhanced settings.html to include standard modules per case type with UI for selection.
- Implemented JavaScript functions to manage case type module defaults.
- Added RelationService for handling case relations with a tree structure.
- Created migration scripts (128 and 129) for new pipeline fields and descriptions.
- Added script to fix relation types in the database.
2026-02-15 11:12:58 +01:00

385 lines
14 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Muligheder - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-open {
background-color: #e3f2fd;
color: #0d47a1;
border: 1px solid rgba(13, 71, 161, 0.1);
}
.status-closed {
background-color: #f5f5f5;
color: #616161;
border: 1px solid rgba(0,0,0,0.05);
}
.opportunity-row:hover {
background-color: rgba(0,0,0,0.02);
}
.opportunity-row td {
vertical-align: middle;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<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">Sager markeret som pipeline</p>
</div>
<div>
<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 shadow-sm border-0">
<div class="row g-3 align-items-center">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
<input type="text" class="form-control border-start-0 ps-0" id="searchInput" placeholder="Søg titel eller kunde..." oninput="filterOpportunities()">
</div>
</div>
<div class="col-md-3">
<select class="form-select" id="stageFilter" onchange="filterOpportunities()">
<option value="all">Alle stages</option>
</select>
</div>
<div class="col-md-2">
<select class="form-select" id="statusFilter" onchange="filterOpportunities()">
<option value="all">Alle status</option>
<option value="open" selected>Åbne</option>
<option value="closed">Lukkede</option>
</select>
</div>
<div class="col-md-3 text-end">
<span class="badge bg-light text-dark border" id="countLabel">Henter data...</span>
</div>
</div>
</div>
<div class="card shadow-sm border-0 overflow-hidden">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">Titel</th>
<th>Kunde</th>
<th>Stage</th>
<th class="text-end">Sandsynlighed</th>
<th class="text-end">Beløb</th>
<th>Status</th>
<th>Deadline</th>
<th>Ansvarlig</th>
<th>Beskrivelse</th>
<th class="text-end pe-4">Handling</th>
</tr>
</thead>
<tbody id="opportunitiesTable">
<tr>
<td colspan="10" class="text-center py-5">
<div class="spinner-border text-primary spinner-border-sm me-2"></div>
<span class="text-muted">Indlæser muligheder...</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Create Opportunity Modal -->
<div class="modal fade" id="opportunityModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<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="mb-3">
<label class="form-label small text-uppercase text-muted fw-bold">Titel *</label>
<input type="text" class="form-control" id="title" required placeholder="Fx Opgradering af serverpark">
</div>
<div class="mb-3">
<label class="form-label small text-uppercase text-muted fw-bold">Kunde *</label>
<select class="form-select" id="customerId" required>
<option value="">Vælg kunde...</option>
</select>
</div>
<div class="mb-3">
<label class="form-label small text-uppercase text-muted fw-bold">Forventet lukning (Deadline)</label>
<input type="date" class="form-control" id="expectedCloseDate">
</div>
<div class="mb-3">
<label class="form-label small text-uppercase text-muted fw-bold">Beskrivelse</label>
<textarea class="form-control" id="description" rows="4" placeholder="Beskriv muligheden..."></textarea>
</div>
</form>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-link text-decoration-none text-muted" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary px-4" onclick="createOpportunity()">Opret</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let allOpportunities = [];
let customers = [];
document.addEventListener('DOMContentLoaded', async () => {
// Load data in parallel
const [custRes, oppRes] = await Promise.allSettled([
fetch('/api/v1/customers?limit=1000'),
fetch('/api/v1/opportunities')
]);
// Handle customers
if (custRes.status === 'fulfilled') {
const data = await custRes.value.json();
customers = Array.isArray(data) ? data : (data.customers || []);
renderCustomerSelect();
}
// Handle opportunities
if (oppRes.status === 'fulfilled') {
allOpportunities = await oppRes.value.json();
renderStageFilter();
filterOpportunities();
} else {
document.getElementById('opportunitiesTable').innerHTML =
'<tr><td colspan="10" class="text-center text-danger py-5">Kunne ikke hente data</td></tr>';
}
// Setup search listener
document.getElementById('searchInput').addEventListener('input', debounce(filterOpportunities, 300));
});
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
function renderCustomerSelect() {
const select = document.getElementById('customerId');
// Sort customers by name
customers.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
}
function renderStageFilter() {
const select = document.getElementById('stageFilter');
if (!select) return;
const current = select.value || 'all';
const stages = [...new Set(
allOpportunities
.map(o => (o.pipeline_stage || '').trim())
.filter(Boolean)
)].sort((a, b) => a.localeCompare(b, 'da'));
select.innerHTML = '<option value="all">Alle stages</option>' +
stages.map(stage => `<option value="${escapeHtml(stage)}">${escapeHtml(stage)}</option>`).join('');
if (current !== 'all' && stages.includes(current)) {
select.value = current;
}
}
function filterOpportunities() {
const search = document.getElementById('searchInput').value.toLowerCase();
const stageFilter = document.getElementById('stageFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
const filtered = allOpportunities.filter(o => {
// Search filter
const matchSearch = !search ||
(o.titel && o.titel.toLowerCase().includes(search)) ||
(o.customer_name && o.customer_name.toLowerCase().includes(search));
// Status filter
let matchStatus = true;
if (statusFilter === 'open') matchStatus = o.status === 'åben';
if (statusFilter === 'closed') matchStatus = o.status !== 'åben';
let matchStage = true;
if (stageFilter !== 'all') {
matchStage = (o.pipeline_stage || '') === stageFilter;
}
return matchSearch && matchStatus && matchStage;
});
renderTable(filtered);
}
function renderTable(data) {
const tbody = document.getElementById('opportunitiesTable');
document.getElementById('countLabel').textContent = `${data.length} fundet`;
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-5">Ingen muligheder fundet</td></tr>';
return;
}
tbody.innerHTML = data.map(o => {
const statusClass = (o.status === 'åben') ? 'status-open' : 'status-closed';
const dateStr = o.deadline ? formatDate(o.deadline) : '<span class="text-muted">-</span>';
const stage = o.pipeline_stage ? escapeHtml(o.pipeline_stage) : '<span class="text-muted">-</span>';
const probability = Number.isFinite(Number(o.pipeline_probability)) ? `${Number(o.pipeline_probability)}%` : '<span class="text-muted">-</span>';
const amount = (o.pipeline_amount === null || o.pipeline_amount === undefined)
? '<span class="text-muted">-</span>'
: formatCurrency(o.pipeline_amount);
const descPreview = o.beskrivelse ?
(o.beskrivelse.length > 50 ? escapeHtml(o.beskrivelse.substring(0, 50)) + '...' : escapeHtml(o.beskrivelse))
: '<span class="text-muted fst-italic">Ingen beskrivelse</span>';
return `
<tr class="opportunity-row" style="cursor:pointer" onclick="window.location.href='/sag/${o.id}'">
<td class="fw-semibold ps-4">${escapeHtml(o.titel)}</td>
<td>${escapeHtml(o.customer_name)}</td>
<td>${stage}</td>
<td class="text-end">${probability}</td>
<td class="text-end">${amount}</td>
<td>
<span class="status-badge ${statusClass}">
${escapeHtml(o.status)}
</span>
</td>
<td>${dateStr}</td>
<td>${escapeHtml(o.ansvarlig_navn)}</td>
<td class="small text-muted">${descPreview}</td>
<td class="text-end pe-4 text-muted">
<i class="bi bi-chevron-right"></i>
</td>
</tr>
`;
}).join('');
}
function openCreateOpportunityModal() {
document.getElementById('opportunityForm').reset();
new bootstrap.Modal(document.getElementById('opportunityModal')).show();
}
async function createOpportunity() {
const title = document.getElementById('title').value;
const customerId = document.getElementById('customerId').value;
const desc = document.getElementById('description').value;
const closeDate = document.getElementById('expectedCloseDate').value;
if (!title || !customerId) {
alert('Titel og Kunde skal udfyldes');
return;
}
const payload = {
title: title,
customer_id: parseInt(customerId),
description: desc,
expected_close_date: closeDate || null
};
try {
const btn = document.querySelector('#opportunityModal .btn-primary');
const originalText = btn.textContent;
btn.textContent = 'Gemmer...';
btn.disabled = true;
const res = await fetch('/api/v1/opportunities', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette');
}
const newCase = await res.json();
// Success
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
// Reload list
const oppRes = await fetch('/api/v1/opportunities');
allOpportunities = await oppRes.json();
filterOpportunities();
// Reset btn
btn.textContent = originalText;
btn.disabled = false;
} catch (e) {
alert('Fejl: ' + e.message);
const btn = document.querySelector('#opportunityModal .btn-primary');
btn.textContent = 'Opret';
btn.disabled = false;
}
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('da-DK', {day: 'numeric', month: 'short', year: 'numeric'});
}
function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(Number(value || 0));
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
</script>
{% endblock %}