bmc_hub/app/opportunities/frontend/opportunities.html
Christian f059cb6c95 feat: Add product search endpoint and enhance opportunity management
- Implemented a new endpoint for searching webshop products with filters for visibility and configuration.
- Enhanced the webshop frontend to include a customer search feature for improved user experience.
- Added opportunity line items management with CRUD operations and comments functionality.
- Created database migrations for opportunity line items and comments, including necessary triggers and indexes.
2026-01-28 14:37:47 +01:00

307 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block title %}Muligheder - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.stage-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
background: rgba(15, 76, 117, 0.1);
color: var(--accent);
}
.stage-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="fw-bold mb-1">Muligheder</h2>
<p class="text-muted mb-0">Hublokal salgspipeline</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary" href="/pipeline">
<i class="bi bi-kanban me-2"></i>Sales Board
</a>
<button class="btn btn-primary" onclick="openCreateOpportunityModal()">
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
</button>
</div>
</div>
<div class="card p-3 mb-4">
<div class="row g-2 align-items-center">
<div class="col-md-4">
<input type="text" class="form-control" id="searchInput" placeholder="Søg titel eller kunde..." oninput="renderOpportunities()">
</div>
<div class="col-md-3">
<select class="form-select" id="stageFilter" onchange="renderOpportunities()"></select>
</div>
<div class="col-md-3">
<select class="form-select" id="statusFilter" onchange="renderOpportunities()">
<option value="all">Alle status</option>
<option value="open">Åbne</option>
<option value="won">Vundet</option>
<option value="lost">Tabt</option>
</select>
</div>
<div class="col-md-2 text-end">
<span class="text-muted small" id="countLabel">0 muligheder</span>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Titel</th>
<th>Kunde</th>
<th>Beløb</th>
<th>Lukningsdato</th>
<th>Stage</th>
<th>Sandsynlighed</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody id="opportunitiesTable">
<tr>
<td colspan="7" class="text-center py-5">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Opportunity Modal -->
<div class="modal fade" id="opportunityModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret mulighed</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="opportunityForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde *</label>
<select class="form-select" id="customerId" required></select>
</div>
<div class="col-md-6">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="title" required>
</div>
<div class="col-md-6">
<label class="form-label">Beløb</label>
<input type="number" step="0.01" class="form-control" id="amount">
</div>
<div class="col-md-6">
<label class="form-label">Valuta</label>
<select class="form-select" id="currency">
<option value="DKK">DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Stage</label>
<select class="form-select" id="stageId"></select>
</div>
<div class="col-md-6">
<label class="form-label">Forventet lukning</label>
<input type="date" class="form-control" id="expectedCloseDate">
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="description" rows="3"></textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createOpportunity()">Gem</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let opportunities = [];
let stages = [];
let customers = [];
document.addEventListener('DOMContentLoaded', async () => {
try {
await loadStages();
} catch (error) {
console.error('Error loading stages:', error);
}
try {
await loadCustomers();
} catch (error) {
console.error('Error loading customers:', error);
}
try {
await loadOpportunities();
} catch (error) {
console.error('Error loading opportunities:', error);
const tbody = document.getElementById('opportunitiesTable');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-5">Fejl ved indlæsning af muligheder</td></tr>';
document.getElementById('countLabel').textContent = '0 muligheder';
}
});
async function loadStages() {
const response = await fetch('/api/v1/pipeline/stages');
stages = await response.json();
const stageFilter = document.getElementById('stageFilter');
stageFilter.innerHTML = '<option value="all">Alle stages</option>' +
stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
const stageSelect = document.getElementById('stageId');
stageSelect.innerHTML = stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
}
async function loadCustomers() {
const response = await fetch('/api/v1/customers?limit=1000');
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 %}