Add local pipeline opportunities
This commit is contained in:
parent
262fa80aef
commit
c2a265d5f9
@ -500,6 +500,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pipeline Tab -->
|
||||||
|
<div class="tab-pane fade" id="pipeline">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-0">Kunde pipeline</h5>
|
||||||
|
<small class="text-muted">Muligheder knyttet til kunden</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openCustomerOpportunityModal()">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
|
||||||
|
</button>
|
||||||
|
</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>Beløb</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>Sandsynlighed</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="customerOpportunitiesTable">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Billing Matrix Tab -->
|
<!-- Billing Matrix Tab -->
|
||||||
<div class="tab-pane fade" id="billing-matrix">
|
<div class="tab-pane fade" id="billing-matrix">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
@ -767,12 +802,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Opportunity Modal -->
|
||||||
|
<div class="modal fade" id="customerOpportunityModal" 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="customerOpportunityForm">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Titel *</label>
|
||||||
|
<input type="text" class="form-control" id="customerOpportunityTitle" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Stage</label>
|
||||||
|
<select class="form-select" id="customerOpportunityStage"></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Beløb</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" id="customerOpportunityAmount">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Valuta</label>
|
||||||
|
<select class="form-select" id="customerOpportunityCurrency">
|
||||||
|
<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">Forventet lukning</label>
|
||||||
|
<input type="date" class="form-control" id="customerOpportunityCloseDate">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Beskrivelse</label>
|
||||||
|
<textarea class="form-control" id="customerOpportunityDescription" 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="createCustomerOpportunity()">Gem</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
const customerId = parseInt(window.location.pathname.split('/').pop());
|
||||||
let customerData = null;
|
let customerData = null;
|
||||||
|
let pipelineStages = [];
|
||||||
|
|
||||||
let eventListenersAdded = false;
|
let eventListenersAdded = false;
|
||||||
|
|
||||||
@ -801,6 +887,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load pipeline when tab is shown
|
||||||
|
const pipelineTab = document.querySelector('a[href="#pipeline"]');
|
||||||
|
if (pipelineTab) {
|
||||||
|
pipelineTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
loadCustomerPipeline();
|
||||||
|
}, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
// Load activity when tab is shown
|
// Load activity when tab is shown
|
||||||
const activityTab = document.querySelector('a[href="#activity"]');
|
const activityTab = document.querySelector('a[href="#activity"]');
|
||||||
if (activityTab) {
|
if (activityTab) {
|
||||||
@ -1014,6 +1108,87 @@ async function loadSubscriptions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPipelineStages() {
|
||||||
|
if (pipelineStages.length > 0) return;
|
||||||
|
const response = await fetch('/api/v1/pipeline/stages');
|
||||||
|
pipelineStages = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('customerOpportunityStage');
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = pipelineStages.map(stage => `<option value="${stage.id}">${stage.name}</option>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomerPipeline() {
|
||||||
|
await loadPipelineStages();
|
||||||
|
const response = await fetch(`/api/v1/opportunities?customer_id=${customerId}`);
|
||||||
|
const opportunities = await response.json();
|
||||||
|
renderCustomerPipeline(opportunities);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCustomerPipeline(opportunities) {
|
||||||
|
const tbody = document.getElementById('customerOpportunitiesTable');
|
||||||
|
if (!opportunities || opportunities.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Ingen muligheder endnu</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = opportunities.map(o => `
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">${escapeHtml(o.title)}</td>
|
||||||
|
<td>${formatCurrency(o.amount, o.currency)}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" style="background:${o.stage_color || '#0f4c75'}; color: white;">${escapeHtml(o.stage_name || '-')}</span>
|
||||||
|
</td>
|
||||||
|
<td>${o.probability || 0}%</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="window.location.href='/opportunities/${o.id}'">
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCustomerOpportunityModal() {
|
||||||
|
const form = document.getElementById('customerOpportunityForm');
|
||||||
|
if (form) form.reset();
|
||||||
|
loadPipelineStages();
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('customerOpportunityModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCustomerOpportunity() {
|
||||||
|
const payload = {
|
||||||
|
customer_id: customerId,
|
||||||
|
title: document.getElementById('customerOpportunityTitle').value,
|
||||||
|
description: document.getElementById('customerOpportunityDescription').value || null,
|
||||||
|
amount: parseFloat(document.getElementById('customerOpportunityAmount').value || 0),
|
||||||
|
currency: document.getElementById('customerOpportunityCurrency').value || 'DKK',
|
||||||
|
stage_id: parseInt(document.getElementById('customerOpportunityStage').value || 0) || null,
|
||||||
|
expected_close_date: document.getElementById('customerOpportunityCloseDate').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.title) {
|
||||||
|
alert('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('customerOpportunityModal')).hide();
|
||||||
|
loadCustomerPipeline();
|
||||||
|
}
|
||||||
|
|
||||||
function displaySubscriptions(data) {
|
function displaySubscriptions(data) {
|
||||||
const container = document.getElementById('subscriptionsContainer');
|
const container = document.getElementById('subscriptionsContainer');
|
||||||
const { recurring_orders, sales_orders, subscriptions, expired_subscriptions, bmc_office_subscriptions } = data;
|
const { recurring_orders, sales_orders, subscriptions, expired_subscriptions, bmc_office_subscriptions } = data;
|
||||||
@ -1060,13 +1235,6 @@ function displaySubscriptions(data) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pipeline Tab -->
|
|
||||||
<div class="tab-pane fade" id="pipeline">
|
|
||||||
<h5 class="fw-bold mb-4">Kunde pipeline</h5>
|
|
||||||
<div class="text-muted text-center py-5">
|
|
||||||
Kunde pipeline kommer snart...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Column 2: Sales Orders
|
// Column 2: Sales Orders
|
||||||
@ -1488,6 +1656,11 @@ function formatDate(dateStr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value, currency) {
|
||||||
|
const num = parseFloat(value || 0);
|
||||||
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadActivity() {
|
async function loadActivity() {
|
||||||
const container = document.getElementById('activityContainer');
|
const container = document.getElementById('activityContainer');
|
||||||
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
||||||
|
|||||||
@ -2,21 +2,262 @@
|
|||||||
|
|
||||||
{% block title %}Pipeline - BMC Hub{% endblock %}
|
{% block title %}Pipeline - BMC Hub{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block extra_css %}
|
||||||
<div class="container-fluid">
|
<style>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
.pipeline-board {
|
||||||
<div>
|
display: grid;
|
||||||
<h2 class="fw-bold mb-1">Pipeline</h2>
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
<p class="text-muted mb-0">Salgspipeline pr. kunde</p>
|
gap: 1rem;
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
.pipeline-column {
|
||||||
<div class="card-body text-center py-5">
|
background: var(--bg-card);
|
||||||
<div class="text-muted">
|
border: 1px solid rgba(0,0,0,0.06);
|
||||||
Pipeline kommer snart...
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
0
app/opportunities/__init__.py
Normal file
0
app/opportunities/__init__.py
Normal file
0
app/opportunities/backend/__init__.py
Normal file
0
app/opportunities/backend/__init__.py
Normal file
307
app/opportunities/backend/router.py
Normal file
307
app/opportunities/backend/router.py
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
Opportunities (Pipeline) Router
|
||||||
|
Hub-local sales pipeline
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import date
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.database import execute_query, execute_query_single, execute_update
|
||||||
|
from app.services.opportunity_service import handle_stage_change
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineStageBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
sort_order: int = 0
|
||||||
|
default_probability: int = 0
|
||||||
|
color: Optional[str] = "#0f4c75"
|
||||||
|
is_won: bool = False
|
||||||
|
is_lost: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineStageCreate(PipelineStageBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineStageUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
default_probability: Optional[int] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
is_won: Optional[bool] = None
|
||||||
|
is_lost: Optional[bool] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpportunityBase(BaseModel):
|
||||||
|
customer_id: int
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
amount: Optional[float] = 0
|
||||||
|
currency: Optional[str] = "DKK"
|
||||||
|
expected_close_date: Optional[date] = None
|
||||||
|
stage_id: Optional[int] = None
|
||||||
|
owner_user_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpportunityCreate(OpportunityBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OpportunityUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
amount: Optional[float] = None
|
||||||
|
currency: Optional[str] = None
|
||||||
|
expected_close_date: Optional[date] = None
|
||||||
|
stage_id: Optional[int] = None
|
||||||
|
owner_user_id: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpportunityStageUpdate(BaseModel):
|
||||||
|
stage_id: int
|
||||||
|
note: Optional[str] = None
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_stage(stage_id: int):
|
||||||
|
stage = execute_query_single(
|
||||||
|
"SELECT * FROM pipeline_stages WHERE id = %s AND is_active = TRUE",
|
||||||
|
(stage_id,)
|
||||||
|
)
|
||||||
|
if not stage:
|
||||||
|
raise HTTPException(status_code=404, detail="Stage not found")
|
||||||
|
return stage
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_stage():
|
||||||
|
stage = execute_query_single(
|
||||||
|
"SELECT * FROM pipeline_stages WHERE is_active = TRUE ORDER BY sort_order ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
if not stage:
|
||||||
|
raise HTTPException(status_code=400, detail="No active stages configured")
|
||||||
|
return stage
|
||||||
|
|
||||||
|
|
||||||
|
def _get_opportunity(opportunity_id: int):
|
||||||
|
query = """
|
||||||
|
SELECT o.*, c.name AS customer_name,
|
||||||
|
s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost
|
||||||
|
FROM pipeline_opportunities o
|
||||||
|
JOIN customers c ON c.id = o.customer_id
|
||||||
|
JOIN pipeline_stages s ON s.id = o.stage_id
|
||||||
|
WHERE o.id = %s
|
||||||
|
"""
|
||||||
|
opportunity = execute_query_single(query, (opportunity_id,))
|
||||||
|
if not opportunity:
|
||||||
|
raise HTTPException(status_code=404, detail="Opportunity not found")
|
||||||
|
return opportunity
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_stage_history(opportunity_id: int, from_stage_id: Optional[int], to_stage_id: int,
|
||||||
|
user_id: Optional[int] = None, note: Optional[str] = None):
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO pipeline_stage_history (opportunity_id, from_stage_id, to_stage_id, changed_by_user_id, note)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(opportunity_id, from_stage_id, to_stage_id, user_id, note)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Pipeline Stages
|
||||||
|
# ============================
|
||||||
|
|
||||||
|
@router.get("/pipeline/stages", tags=["Pipeline Stages"])
|
||||||
|
async def list_stages():
|
||||||
|
query = "SELECT * FROM pipeline_stages WHERE is_active = TRUE ORDER BY sort_order ASC"
|
||||||
|
return execute_query(query) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/stages", tags=["Pipeline Stages"])
|
||||||
|
async def create_stage(stage: PipelineStageCreate):
|
||||||
|
query = """
|
||||||
|
INSERT INTO pipeline_stages
|
||||||
|
(name, description, sort_order, default_probability, color, is_won, is_lost, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (
|
||||||
|
stage.name,
|
||||||
|
stage.description,
|
||||||
|
stage.sort_order,
|
||||||
|
stage.default_probability,
|
||||||
|
stage.color,
|
||||||
|
stage.is_won,
|
||||||
|
stage.is_lost,
|
||||||
|
stage.is_active
|
||||||
|
))
|
||||||
|
logger.info("✅ Created pipeline stage: %s", stage.name)
|
||||||
|
return result[0] if result else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/pipeline/stages/{stage_id}", tags=["Pipeline Stages"])
|
||||||
|
async def update_stage(stage_id: int, stage: PipelineStageUpdate):
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
for field, value in stage.dict(exclude_unset=True).items():
|
||||||
|
updates.append(f"{field} = %s")
|
||||||
|
params.append(value)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
params.append(stage_id)
|
||||||
|
query = f"UPDATE pipeline_stages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING *"
|
||||||
|
result = execute_query(query, tuple(params))
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Stage not found")
|
||||||
|
|
||||||
|
logger.info("✅ Updated pipeline stage: %s", stage_id)
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pipeline/stages/{stage_id}", tags=["Pipeline Stages"])
|
||||||
|
async def deactivate_stage(stage_id: int):
|
||||||
|
affected = execute_update(
|
||||||
|
"UPDATE pipeline_stages SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||||
|
(stage_id,)
|
||||||
|
)
|
||||||
|
if not affected:
|
||||||
|
raise HTTPException(status_code=404, detail="Stage not found")
|
||||||
|
logger.info("⚠️ Deactivated pipeline stage: %s", stage_id)
|
||||||
|
return {"status": "success", "stage_id": stage_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Opportunities
|
||||||
|
# ============================
|
||||||
|
|
||||||
|
@router.get("/opportunities", tags=["Opportunities"])
|
||||||
|
async def list_opportunities(customer_id: Optional[int] = None, stage_id: Optional[int] = None):
|
||||||
|
query = """
|
||||||
|
SELECT o.*, c.name AS customer_name,
|
||||||
|
s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost
|
||||||
|
FROM pipeline_opportunities o
|
||||||
|
JOIN customers c ON c.id = o.customer_id
|
||||||
|
JOIN pipeline_stages s ON s.id = o.stage_id
|
||||||
|
WHERE o.is_active = TRUE
|
||||||
|
"""
|
||||||
|
params: List = []
|
||||||
|
if customer_id is not None:
|
||||||
|
query += " AND o.customer_id = %s"
|
||||||
|
params.append(customer_id)
|
||||||
|
if stage_id is not None:
|
||||||
|
query += " AND o.stage_id = %s"
|
||||||
|
params.append(stage_id)
|
||||||
|
|
||||||
|
query += " ORDER BY o.updated_at DESC NULLS LAST, o.created_at DESC"
|
||||||
|
if params:
|
||||||
|
return execute_query(query, tuple(params)) or []
|
||||||
|
return execute_query(query) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/opportunities/{opportunity_id}", tags=["Opportunities"])
|
||||||
|
async def get_opportunity(opportunity_id: int):
|
||||||
|
return _get_opportunity(opportunity_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/opportunities", tags=["Opportunities"])
|
||||||
|
async def create_opportunity(opportunity: OpportunityCreate):
|
||||||
|
stage = _get_stage(opportunity.stage_id) if opportunity.stage_id else _get_default_stage()
|
||||||
|
probability = stage["default_probability"]
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO pipeline_opportunities
|
||||||
|
(customer_id, title, description, amount, currency, expected_close_date, stage_id, probability, owner_user_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (
|
||||||
|
opportunity.customer_id,
|
||||||
|
opportunity.title,
|
||||||
|
opportunity.description,
|
||||||
|
opportunity.amount or 0,
|
||||||
|
opportunity.currency or "DKK",
|
||||||
|
opportunity.expected_close_date,
|
||||||
|
stage["id"],
|
||||||
|
probability,
|
||||||
|
opportunity.owner_user_id
|
||||||
|
))
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create opportunity")
|
||||||
|
|
||||||
|
opportunity_id = result[0]["id"]
|
||||||
|
_insert_stage_history(opportunity_id, None, stage["id"], opportunity.owner_user_id, "Oprettet")
|
||||||
|
logger.info("✅ Created opportunity %s", opportunity_id)
|
||||||
|
return _get_opportunity(opportunity_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/opportunities/{opportunity_id}", tags=["Opportunities"])
|
||||||
|
async def update_opportunity(opportunity_id: int, update: OpportunityUpdate):
|
||||||
|
existing = _get_opportunity(opportunity_id)
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
update_dict = update.dict(exclude_unset=True)
|
||||||
|
stage_changed = False
|
||||||
|
new_stage = None
|
||||||
|
|
||||||
|
if "stage_id" in update_dict:
|
||||||
|
new_stage = _get_stage(update_dict["stage_id"])
|
||||||
|
update_dict["probability"] = new_stage["default_probability"]
|
||||||
|
stage_changed = new_stage["id"] != existing["stage_id"]
|
||||||
|
|
||||||
|
for field, value in update_dict.items():
|
||||||
|
updates.append(f"{field} = %s")
|
||||||
|
params.append(value)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
params.append(opportunity_id)
|
||||||
|
query = f"UPDATE pipeline_opportunities SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
|
||||||
|
execute_update(query, tuple(params))
|
||||||
|
|
||||||
|
if stage_changed and new_stage:
|
||||||
|
_insert_stage_history(opportunity_id, existing["stage_id"], new_stage["id"], update.owner_user_id, "Stage ændret")
|
||||||
|
updated = _get_opportunity(opportunity_id)
|
||||||
|
handle_stage_change(updated, new_stage)
|
||||||
|
|
||||||
|
return _get_opportunity(opportunity_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/opportunities/{opportunity_id}/stage", tags=["Opportunities"])
|
||||||
|
async def update_opportunity_stage(opportunity_id: int, update: OpportunityStageUpdate):
|
||||||
|
existing = _get_opportunity(opportunity_id)
|
||||||
|
new_stage = _get_stage(update.stage_id)
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE pipeline_opportunities
|
||||||
|
SET stage_id = %s,
|
||||||
|
probability = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(new_stage["id"], new_stage["default_probability"], opportunity_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
_insert_stage_history(opportunity_id, existing["stage_id"], new_stage["id"], update.user_id, update.note)
|
||||||
|
updated = _get_opportunity(opportunity_id)
|
||||||
|
handle_stage_change(updated, new_stage)
|
||||||
|
|
||||||
|
return updated
|
||||||
0
app/opportunities/frontend/__init__.py
Normal file
0
app/opportunities/frontend/__init__.py
Normal file
289
app/opportunities/frontend/opportunities.html
Normal file
289
app/opportunities/frontend/opportunities.html
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
{% 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 () => {
|
||||||
|
await loadStages();
|
||||||
|
await loadCustomers();
|
||||||
|
await loadOpportunities();
|
||||||
|
});
|
||||||
|
|
||||||
|
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=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();
|
||||||
|
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>
|
||||||
|
<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">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="goToDetail(${o.id})">
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</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 %}
|
||||||
216
app/opportunities/frontend/opportunity_detail.html
Normal file
216
app/opportunities/frontend/opportunity_detail.html
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Mulighed - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1.2fr 0.8fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
border: 1px solid rgba(0,0,0,0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="fw-bold mb-1" id="pageTitle">Mulighed</h2>
|
||||||
|
<p class="text-muted mb-0">Detaljeret pipeline‑visning</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary" href="/opportunities">
|
||||||
|
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-primary" onclick="saveOpportunity()">
|
||||||
|
<i class="bi bi-check-lg me-2"></i>Gem
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">Grundoplysninger</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Titel *</label>
|
||||||
|
<input type="text" class="form-control" id="title" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Kunde</label>
|
||||||
|
<input type="text" class="form-control" id="customerName" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="mb-0">
|
||||||
|
<label class="form-label">Beskrivelse</label>
|
||||||
|
<textarea class="form-control" id="description" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">Salgsstatus</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<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">Sandsynlighed</label>
|
||||||
|
<input type="text" class="form-control" id="probability" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Forventet lukning</label>
|
||||||
|
<input type="date" class="form-control" id="expectedCloseDate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">Løsning & Salgsdetaljer</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">Tilbud & Kontrakt</div>
|
||||||
|
<div class="text-muted small">Felt til dokumentlink og kontraktstatus kommer i næste version.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sticky-panel d-flex flex-column gap-3">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">Pipeline‑status</div>
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span class="text-muted">Kunde</span>
|
||||||
|
<span id="customerNameBadge">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span class="text-muted">Stage</span>
|
||||||
|
<span id="stageBadge">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span class="text-muted">Værdi</span>
|
||||||
|
<span id="amountBadge">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="text-muted">Sandsynlighed</span>
|
||||||
|
<span id="probabilityBadge">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">Næste aktivitet</div>
|
||||||
|
<div class="text-muted small">Aktivitetsmodul kommer senere.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const opportunityId = parseInt(window.location.pathname.split('/').pop());
|
||||||
|
let stages = [];
|
||||||
|
let opportunity = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await loadStages();
|
||||||
|
await loadOpportunity();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadStages() {
|
||||||
|
const response = await fetch('/api/v1/pipeline/stages');
|
||||||
|
stages = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('stageId');
|
||||||
|
select.innerHTML = stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOpportunity() {
|
||||||
|
const response = await fetch(`/api/v1/opportunities/${opportunityId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
alert('Mulighed ikke fundet');
|
||||||
|
window.location.href = '/opportunities';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
opportunity = await response.json();
|
||||||
|
renderOpportunity();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOpportunity() {
|
||||||
|
document.getElementById('pageTitle').textContent = opportunity.title;
|
||||||
|
document.getElementById('title').value = opportunity.title;
|
||||||
|
document.getElementById('customerName').value = opportunity.customer_name || '-';
|
||||||
|
document.getElementById('description').value = opportunity.description || '';
|
||||||
|
document.getElementById('amount').value = opportunity.amount || 0;
|
||||||
|
document.getElementById('currency').value = opportunity.currency || 'DKK';
|
||||||
|
document.getElementById('expectedCloseDate').value = opportunity.expected_close_date || '';
|
||||||
|
document.getElementById('stageId').value = opportunity.stage_id;
|
||||||
|
document.getElementById('probability').value = `${opportunity.probability || 0}%`;
|
||||||
|
|
||||||
|
document.getElementById('customerNameBadge').textContent = opportunity.customer_name || '-';
|
||||||
|
document.getElementById('stageBadge').textContent = opportunity.stage_name || '-';
|
||||||
|
document.getElementById('amountBadge').textContent = formatCurrency(opportunity.amount, opportunity.currency);
|
||||||
|
document.getElementById('probabilityBadge').textContent = `${opportunity.probability || 0}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOpportunity() {
|
||||||
|
const payload = {
|
||||||
|
title: document.getElementById('title').value,
|
||||||
|
description: document.getElementById('description').value || null,
|
||||||
|
amount: parseFloat(document.getElementById('amount').value || 0),
|
||||||
|
currency: document.getElementById('currency').value,
|
||||||
|
expected_close_date: document.getElementById('expectedCloseDate').value || null,
|
||||||
|
stage_id: parseInt(document.getElementById('stageId').value)
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/opportunities/${opportunityId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert('Kunne ikke gemme mulighed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
opportunity = await response.json();
|
||||||
|
renderOpportunity();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value, currency) {
|
||||||
|
const num = parseFloat(value || 0);
|
||||||
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
19
app/opportunities/frontend/views.py
Normal file
19
app/opportunities/frontend/views.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/opportunities", response_class=HTMLResponse)
|
||||||
|
async def opportunities_page(request: Request):
|
||||||
|
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/opportunities/{opportunity_id}", response_class=HTMLResponse)
|
||||||
|
async def opportunity_detail_page(request: Request, opportunity_id: int):
|
||||||
|
return templates.TemplateResponse("opportunities/frontend/opportunity_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"opportunity_id": opportunity_id
|
||||||
|
})
|
||||||
22
app/services/opportunity_service.py
Normal file
22
app/services/opportunity_service.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_stage_change(opportunity: Dict, stage: Dict) -> None:
|
||||||
|
"""Handle side-effects for stage changes (Hub-local)."""
|
||||||
|
if stage.get("is_won"):
|
||||||
|
logger.info("✅ Opportunity won (id=%s, customer_id=%s)", opportunity.get("id"), opportunity.get("customer_id"))
|
||||||
|
_create_local_order_placeholder(opportunity)
|
||||||
|
elif stage.get("is_lost"):
|
||||||
|
logger.info("⚠️ Opportunity lost (id=%s, customer_id=%s)", opportunity.get("id"), opportunity.get("customer_id"))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_local_order_placeholder(opportunity: Dict) -> None:
|
||||||
|
"""Placeholder for local order creation (next version)."""
|
||||||
|
logger.info(
|
||||||
|
"🧩 Local order hook pending for opportunity %s (customer_id=%s)",
|
||||||
|
opportunity.get("id"),
|
||||||
|
opportunity.get("customer_id")
|
||||||
|
)
|
||||||
@ -92,6 +92,9 @@
|
|||||||
<a class="nav-link" href="#tags" data-tab="tags">
|
<a class="nav-link" href="#tags" data-tab="tags">
|
||||||
<i class="bi bi-tags me-2"></i>Tags
|
<i class="bi bi-tags me-2"></i>Tags
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="#pipeline" data-tab="pipeline">
|
||||||
|
<i class="bi bi-diagram-3 me-2"></i>Pipeline
|
||||||
|
</a>
|
||||||
<a class="nav-link" href="#sync" data-tab="sync">
|
<a class="nav-link" href="#sync" data-tab="sync">
|
||||||
<i class="bi bi-arrow-repeat me-2"></i>Sync
|
<i class="bi bi-arrow-repeat me-2"></i>Sync
|
||||||
</a>
|
</a>
|
||||||
@ -304,6 +307,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pipeline Settings -->
|
||||||
|
<div class="tab-pane fade" id="pipeline">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-1">Pipeline Stages</h5>
|
||||||
|
<p class="text-muted mb-0">Administrer faser i salgspipelinen</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="openStageModal()">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret stage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Sortering</th>
|
||||||
|
<th>Standard %</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="stagesTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sync Integration -->
|
<!-- Sync Integration -->
|
||||||
<div class="tab-pane fade" id="sync">
|
<div class="tab-pane fade" id="sync">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@ -742,11 +781,66 @@ async def scan_document(file_path: str):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pipeline Stage Modal -->
|
||||||
|
<div class="modal fade" id="stageModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="stageModalTitle">Opret stage</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="stageForm">
|
||||||
|
<input type="hidden" id="stageId">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Navn *</label>
|
||||||
|
<input type="text" class="form-control" id="stageName" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Beskrivelse</label>
|
||||||
|
<textarea class="form-control" id="stageDescription" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Sortering</label>
|
||||||
|
<input type="number" class="form-control" id="stageSortOrder" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Standard sandsynlighed (%)</label>
|
||||||
|
<input type="number" class="form-control" id="stageProbability" value="0" min="0" max="100">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Farve</label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="stageColor" value="#0f4c75">
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="stageIsWon">
|
||||||
|
<label class="form-check-label" for="stageIsWon">Vundet</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="stageIsLost">
|
||||||
|
<label class="form-check-label" for="stageIsLost">Tabt</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="stageIsActive" checked>
|
||||||
|
<label class="form-check-label" for="stageIsActive">Aktiv</label>
|
||||||
|
</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="saveStage()">Gem</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
let allSettings = [];
|
let allSettings = [];
|
||||||
|
let pipelineStagesCache = [];
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
@ -1788,11 +1882,133 @@ function createToastContainer() {
|
|||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pipeline stages
|
||||||
|
async function loadPipelineStages() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/pipeline/stages');
|
||||||
|
const stages = await response.json();
|
||||||
|
pipelineStagesCache = stages || [];
|
||||||
|
renderPipelineStages(pipelineStagesCache);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading pipeline stages:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPipelineStages(stages) {
|
||||||
|
const tbody = document.getElementById('stagesTableBody');
|
||||||
|
if (!stages || stages.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Ingen stages</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = stages.map(stage => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge" style="background:${stage.color}; color: white;">${stage.name}</span>
|
||||||
|
<span class="text-muted small">${stage.description || ''}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>${stage.sort_order}</td>
|
||||||
|
<td>${stage.default_probability}%</td>
|
||||||
|
<td>
|
||||||
|
${stage.is_won ? '<span class="badge bg-success">Vundet</span>' : ''}
|
||||||
|
${stage.is_lost ? '<span class="badge bg-danger">Tabt</span>' : ''}
|
||||||
|
${(!stage.is_won && !stage.is_lost) ? '<span class="badge bg-secondary">Åben</span>' : ''}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-light" onclick="editStageById(${stage.id})">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-light text-danger" onclick="deactivateStage(${stage.id})">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStageModal() {
|
||||||
|
document.getElementById('stageModalTitle').textContent = 'Opret stage';
|
||||||
|
document.getElementById('stageForm').reset();
|
||||||
|
document.getElementById('stageIsActive').checked = true;
|
||||||
|
document.getElementById('stageId').value = '';
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('stageModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editStageById(stageId) {
|
||||||
|
const stage = pipelineStagesCache.find(s => s.id === stageId);
|
||||||
|
if (!stage) return;
|
||||||
|
document.getElementById('stageModalTitle').textContent = 'Rediger stage';
|
||||||
|
document.getElementById('stageId').value = stage.id;
|
||||||
|
document.getElementById('stageName').value = stage.name;
|
||||||
|
document.getElementById('stageDescription').value = stage.description || '';
|
||||||
|
document.getElementById('stageSortOrder').value = stage.sort_order || 0;
|
||||||
|
document.getElementById('stageProbability').value = stage.default_probability || 0;
|
||||||
|
document.getElementById('stageColor').value = stage.color || '#0f4c75';
|
||||||
|
document.getElementById('stageIsWon').checked = !!stage.is_won;
|
||||||
|
document.getElementById('stageIsLost').checked = !!stage.is_lost;
|
||||||
|
document.getElementById('stageIsActive').checked = !!stage.is_active;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('stageModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStage() {
|
||||||
|
const stageId = document.getElementById('stageId').value;
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('stageName').value,
|
||||||
|
description: document.getElementById('stageDescription').value || null,
|
||||||
|
sort_order: parseInt(document.getElementById('stageSortOrder').value || 0),
|
||||||
|
default_probability: parseInt(document.getElementById('stageProbability').value || 0),
|
||||||
|
color: document.getElementById('stageColor').value,
|
||||||
|
is_won: document.getElementById('stageIsWon').checked,
|
||||||
|
is_lost: document.getElementById('stageIsLost').checked,
|
||||||
|
is_active: document.getElementById('stageIsActive').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.name) {
|
||||||
|
alert('Navn er påkrævet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = stageId ? `/api/v1/pipeline/stages/${stageId}` : '/api/v1/pipeline/stages';
|
||||||
|
const method = stageId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert('Kunne ikke gemme stage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('stageModal')).hide();
|
||||||
|
loadPipelineStages();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivateStage(stageId) {
|
||||||
|
if (!confirm('Er du sikker på at du vil deaktivere denne stage?')) return;
|
||||||
|
const response = await fetch(`/api/v1/pipeline/stages/${stageId}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) {
|
||||||
|
alert('Kunne ikke deaktivere stage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadPipelineStages();
|
||||||
|
}
|
||||||
|
|
||||||
// Load on page ready
|
// Load on page ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
loadUsers();
|
loadUsers();
|
||||||
setupTagModalListeners();
|
setupTagModalListeners();
|
||||||
|
loadPipelineStages();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -253,6 +253,7 @@
|
|||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
|
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
|
<li><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
4
main.py
4
main.py
@ -53,6 +53,8 @@ from app.backups.frontend import views as backups_views
|
|||||||
from app.backups.backend.scheduler import backup_scheduler
|
from app.backups.backend.scheduler import backup_scheduler
|
||||||
from app.conversations.backend import router as conversations_api
|
from app.conversations.backend import router as conversations_api
|
||||||
from app.conversations.frontend import views as conversations_views
|
from app.conversations.frontend import views as conversations_views
|
||||||
|
from app.opportunities.backend import router as opportunities_api
|
||||||
|
from app.opportunities.frontend import views as opportunities_views
|
||||||
|
|
||||||
# Modules
|
# Modules
|
||||||
from app.modules.webshop.backend import router as webshop_api
|
from app.modules.webshop.backend import router as webshop_api
|
||||||
@ -134,6 +136,7 @@ app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
|
|||||||
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
|
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
|
||||||
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
|
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
|
||||||
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
|
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
|
||||||
|
app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"])
|
||||||
|
|
||||||
# Module Routers
|
# Module Routers
|
||||||
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
|
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
|
||||||
@ -153,6 +156,7 @@ app.include_router(emails_views.router, tags=["Frontend"])
|
|||||||
app.include_router(backups_views.router, tags=["Frontend"])
|
app.include_router(backups_views.router, tags=["Frontend"])
|
||||||
app.include_router(conversations_views.router, tags=["Frontend"])
|
app.include_router(conversations_views.router, tags=["Frontend"])
|
||||||
app.include_router(webshop_views.router, tags=["Frontend"])
|
app.include_router(webshop_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(opportunities_views.router, tags=["Frontend"])
|
||||||
|
|
||||||
# Serve static files (UI)
|
# Serve static files (UI)
|
||||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||||
|
|||||||
59
migrations/016_opportunities.sql
Normal file
59
migrations/016_opportunities.sql
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
-- =========================================================================
|
||||||
|
-- Migration 016: Pipeline Opportunities (Hub-local)
|
||||||
|
-- =========================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pipeline_stages (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
default_probability INTEGER NOT NULL DEFAULT 0,
|
||||||
|
color VARCHAR(20) DEFAULT '#0f4c75',
|
||||||
|
is_won BOOLEAN DEFAULT FALSE,
|
||||||
|
is_lost BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pipeline_opportunities (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
amount NUMERIC(12, 2) DEFAULT 0,
|
||||||
|
currency VARCHAR(10) DEFAULT 'DKK',
|
||||||
|
expected_close_date DATE,
|
||||||
|
stage_id INTEGER NOT NULL REFERENCES pipeline_stages(id),
|
||||||
|
probability INTEGER NOT NULL DEFAULT 0,
|
||||||
|
owner_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pipeline_stage_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
opportunity_id INTEGER NOT NULL REFERENCES pipeline_opportunities(id) ON DELETE CASCADE,
|
||||||
|
from_stage_id INTEGER REFERENCES pipeline_stages(id),
|
||||||
|
to_stage_id INTEGER NOT NULL REFERENCES pipeline_stages(id),
|
||||||
|
changed_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_opportunities_customer_id ON pipeline_opportunities(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_opportunities_stage_id ON pipeline_opportunities(stage_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_stage_history_opportunity_id ON pipeline_stage_history(opportunity_id);
|
||||||
|
|
||||||
|
-- Seed default stages (Option B)
|
||||||
|
INSERT INTO pipeline_stages (name, description, sort_order, default_probability, color, is_won, is_lost)
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
('Ny', 'Ny mulighed', 1, 10, '#0f4c75', FALSE, FALSE),
|
||||||
|
('Afklaring', 'Kvalificering og behov', 2, 25, '#1b6ca8', FALSE, FALSE),
|
||||||
|
('Tilbud', 'Tilbud er sendt', 3, 50, '#3282b8', FALSE, FALSE),
|
||||||
|
('Commit', 'Forhandling og commit', 4, 75, '#5b8c5a', FALSE, FALSE),
|
||||||
|
('Vundet', 'Lukket som vundet', 5, 100, '#2f9e44', TRUE, FALSE),
|
||||||
|
('Tabt', 'Lukket som tabt', 6, 0, '#d9480f', FALSE, TRUE)
|
||||||
|
) AS v(name, description, sort_order, default_probability, color, is_won, is_lost)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages);
|
||||||
24
updateto.sh
24
updateto.sh
@ -40,6 +40,11 @@ if [ ! -f ".env" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Load environment variables (DB credentials)
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
# Update RELEASE_VERSION in .env
|
# Update RELEASE_VERSION in .env
|
||||||
echo "📝 Opdaterer .env med version $VERSION..."
|
echo "📝 Opdaterer .env med version $VERSION..."
|
||||||
if grep -q "^RELEASE_VERSION=" .env; then
|
if grep -q "^RELEASE_VERSION=" .env; then
|
||||||
@ -79,6 +84,25 @@ echo ""
|
|||||||
echo "⏳ Venter på container startup..."
|
echo "⏳ Venter på container startup..."
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|
||||||
|
# Run database migration
|
||||||
|
echo ""
|
||||||
|
echo "🧱 Kører database migrationer..."
|
||||||
|
if [ -z "$POSTGRES_USER" ] || [ -z "$POSTGRES_DB" ]; then
|
||||||
|
echo "❌ Fejl: POSTGRES_USER/POSTGRES_DB mangler i .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for i in {1..10}; do
|
||||||
|
if podman exec bmc-hub-postgres-prod pg_isready -U "$POSTGRES_USER" >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "⏳ Venter på postgres... ($i/10)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
podman exec -i bmc-hub-postgres-prod psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /docker-entrypoint-initdb.d/016_opportunities.sql
|
||||||
|
echo "✅ Migration 016_opportunities.sql kørt"
|
||||||
|
|
||||||
# Show logs
|
# Show logs
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 Logs fra startup:"
|
echo "📋 Logs fra startup:"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user