- Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview. - Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities. - Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management. - Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions. - Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths. - Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management. - Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql). - Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql). - Added a script to move time tracking section in detail view (move_time_section.py). - Created a test script for subscription processing (test_subscription_processing.py).
366 lines
15 KiB
HTML
366 lines
15 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Varekøb & Salg - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.summary-card {
|
|
background: var(--bg-card);
|
|
border-radius: 12px;
|
|
padding: 1rem 1.25rem;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
|
border: 1px solid rgba(0,0,0,0.05);
|
|
}
|
|
.summary-title {
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.6px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
.summary-value {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
.table-wrapper {
|
|
background: var(--bg-card);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
|
}
|
|
.table thead th {
|
|
background: var(--accent);
|
|
color: white;
|
|
border: none;
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.25rem 0.6rem;
|
|
border-radius: 999px;
|
|
font-size: 0.75rem;
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
background: var(--bg-light);
|
|
}
|
|
.table-secondary {
|
|
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.08) !important;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
.table-secondary:hover {
|
|
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.15) !important;
|
|
}
|
|
.table-secondary td {
|
|
padding: 0.75rem !important;
|
|
}
|
|
.case-lines-container {
|
|
display: none;
|
|
}
|
|
.case-lines-container.show {
|
|
display: table-row;
|
|
}
|
|
.expand-icon {
|
|
transition: transform 0.3s;
|
|
}
|
|
.expand-icon.expanded {
|
|
transform: rotate(90deg);
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid">
|
|
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h2 class="mb-1"><i class="bi bi-basket3 me-2"></i>Varekøb & Salg</h2>
|
|
<div class="text-muted">Samlet oversigt over alle varelinjer på tværs af sager</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a class="btn btn-outline-primary" href="/sag"><i class="bi bi-arrow-left me-1"></i>Tilbage til sager</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-md-3">
|
|
<div class="summary-card">
|
|
<div class="summary-title">Total salg</div>
|
|
<div class="summary-value" id="summarySalesTotal">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="summary-card">
|
|
<div class="summary-title">Total køb</div>
|
|
<div class="summary-value" id="summaryPurchaseTotal">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="summary-card">
|
|
<div class="summary-title">Netto</div>
|
|
<div class="summary-value" id="summaryNetTotal">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="summary-card">
|
|
<div class="summary-title">Linjer (total)</div>
|
|
<div class="summary-value" id="summaryLinesTotal">-</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mb-4">
|
|
<div class="card-body">
|
|
<div class="row g-3 align-items-end">
|
|
<div class="col-md-4">
|
|
<label class="form-label">Søg</label>
|
|
<input type="text" class="form-control" id="ordersSearch" placeholder="Søg i beskrivelse, sag eller kunde">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Status</label>
|
|
<select class="form-select" id="ordersStatus">
|
|
<option value="">Alle</option>
|
|
<option value="draft">Kladde</option>
|
|
<option value="confirmed">Bekræftet</option>
|
|
<option value="cancelled">Annulleret</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Sag ID</label>
|
|
<input type="number" class="form-control" id="ordersCaseId" placeholder="F.eks. 12">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Kunde ID</label>
|
|
<input type="number" class="form-control" id="ordersCustomerId" placeholder="F.eks. 45">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Fra dato</label>
|
|
<input type="date" class="form-control" id="ordersDateFrom">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Til dato</label>
|
|
<input type="date" class="form-control" id="ordersDateTo">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<button class="btn btn-primary w-100" onclick="loadOrders()"><i class="bi bi-search me-1"></i>Filtrér</button>
|
|
</div>
|
|
<div class="col-md-3 text-end">
|
|
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-lg-6">
|
|
<div class="table-wrapper">
|
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-bag-check me-2"></i>Salgslinjer</h6>
|
|
<span class="badge bg-light text-dark border" id="salesSubtotal">-</span>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 30px;"></th>
|
|
<th>Dato</th>
|
|
<th>Beskrivelse</th>
|
|
<th>Kunde</th>
|
|
<th>Antal</th>
|
|
<th>Enhed</th>
|
|
<th>Enhedspris</th>
|
|
<th>Linjesum</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="ordersSalesBody">
|
|
<tr>
|
|
<td colspan="9" class="text-center py-4 text-muted">Indlæser...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-6">
|
|
<div class="table-wrapper">
|
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-cart-x me-2"></i>Indkøbslinjer</h6>
|
|
<span class="badge bg-light text-dark border" id="purchaseSubtotal">-</span>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 30px;"></th>
|
|
<th>Dato</th>
|
|
<th>Beskrivelse</th>
|
|
<th>Kunde</th>
|
|
<th>Antal</th>
|
|
<th>Enhed</th>
|
|
<th>Enhedspris</th>
|
|
<th>Linjesum</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="ordersPurchaseBody">
|
|
<tr>
|
|
<td colspan="9" class="text-center py-4 text-muted">Indlæser...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
function formatCurrency(value) {
|
|
const num = Number(value || 0);
|
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(num);
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
const num = Number(value || 0);
|
|
return new Intl.NumberFormat('da-DK', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(num);
|
|
}
|
|
|
|
function renderOrderRows(items, tbodyId) {
|
|
const tbody = document.getElementById(tbodyId);
|
|
if (!tbody) return;
|
|
if (!items.length) {
|
|
tbody.innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">Ingen linjer</td></tr>';
|
|
return;
|
|
}
|
|
|
|
// Group items by case (sag_id)
|
|
const grouped = {};
|
|
items.forEach((item, originalIndex) => {
|
|
const caseKey = item.sag_id || 'ingen-sag';
|
|
if (!grouped[caseKey]) {
|
|
grouped[caseKey] = {
|
|
sag_id: item.sag_id || null,
|
|
sag_titel: item.sag_titel || 'Ingen sag',
|
|
items: []
|
|
};
|
|
}
|
|
grouped[caseKey].items.push({ ...item, originalIndex });
|
|
});
|
|
|
|
// Render grouped rows with collapsible structure
|
|
let html = '';
|
|
Object.keys(grouped).forEach((caseKey, groupIndex) => {
|
|
const group = grouped[caseKey];
|
|
const groupTotal = group.items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
|
const tableId = tbodyId.replace('Body', ''); // Extract table identifier
|
|
const groupId = `${tableId}-case-${groupIndex}`;
|
|
|
|
// Case header row (clickable to expand/collapse)
|
|
const caseLink = group.sag_id
|
|
? `<a href="/sag/${group.sag_id}" class="text-decoration-none fw-bold" onclick="event.stopPropagation();">${group.sag_titel} <span class="badge bg-light text-dark border">Sag ${group.sag_id}</span></a>`
|
|
: `<span class="fw-bold">${group.sag_titel}</span>`;
|
|
|
|
html += `
|
|
<tr class="table-secondary" onclick="toggleCaseLines('${groupId}')">
|
|
<td>
|
|
<i class="bi bi-chevron-right expand-icon" id="icon-${groupId}"></i>
|
|
</td>
|
|
<td colspan="8">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>${caseLink}</div>
|
|
<div class="text-end">
|
|
<span class="badge bg-primary me-2">${group.items.length} ${group.items.length === 1 ? 'linje' : 'linjer'}</span>
|
|
<span class="fw-bold">${formatCurrency(groupTotal)}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
|
|
// Render lines in this case (hidden by default)
|
|
group.items.forEach(item => {
|
|
const statusLabel = item.status || 'draft';
|
|
html += `
|
|
<tr class="case-lines-container" data-case="${groupId}">
|
|
<td></td>
|
|
<td>${item.line_date || '-'}</td>
|
|
<td>${item.description || '-'}</td>
|
|
<td>${item.customer_name || '-'}</td>
|
|
<td>${item.quantity ?? '-'}</td>
|
|
<td>${item.unit || '-'}</td>
|
|
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
|
|
<td class="fw-bold">${formatCurrency(item.amount)}</td>
|
|
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
|
|
</tr>
|
|
`;
|
|
});
|
|
});
|
|
|
|
tbody.innerHTML = html;
|
|
}
|
|
|
|
function toggleCaseLines(caseId) {
|
|
const lines = document.querySelectorAll(`tr[data-case="${caseId}"]`);
|
|
const icon = document.getElementById(`icon-${caseId}`);
|
|
|
|
lines.forEach(line => {
|
|
line.classList.toggle('show');
|
|
});
|
|
|
|
if (icon) {
|
|
icon.classList.toggle('expanded');
|
|
}
|
|
}
|
|
|
|
async function loadOrders() {
|
|
const search = document.getElementById('ordersSearch').value.trim();
|
|
const status = document.getElementById('ordersStatus').value;
|
|
const caseId = document.getElementById('ordersCaseId').value;
|
|
const customerId = document.getElementById('ordersCustomerId').value;
|
|
const dateFrom = document.getElementById('ordersDateFrom').value;
|
|
const dateTo = document.getElementById('ordersDateTo').value;
|
|
const params = new URLSearchParams();
|
|
if (search) params.append('q', search);
|
|
if (status) params.append('status', status);
|
|
if (caseId) params.append('sag_id', caseId);
|
|
if (customerId) params.append('customer_id', customerId);
|
|
if (dateFrom) params.append('date_from', dateFrom);
|
|
if (dateTo) params.append('date_to', dateTo);
|
|
|
|
const res = await fetch(`/api/v1/sag/sale-items/all?${params.toString()}`);
|
|
if (!res.ok) {
|
|
document.getElementById('ordersSalesBody').innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
|
|
document.getElementById('ordersPurchaseBody').innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
|
|
const sales = data.filter(item => (item.type || '').toLowerCase() !== 'purchase');
|
|
const purchases = data.filter(item => (item.type || '').toLowerCase() === 'purchase');
|
|
|
|
renderOrderRows(sales, 'ordersSalesBody');
|
|
renderOrderRows(purchases, 'ordersPurchaseBody');
|
|
|
|
const salesSum = sales.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
|
const purchaseSum = purchases.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
|
|
|
document.getElementById('summarySalesTotal').textContent = formatCurrency(salesSum);
|
|
document.getElementById('summaryPurchaseTotal').textContent = formatCurrency(purchaseSum);
|
|
document.getElementById('summaryNetTotal').textContent = formatCurrency(salesSum - purchaseSum);
|
|
document.getElementById('summaryLinesTotal').textContent = formatNumber(data.length);
|
|
document.getElementById('salesSubtotal').textContent = formatCurrency(salesSum);
|
|
document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadOrders();
|
|
});
|
|
</script>
|
|
{% endblock %}
|