Add local pipeline opportunities
This commit is contained in:
parent
262fa80aef
commit
c2a265d5f9
@ -500,6 +500,41 @@
|
||||
</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 -->
|
||||
<div class="tab-pane fade" id="billing-matrix">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
@ -767,12 +802,63 @@
|
||||
</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
||||
let customerData = null;
|
||||
let pipelineStages = [];
|
||||
|
||||
let eventListenersAdded = false;
|
||||
|
||||
@ -800,6 +886,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadInternalComment();
|
||||
}, { 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
|
||||
const activityTab = document.querySelector('a[href="#activity"]');
|
||||
@ -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) {
|
||||
const container = document.getElementById('subscriptionsContainer');
|
||||
const { recurring_orders, sales_orders, subscriptions, expired_subscriptions, bmc_office_subscriptions } = data;
|
||||
@ -1060,13 +1235,6 @@ function displaySubscriptions(data) {
|
||||
</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
|
||||
@ -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() {
|
||||
const container = document.getElementById('activityContainer');
|
||||
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 content %}
|
||||
<div class="container-fluid">
|
||||
<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>
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.pipeline-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="text-muted">
|
||||
Pipeline kommer snart...
|
||||
.pipeline-column {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
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>
|
||||
{% 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">
|
||||
<i class="bi bi-tags me-2"></i>Tags
|
||||
</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">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Sync
|
||||
</a>
|
||||
@ -304,6 +307,42 @@
|
||||
</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 -->
|
||||
<div class="tab-pane fade" id="sync">
|
||||
<div class="mb-4">
|
||||
@ -742,11 +781,66 @@ async def scan_document(file_path: str):
|
||||
</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let allSettings = [];
|
||||
let pipelineStagesCache = [];
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
@ -1788,11 +1882,133 @@ function createToastContainer() {
|
||||
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
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSettings();
|
||||
loadUsers();
|
||||
setupTagModalListeners();
|
||||
loadPipelineStages();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -253,6 +253,7 @@
|
||||
<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><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>
|
||||
</ul>
|
||||
</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.conversations.backend import router as conversations_api
|
||||
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
|
||||
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(backups_api, prefix="/api/v1", tags=["Backups"])
|
||||
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
|
||||
app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"])
|
||||
|
||||
# Module Routers
|
||||
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(conversations_views.router, tags=["Frontend"])
|
||||
app.include_router(webshop_views.router, tags=["Frontend"])
|
||||
app.include_router(opportunities_views.router, tags=["Frontend"])
|
||||
|
||||
# Serve static files (UI)
|
||||
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
|
||||
fi
|
||||
|
||||
# Load environment variables (DB credentials)
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
# Update RELEASE_VERSION in .env
|
||||
echo "📝 Opdaterer .env med version $VERSION..."
|
||||
if grep -q "^RELEASE_VERSION=" .env; then
|
||||
@ -79,6 +84,25 @@ echo ""
|
||||
echo "⏳ Venter på container startup..."
|
||||
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
|
||||
echo ""
|
||||
echo "📋 Logs fra startup:"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user