bmc_hub/app/fixed_price/frontend/list.html

617 lines
27 KiB
HTML
Raw Permalink Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Fastpris Aftaler - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<h1 class="h3 mb-0">📋 Fastpris Aftaler</h1>
<p class="text-muted">Månedlige timer aftaler med overtid håndtering</p>
</div>
<div class="col-auto">
<a href="/fixed-price-agreements/reports/dashboard" class="btn btn-outline-primary me-2">
<i class="bi bi-graph-up"></i> Rapporter
</a>
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-circle"></i> Opret Ny Aftale
</button>
</div>
</div>
<!-- Stats Row -->
<div class="row g-3 mb-4" id="statsCards">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-success bg-opacity-10 p-3">
<i class="bi bi-calendar-check text-success fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Aktive Aftaler</p>
<h3 class="mb-0" id="activeCount">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-primary bg-opacity-10 p-3">
<i class="bi bi-currency-dollar text-primary fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Total Omsætning</p>
<h3 class="mb-0" id="totalRevenue">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-info bg-opacity-10 p-3">
<i class="bi bi-pie-chart text-info fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Total Profit</p>
<h3 class="mb-0" id="totalProfit">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-warning bg-opacity-10 p-3">
<i class="bi bi-clock text-warning fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Brugte Timer</p>
<h3 class="mb-0" id="totalHours">-</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Agreements Table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="row align-items-center">
<div class="col">
<h5 class="mb-0">Alle Aftaler</h5>
</div>
<div class="col-auto">
<input type="text" class="form-control form-control-sm" id="searchInput" placeholder="Søg...">
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle" id="agreementsTable">
<thead class="table-light">
<tr>
<th>Aftale Nr.</th>
<th>Kunde</th>
<th>Månedlige Timer</th>
<th>Status</th>
<th>Denne Måned</th>
<th>Start</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="agreementsBody">
<tr>
<td colspan="7" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Load customers from server-side render
const customersCache = {{ customers | tojson }};
console.log('✅ Loaded customers from template:', customersCache?.length || 0);
async function loadAgreements() {
try {
// Load stats
const stats = await fetch('/api/v1/fixed-price-agreements/stats/summary').then(r => r.json());
document.getElementById('activeCount').textContent = stats.active_agreements || 0;
document.getElementById('totalRevenue').textContent = formatCurrency(stats.total_revenue || 0);
document.getElementById('totalProfit').textContent = formatCurrency(stats.total_profit || 0);
document.getElementById('totalHours').textContent = (stats.total_used_hours || 0).toFixed(1) + ' t';
// Load agreements
const agreements = await fetch('/api/v1/fixed-price-agreements?include_current_period=true').then(r => r.json());
renderAgreements(agreements);
} catch (e) {
console.error('Error loading agreements:', e);
document.getElementById('agreementsBody').innerHTML = `
<tr><td colspan="7" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlæsning</p>
</td></tr>
`;
}
}
function renderAgreements(agreements) {
const tbody = document.getElementById('agreementsBody');
if (!agreements || agreements.length === 0) {
tbody.innerHTML = `
<tr><td colspan="7" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 mb-3"></i>
<p>Ingen aftaler endnu</p>
</td></tr>
`;
return;
}
tbody.innerHTML = agreements.map(a => {
const statusBadge = getStatusBadge(a.status);
const currentPeriod = a.current_period;
const usedHours = currentPeriod ? parseFloat(currentPeriod.used_hours || 0).toFixed(1) : '0.0';
const remainingHours = a.remaining_hours_this_month ? a.remaining_hours_this_month.toFixed(1) : parseFloat(a.monthly_hours).toFixed(1);
return `
<tr onclick="window.location.href='/fixed-price-agreements/${a.id}'" style="cursor: pointer;">
<td><strong>${a.agreement_number}</strong></td>
<td>${a.customer_name || '-'}</td>
<td>${parseFloat(a.monthly_hours).toFixed(0)} t/md</td>
<td>${statusBadge}</td>
<td>
<small class="text-muted">${usedHours}t brugt / ${remainingHours}t tilbage</small>
${currentPeriod && currentPeriod.status === 'pending_approval' ? '<span class="badge bg-warning ms-1">⚠️ Overtid</span>' : ''}
</td>
<td>${new Date(a.start_date).toLocaleDateString('da-DK')}</td>
<td class="text-end" onclick="event.stopPropagation();">
<a href="/fixed-price-agreements/${a.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Detaljer
</a>
</td>
</tr>
`;
}).join('');
}
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'suspended': '<span class="badge bg-warning">Suspenderet</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-secondary">Annulleret</span>',
'pending_cancellation': '<span class="badge bg-warning">Opsagt</span>'
};
return badges[status] || status;
}
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
function calculateHourlyRate() {
const monthlyAmount = parseFloat(document.getElementById('createMonthlyAmount')?.value || 0);
const monthlyHours = parseFloat(document.getElementById('createMonthlyHours')?.value || 1);
if (monthlyAmount > 0 && monthlyHours > 0) {
const hourlyRate = (monthlyAmount / monthlyHours).toFixed(2);
document.getElementById('calculatedHourlyRate').textContent = `(≈ ${hourlyRate} kr/t)`;
} else {
document.getElementById('calculatedHourlyRate').textContent = '';
}
}
async function openCreateModal() {
console.log('🚀 openCreateModal() called');
console.log('📊 Customers cache:', customersCache?.length || 0);
// Show loading state
const customerList = document.getElementById('createCustomerList');
if (!customerList) {
console.error('❌ createCustomerList element not found!');
return;
}
console.log('✅ Found createCustomerList element');
customerList.innerHTML = '<div class="list-group-item text-center"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser kunder...</div>';
// Check if customers are loaded
if (!customersCache || customersCache.length === 0) {
console.error('❌ No customers available');
customerList.innerHTML = '<div class="list-group-item text-danger"><i class="bi bi-exclamation-triangle me-2"></i>Ingen kunder tilgængelige</div>';
return;
}
console.log('✅ Customers ready, rendering...');
// Populate customer list
const searchInput = document.getElementById('createCustomerSearch');
// Reset search and render all customers
searchInput.value = '';
renderCustomerOptions(customersCache);
// Reset form
document.getElementById('createAgreementForm').reset();
// Set default values
document.getElementById('createStartDate').value = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
// Calculate initial hourly rate display
setTimeout(() => calculateHourlyRate(), 100);
// Show modal
console.log('📋 Opening modal...');
const modalElement = document.getElementById('createAgreementModal');
if (!modalElement) {
console.error('❌ Modal element not found!');
return;
}
console.log('✅ Found modal element');
const modal = new bootstrap.Modal(modalElement);
modal.show();
console.log('✅ Modal.show() called');
}
function renderCustomerOptions(customers) {
const listGroup = document.getElementById('createCustomerList');
console.log('📋 Rendering customers:', customers?.length || 0);
console.log('First customer:', customers?.[0]);
if (!customers || customers.length === 0) {
console.warn('⚠️ No customers to render');
listGroup.innerHTML = '<div class="list-group-item text-muted"><i class="bi bi-inbox me-2"></i>Ingen kunder fundet</div>';
return;
}
console.log('✅ Rendering', customers.length, 'customers');
listGroup.innerHTML = '';
customers.forEach((c, idx) => {
if (idx < 3) console.log(`Customer ${idx}:`, c);
const item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action';
item.dataset.customerId = c.id;
item.dataset.customerName = c.name || 'Ukendt';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>${escapeHtml(c.name || 'Ukendt')}</strong>
${c.cvr_number ? `<br><small class="text-muted">CVR: ${escapeHtml(c.cvr_number)}</small>` : ''}
</div>
${c.is_active === false ? '<span class="badge bg-secondary">Inaktiv</span>' : ''}
</div>
`;
item.addEventListener('click', (e) => {
e.preventDefault();
selectCustomer(c.id, c.name || 'Ukendt');
});
listGroup.appendChild(item);
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function selectCustomer(customerId, customerName) {
// Set hidden input
document.getElementById('createCustomerId').value = customerId;
// Update display
const selectedDiv = document.getElementById('selectedCustomerName');
selectedDiv.innerHTML = '';
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success mb-2';
alertDiv.innerHTML = `
<i class="bi bi-check-circle me-2"></i>
<strong>Valgt kunde:</strong> ${escapeHtml(customerName)}
`;
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn btn-sm btn-link float-end text-decoration-none';
clearBtn.innerHTML = '<i class="bi bi-x"></i> Ryd';
clearBtn.addEventListener('click', clearCustomerSelection);
alertDiv.appendChild(clearBtn);
selectedDiv.appendChild(alertDiv);
// Hide list
document.getElementById('createCustomerList').style.display = 'none';
document.getElementById('createCustomerSearch').style.display = 'none';
}
function clearCustomerSelection() {
document.getElementById('createCustomerId').value = '';
document.getElementById('selectedCustomerName').innerHTML = '';
document.getElementById('createCustomerList').style.display = 'block';
document.getElementById('createCustomerSearch').style.display = 'block';
renderCustomerOptions(customersCache);
}
function searchCustomers() {
const searchTerm = document.getElementById('createCustomerSearch').value.toLowerCase();
const filtered = customersCache.filter(c =>
(c.name || '').toLowerCase().includes(searchTerm) ||
(c.cvr_number || '').toLowerCase().includes(searchTerm) ||
(c.email || '').toLowerCase().includes(searchTerm)
);
renderCustomerOptions(filtered);
}
async function submitCreateAgreement(event) {
event.preventDefault();
const form = event.target;
const submitBtn = form.querySelector('button[type="submit"]');
const originalBtnText = submitBtn.innerHTML;
// Get form data
const customerIdInput = document.getElementById('createCustomerId');
const customerId = parseInt(customerIdInput.value);
// Validate customer selection
if (!customerId || isNaN(customerId)) {
alert('⚠️ Vælg venligst en kunde først');
return;
}
const customer = customersCache.find(c => c.id === customerId);
const customerName = customer?.name || '';
const data = {
customer_id: customerId,
customer_name: customerName,
monthly_hours: parseFloat(form.monthlyHours.value),
monthly_amount: parseFloat(form.monthlyAmount.value),
overtime_rate: parseFloat(form.overtimeRate.value),
rounding_minutes: parseInt(form.roundingMinutes.value),
start_date: form.startDate.value,
binding_months: parseInt(form.bindingMonths.value),
notice_period_days: parseInt(form.noticePeriodDays.value)
};
// Validate
if (!data.customer_id || data.monthly_hours <= 0 || data.monthly_amount <= 0) {
alert('Udfyld venligst alle påkrævede felter');
return;
}
try {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
const response = await fetch('/api/v1/fixed-price-agreements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fejl ved oprettelse');
}
const result = await response.json();
// Close modal
bootstrap.Modal.getInstance(document.getElementById('createAgreementModal')).hide();
// Show success message with details
const successMsg = `✅ Aftale oprettet!\n\nAftalenummer: ${result.agreement_number}\nKunde: ${customerName}\nMånedlige timer: ${data.monthly_hours}t\n\nAftalen er nu tilgængelig i listen.`;
// Reload list to show new agreement
await loadAgreements();
alert(successMsg);
} catch (error) {
console.error('Create error:', error);
alert('❌ Fejl: ' + error.message);
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText;
}
}
// Search functionality
document.getElementById('searchInput')?.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
const rows = document.querySelectorAll('#agreementsBody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(term) ? '' : 'none';
});
});
// Initialize after DOM is loaded
loadAgreements();
// Setup event listeners after modal is in DOM
setTimeout(() => {
// Auto-calculate overtime rate (125%)
document.getElementById('createHourlyRate')?.addEventListener('input', (e) => {
const hourlyRate = parseFloat(e.target.value);
if (hourlyRate > 0) {
document.getElementById('createOvertimeRate').value = (hourlyRate * 1.25).toFixed(2);
}
});
// Customer search listener
const searchInput = document.getElementById('createCustomerSearch');
if (searchInput) {
searchInput.addEventListener('input', searchCustomers);
}
}, 100);
</script>
<!-- Create Agreement Modal -->
<div class="modal fade" id="createAgreementModal" tabindex="-1" aria-labelledby="createAgreementModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createAgreementModalLabel">
<i class="bi bi-plus-circle text-primary me-2"></i>Opret Ny Fastpris Aftale
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="createAgreementForm" onsubmit="submitCreateAgreement(event)">
<div class="modal-body">
<div class="row g-3">
<!-- Customer Selection -->
<div class="col-12">
<label class="form-label">Kunde <span class="text-danger">*</span></label>
<input type="hidden" id="createCustomerId" name="customerId" required>
<!-- Selected Customer Display -->
<div id="selectedCustomerName"></div>
<!-- Search Input -->
<input type="text"
class="form-control mb-2"
id="createCustomerSearch"
placeholder="🔍 Søg kunde (navn, CVR, email)..."
autocomplete="off">
<!-- Customer List -->
<div class="border rounded" style="max-height: 300px; overflow-y: auto;">
<div class="list-group list-group-flush" id="createCustomerList">
<div class="list-group-item text-center text-muted">
<span class="spinner-border spinner-border-sm me-2"></span>
Indlæser kunder...
</div>
</div>
</div>
</div>
<!-- Monthly Hours -->
<div class="col-md-6">
<label for="createMonthlyHours" class="form-label">Månedlige Timer <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createMonthlyHours" name="monthlyHours"
min="1" step="0.5" value="160" required oninput="calculateHourlyRate()">
<div class="form-text">Timer inkluderet per måned</div>
</div>
<!-- Rounding Minutes -->
<div class="col-md-6">
<label for="createRoundingMinutes" class="form-label">Afrunding <span class="text-danger">*</span></label>
<select class="form-select" id="createRoundingMinutes" name="roundingMinutes" required>
<option value="0">Ingen afrunding</option>
<option value="15" selected>15 minutter</option>
<option value="30">30 minutter</option>
<option value="60">60 minutter</option>
</select>
<div class="form-text">Afrund tid ved registrering</div>
</div>
<!-- Monthly Amount -->
<div class="col-md-6">
<label for="createMonthlyAmount" class="form-label">Månedspris (DKK) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createMonthlyAmount" name="monthlyAmount"
min="0" step="0.01" value="80000" required oninput="calculateHourlyRate()">
<div class="form-text">
Fast pris pr. måned
<span id="calculatedHourlyRate" class="text-primary fw-bold"></span>
</div>
</div>
<!-- Overtime Rate -->
<div class="col-md-6">
<label for="createOvertimeRate" class="form-label">Overtid Timepris (DKK) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createOvertimeRate" name="overtimeRate"
min="0" step="0.01" value="625" required>
<div class="form-text">Pris for overtid (typisk 125%)</div>
</div>
<!-- Start Date -->
<div class="col-md-6">
<label for="createStartDate" class="form-label">Start Dato <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="createStartDate" name="startDate" required>
<div class="form-text">Aftalens første dag</div>
</div>
<!-- Binding Months -->
<div class="col-md-6">
<label for="createBindingMonths" class="form-label">Binding (måneder) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createBindingMonths" name="bindingMonths"
min="0" step="1" value="12" required>
<div class="form-text">0 = ingen binding</div>
</div>
<!-- Notice Period -->
<div class="col-md-6">
<label for="createNoticePeriodDays" class="form-label">Opsigelsesfrist (dage)</label>
<input type="number" class="form-control" id="createNoticePeriodDays" name="noticePeriodDays"
min="0" step="1" value="30">
<div class="form-text">Varslingsfrist ved opsigelse</div>
</div>
<!-- End Date (Optional) -->
<div class="col-md-6">
<label for="createEndDate" class="form-label">Slut Dato (valgfri)</label>
<input type="date" class="form-control" id="createEndDate" name="endDate">
<div class="form-text">Lad blank for løbende aftale</div>
</div>
<div class="col-12">
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
<strong>Note:</strong> Aftalen tildeles automatisk et unikt nummer (FPA-YYYYMMDD-XXX) ved oprettelse.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-1"></i>Annuller
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Opret Aftale
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}