- 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.
385 lines
14 KiB
HTML
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
</script>
|
|
{% endblock %}
|