bmc_hub/app/timetracking/frontend/orders.html
Christian a230071632 feat: Add customer time pricing management page with dynamic features
- Implemented a new HTML page for managing customer time pricing with Bootstrap styling.
- Added navigation and responsive design elements.
- Integrated JavaScript for loading customer data, editing rates, and handling modals for time entries and order creation.
- Included theme toggle functionality and statistics display for customer rates.
- Enhanced user experience with toast notifications for actions performed.

docs: Create e-conomic Write Mode guide

- Added comprehensive documentation for exporting approved time entries to e-conomic as draft orders.
- Detailed safety flags for write operations, including read-only and dry-run modes.
- Provided activation steps, error handling, and best practices for using the e-conomic integration.

migrations: Add user_company field to contacts and e-conomic customer number to customers

- Created migration to add user_company field to contacts for better organization tracking.
- Added e-conomic customer number field to tmodule_customers for invoice export synchronization.
2025-12-10 18:29:13 +01:00

575 lines
24 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.

<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ordrer - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--success: #28a745;
--warning: #ffc107;
--danger: #dc3545;
--border-radius: 12px;
}
[data-theme="dark"] {
--bg-body: #1a1a1a;
--bg-card: #2d2d2d;
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--accent-light: #1e3a52;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
transition: background-color 0.3s, color 0.3s;
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
padding: 1rem 0;
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
}
.nav-link:hover {
background-color: var(--accent-light);
color: var(--accent);
}
.nav-link.active {
background-color: var(--accent);
color: white;
font-weight: 600;
}
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
background: var(--bg-card);
margin-bottom: 1.5rem;
}
.table {
background: var(--bg-card);
}
.table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
border-bottom: 2px solid var(--accent-light);
}
.order-row {
cursor: pointer;
transition: background-color 0.2s;
}
.order-row:hover {
background-color: var(--accent-light);
}
.order-details {
background: var(--accent-light);
border-radius: var(--border-radius);
padding: 1.5rem;
margin-top: 1rem;
}
.line-item {
padding: 0.75rem;
background: var(--bg-card);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.modal-body .info-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--accent-light);
}
.modal-body .info-row:last-child {
border-bottom: none;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/dashboard">
<i class="bi bi-grid-3x3-gap-fill"></i> BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/dashboard">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking">
<i class="bi bi-clock-history"></i> Oversigt
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking/wizard">
<i class="bi bi-check-circle"></i> Godkend Tider
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking/customers">
<i class="bi bi-building"></i> Kunder & Priser
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/timetracking/orders">
<i class="bi bi-receipt"></i> Ordrer
</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<button class="btn btn-link nav-link" onclick="toggleTheme()">
<i class="bi bi-moon-fill" id="theme-icon"></i>
</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="mb-1">
<i class="bi bi-receipt text-primary"></i> Ordrer
</h1>
<p class="text-muted mb-0">Oversigt over genererede ordrer og eksport til e-conomic</p>
</div>
<a href="/timetracking" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Tilbage
</a>
</div>
</div>
</div>
<!-- Safety Banner -->
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="bi bi-shield-exclamation me-2"></i>
<div>
<strong>DRY-RUN Mode Aktiv</strong> -
Eksport til e-conomic er i test-mode. Fakturaer oprettes ikke i e-conomic.
</div>
</div>
</div>
</div>
<!-- Orders Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Alle Ordrer</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadOrders()">
<i class="bi bi-arrow-clockwise"></i> Opdater
</button>
</div>
<div class="card-body">
<div id="loading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</div>
<div id="orders-table" class="d-none">
<table class="table table-hover">
<thead>
<tr>
<th>Ordrenr.</th>
<th>Kunde</th>
<th>Dato</th>
<th class="text-center">Linjer</th>
<th class="text-end">Total</th>
<th class="text-center">Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="orders-tbody">
</tbody>
</table>
</div>
<div id="no-orders" class="text-center py-5 d-none">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">Ingen ordrer endnu</p>
<a href="/timetracking" class="btn btn-primary">
<i class="bi bi-arrow-left"></i> Godkend tider først
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Order Details Modal -->
<div class="modal fade" id="orderModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-receipt"></i> Ordre Detaljer
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="order-details-content">
<!-- Will be populated dynamically -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-success" id="export-order-btn" onclick="exportCurrentOrder()">
<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
let currentOrderId = null;
let orderModal = null;
// Theme toggle
function toggleTheme() {
const html = document.documentElement;
const icon = document.getElementById('theme-icon');
if (html.getAttribute('data-theme') === 'dark') {
html.removeAttribute('data-theme');
icon.className = 'bi bi-moon-fill';
localStorage.setItem('theme', 'light');
} else {
html.setAttribute('data-theme', 'dark');
icon.className = 'bi bi-sun-fill';
localStorage.setItem('theme', 'dark');
}
}
// Load saved theme
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('theme-icon').className = 'bi bi-sun-fill';
}
// Initialize modal
document.addEventListener('DOMContentLoaded', function() {
orderModal = new bootstrap.Modal(document.getElementById('orderModal'));
loadOrders();
});
// Load all orders
async function loadOrders() {
document.getElementById('loading').classList.remove('d-none');
document.getElementById('orders-table').classList.add('d-none');
document.getElementById('no-orders').classList.add('d-none');
try {
const response = await fetch('/api/v1/timetracking/orders');
const orders = await response.json();
if (orders.length === 0) {
document.getElementById('loading').classList.add('d-none');
document.getElementById('no-orders').classList.remove('d-none');
return;
}
const tbody = document.getElementById('orders-tbody');
tbody.innerHTML = orders.map(order => {
const statusBadge = getStatusBadge(order);
const exportedIcon = order.exported_to_economic
? '<i class="bi bi-check-circle text-success" title="Eksporteret"></i>'
: '';
return `
<tr class="order-row" onclick="viewOrder(${order.id})">
<td>
<strong>${order.order_number}</strong>
${exportedIcon}
</td>
<td>${order.customer_name}</td>
<td>${new Date(order.order_date).toLocaleDateString('da-DK')}</td>
<td class="text-center">${order.line_count || 0}</td>
<td class="text-end"><strong>${parseFloat(order.total_amount).toFixed(2)} DKK</strong></td>
<td class="text-center">${statusBadge}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); viewOrder(${order.id})">
<i class="bi bi-eye"></i>
</button>
${!order.exported_to_economic ? `
<button class="btn btn-sm btn-success"
onclick="event.stopPropagation(); exportOrder(${order.id})">
<i class="bi bi-cloud-upload"></i>
</button>
` : ''}
</td>
</tr>
`;
}).join('');
document.getElementById('loading').classList.add('d-none');
document.getElementById('orders-table').classList.remove('d-none');
} catch (error) {
console.error('Error loading orders:', error);
document.getElementById('loading').innerHTML = `
<div class="alert alert-danger">
Fejl ved indlæsning: ${error.message}
</div>
`;
}
}
// Get status badge
function getStatusBadge(order) {
if (order.cancelled_at) {
return '<span class="badge bg-danger">Annulleret</span>';
}
if (order.exported_to_economic) {
return '<span class="badge bg-success">Eksporteret</span>';
}
return '<span class="badge bg-warning">Pending</span>';
}
// View order details
async function viewOrder(orderId) {
currentOrderId = orderId;
try {
const response = await fetch(`/api/v1/timetracking/orders/${orderId}`);
const order = await response.json();
const content = document.getElementById('order-details-content');
content.innerHTML = `
<div class="info-row">
<span class="fw-bold">Ordrenummer:</span>
<span>${order.order_number}</span>
</div>
<div class="info-row">
<span class="fw-bold">Kunde:</span>
<span>${order.customer_name}</span>
</div>
<div class="info-row">
<span class="fw-bold">Dato:</span>
<span>${new Date(order.order_date).toLocaleDateString('da-DK')}</span>
</div>
<div class="info-row">
<span class="fw-bold">Total:</span>
<span class="fs-5 fw-bold text-primary">${parseFloat(order.total_amount).toFixed(2)} DKK</span>
</div>
<hr class="my-3">
<h6 class="mb-3">Ordrelinjer:</h6>
${order.lines.map(line => {
// Parse data
const caseMatch = line.description.match(/CC(\d+)/);
const caseTitle = line.description.split(' - ').slice(1).join(' - ') || line.description;
const hours = parseFloat(line.quantity);
const unitPrice = parseFloat(line.unit_price);
const total = parseFloat(line.line_total);
const date = new Date(line.time_date).toLocaleDateString('da-DK');
// Extract contact name from case_contact if available
const contactName = line.case_contact || 'Ingen kontakt';
// Check if it's an on-site visit (udkørsel)
const isOnSite = line.description.toLowerCase().includes('udkørsel') ||
line.description.toLowerCase().includes('on-site');
return `
<div class="line-item mb-3 p-3" style="border: 1px solid #dee2e6; border-radius: 8px;">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
${caseMatch ? `<span class="badge bg-secondary">${caseMatch[0]}</span>` : ''}
<span class="fw-bold">${hours.toFixed(1)} timer</span>
<span class="text-muted">×</span>
<span>${unitPrice.toFixed(2)} DKK</span>
</div>
<div class="fw-bold text-uppercase mb-1" style="font-size: 0.95rem;">
${caseTitle}
</div>
<div class="text-muted small">
${date} - ${contactName}${isOnSite ? ' <span class="badge bg-info">Udkørsel</span>' : ''}
</div>
</div>
<div class="text-end">
<div class="fs-5 fw-bold text-primary">${total.toFixed(2)} DKK</div>
</div>
</div>
</div>
`;
}).join('')}
${order.economic_draft_id ? `
<div class="alert alert-success mt-3 mb-0">
<i class="bi bi-check-circle"></i>
Eksporteret til e-conomic den ${new Date(order.exported_at).toLocaleDateString('da-DK')}
<br>Draft Order nr.: ${order.economic_draft_id}
${order.economic_order_number ? `<br>e-conomic ordre nr.: ${order.economic_order_number}` : ''}
</div>
` : ''}
`;
// Update export button
const exportBtn = document.getElementById('export-order-btn');
if (order.economic_draft_id) {
exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Re-eksporter (force)';
exportBtn.onclick = () => {
if (confirm('Re-eksporter ordre til e-conomic?\n\nDette vil overskrive den eksisterende draft order.')) {
exportOrderForce(currentOrderId);
}
};
} else {
exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic';
exportBtn.onclick = exportCurrentOrder;
}
orderModal.show();
} catch (error) {
alert('Fejl ved indlæsning af ordre: ' + error.message);
}
}
// Export order
async function exportOrder(orderId) {
if (!confirm('Eksporter ordre til e-conomic?\n\nDette opretter en kladde-ordre i e-conomic.')) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
order_id: orderId,
force: false
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Export failed');
}
const result = await response.json();
if (result.dry_run) {
alert(`DRY-RUN MODE:\n\n${result.message}\n\nDetails:\n- Ordre: ${result.details.order_number}\n- Kunde: ${result.details.customer_name}\n- Total: ${result.details.total_amount} DKK\n- Linjer: ${result.details.line_count}\n\n⚠️ Ingen ændringer er foretaget i e-conomic (DRY-RUN mode aktiveret).`);
} else if (result.success) {
alert(`✅ Ordre eksporteret til e-conomic!\n\n- Draft Order nr.: ${result.economic_draft_id}\n- e-conomic ordre nr.: ${result.economic_order_number}\n\n${result.message}`);
loadOrders();
if (orderModal._isShown) {
orderModal.hide();
}
} else {
throw new Error(result.message || 'Export failed');
}
} catch (error) {
alert('Fejl ved eksport: ' + error.message);
}
}
// Export current order from modal
function exportCurrentOrder() {
if (currentOrderId) {
exportOrder(currentOrderId);
}
}
// Force re-export order
async function exportOrderForce(orderId) {
try {
const response = await fetch(`/api/v1/timetracking/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
order_id: orderId,
force: true
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Export failed');
}
const result = await response.json();
if (result.dry_run) {
alert(`DRY-RUN MODE:\n\n${result.message}\n\n⚠️ Ingen ændringer er foretaget i e-conomic (DRY-RUN mode aktiveret).`);
} else if (result.success) {
alert(`✅ Ordre re-eksporteret til e-conomic!\n\n- Draft Order nr.: ${result.economic_draft_id}\n- e-conomic ordre nr.: ${result.economic_order_number}`);
loadOrders();
if (orderModal._isShown) {
orderModal.hide();
}
} else {
throw new Error(result.message || 'Export failed');
}
} catch (error) {
alert('Fejl ved eksport: ' + error.message);
}
}
</script>
</body>
</html>