2025-12-13 12:06:28 +01:00
|
|
|
{% extends "shared/frontend/base.html" %}
|
2025-12-10 18:29:13 +01:00
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
{% block title %}Kunde Timepriser - BMC Hub{% endblock %}
|
2025-12-10 18:29:13 +01:00
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
{% block extra_css %}
|
|
|
|
|
<style>
|
|
|
|
|
/* Page specific styles */
|
2025-12-10 18:29:13 +01:00
|
|
|
|
|
|
|
|
.table-hover tbody tr:hover {
|
|
|
|
|
background-color: var(--accent-light);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.rate-input {
|
|
|
|
|
width: 150px;
|
|
|
|
|
text-align: right;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.editable-row {
|
|
|
|
|
transition: all 0.3s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.editable-row.editing {
|
|
|
|
|
background-color: #fff3cd !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.badge-rate {
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
padding: 0.4rem 0.8rem;
|
|
|
|
|
}
|
2025-12-13 12:06:28 +01:00
|
|
|
</style>
|
|
|
|
|
{% endblock %}
|
2025-12-10 18:29:13 +01:00
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
{% block content %}
|
|
|
|
|
<div class="container py-4">
|
2025-12-10 18:29:13 +01:00
|
|
|
<!-- 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-building text-primary"></i> Kunde Timepriser
|
|
|
|
|
</h1>
|
|
|
|
|
<p class="text-muted mb-0">Administrer timepriser for kunder</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span class="badge bg-info badge-rate">
|
|
|
|
|
<i class="bi bi-cash"></i> Standard: <span id="default-rate">850</span> DKK/time
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Stats Cards -->
|
|
|
|
|
<div class="row mb-4" id="stats-cards">
|
|
|
|
|
<div class="col-md-3">
|
|
|
|
|
<div class="card">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<h6 class="text-muted mb-2">Total Kunder</h6>
|
|
|
|
|
<h3 class="mb-0" id="total-customers">-</h3>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-3">
|
|
|
|
|
<div class="card">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<h6 class="text-muted mb-2">Custom Priser</h6>
|
|
|
|
|
<h3 class="mb-0" id="custom-rates">-</h3>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-3">
|
|
|
|
|
<div class="card">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<h6 class="text-muted mb-2">Standard Priser</h6>
|
|
|
|
|
<h3 class="mb-0" id="standard-rates">-</h3>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-3">
|
|
|
|
|
<div class="card">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<h6 class="text-muted mb-2">Gennemsnitspris</h6>
|
|
|
|
|
<h3 class="mb-0" id="avg-rate">-</h3>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Filters -->
|
|
|
|
|
<div class="row mb-3">
|
2025-12-23 14:31:10 +01:00
|
|
|
<div class="col-md-4">
|
2025-12-10 18:29:13 +01:00
|
|
|
<input type="text" class="form-control" id="search-input" placeholder="🔍 Søg kunde..." onkeyup="filterTable()">
|
|
|
|
|
</div>
|
2025-12-23 14:31:10 +01:00
|
|
|
<div class="col-md-4">
|
2025-12-10 18:29:13 +01:00
|
|
|
<select class="form-select" id="filter-select" onchange="filterTable()">
|
|
|
|
|
<option value="all">Alle kunder</option>
|
|
|
|
|
<option value="custom">Kun custom priser</option>
|
|
|
|
|
<option value="standard">Kun standard priser</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2025-12-23 14:31:10 +01:00
|
|
|
<div class="col-md-4">
|
|
|
|
|
<button class="btn btn-primary w-100" id="bulk-price-btn" onclick="openBulkPriceModal()" disabled>
|
|
|
|
|
<i class="bi bi-tag"></i> Opdater pris (<span id="selected-count">0</span> valgt)
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-12-10 18:29:13 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Customers Table -->
|
|
|
|
|
<div class="card">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
<table class="table table-hover" id="customers-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
2025-12-23 14:31:10 +01:00
|
|
|
<th style="width: 40px;">
|
|
|
|
|
<input type="checkbox" class="form-check-input" id="select-all" onchange="toggleSelectAll()">
|
|
|
|
|
</th>
|
2025-12-10 18:29:13 +01:00
|
|
|
<th>Kunde</th>
|
|
|
|
|
<th>vTiger ID</th>
|
|
|
|
|
<th class="text-end">Timepris (DKK)</th>
|
|
|
|
|
<th class="text-center">Status</th>
|
|
|
|
|
<th class="text-end">Handlinger</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="customers-tbody">
|
|
|
|
|
<tr>
|
|
|
|
|
<td colspan="5" class="text-center py-5">
|
|
|
|
|
<div class="spinner-border text-primary" role="status">
|
|
|
|
|
<span class="visually-hidden">Indlæser...</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Time Entries Modal -->
|
|
|
|
|
<div class="modal fade" id="timeEntriesModal" tabindex="-1">
|
|
|
|
|
<div class="modal-dialog modal-xl">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
<h5 class="modal-title">
|
|
|
|
|
<i class="bi bi-clock-history"></i> Tidsregistreringer - <span id="modal-customer-name"></span>
|
|
|
|
|
</h5>
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
<div id="time-entries-loading" class="text-center py-5">
|
|
|
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
|
|
|
<p class="mt-2">Indlæser tidsregistreringer...</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="time-entries-content" class="d-none">
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
<table class="table table-sm">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Case</th>
|
|
|
|
|
<th>Dato</th>
|
|
|
|
|
<th>Timer</th>
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
<th>Udført af</th>
|
|
|
|
|
<th>Handlinger</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="time-entries-tbody"></tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="time-entries-empty" class="alert alert-info d-none">
|
|
|
|
|
Ingen tidsregistreringer fundet for denne kunde
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Create Order Modal -->
|
|
|
|
|
<div class="modal fade" id="createOrderModal" tabindex="-1">
|
|
|
|
|
<div class="modal-dialog modal-lg">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
<h5 class="modal-title">
|
|
|
|
|
<i class="bi bi-plus-circle"></i> Opret ordre - <span id="order-customer-name"></span>
|
|
|
|
|
</h5>
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
<div id="order-loading" class="text-center py-5">
|
|
|
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
|
|
|
<p class="mt-2">Henter godkendte tidsregistreringer...</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="order-content" class="d-none">
|
|
|
|
|
<div class="alert alert-info">
|
|
|
|
|
<i class="bi bi-info-circle"></i> Denne handling vil oprette en ordre for <strong>alle godkendte</strong> tidsregistreringer for denne kunde.
|
|
|
|
|
</div>
|
|
|
|
|
<div id="order-summary" class="mb-3"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="order-empty" class="alert alert-warning d-none">
|
|
|
|
|
<i class="bi bi-exclamation-triangle"></i> Ingen godkendte tidsregistreringer fundet for denne kunde
|
|
|
|
|
</div>
|
|
|
|
|
<div id="order-creating" class="text-center py-5 d-none">
|
|
|
|
|
<div class="spinner-border text-success" role="status"></div>
|
|
|
|
|
<p class="mt-2">Opretter ordre...</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
|
|
|
<button type="button" class="btn btn-success" id="confirm-create-order" disabled>
|
|
|
|
|
<i class="bi bi-check-circle"></i> Opret ordre
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="spinner-border text-primary" role="status">
|
|
|
|
|
<span class="visually-hidden">Indlæser...</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-23 14:31:10 +01:00
|
|
|
<!-- Bulk Price Update Modal -->
|
|
|
|
|
<div class="modal fade" id="bulkPriceModal" tabindex="-1">
|
|
|
|
|
<div class="modal-dialog">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
<h5 class="modal-title">
|
|
|
|
|
<i class="bi bi-tag"></i> Opdater timepris for flere kunder
|
|
|
|
|
</h5>
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
<div class="alert alert-info">
|
|
|
|
|
<i class="bi bi-info-circle"></i> Du har valgt <strong><span id="bulk-customer-count">0</span> kunder</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<label for="bulk-price-input" class="form-label">Ny timepris (DKK)</label>
|
|
|
|
|
<input type="number" class="form-control" id="bulk-price-input"
|
|
|
|
|
min="0" step="50" placeholder="f.eks. 1200">
|
|
|
|
|
<div class="form-text">Indtast ny timepris for alle valgte kunder</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="bulk-selected-customers" class="mt-3">
|
|
|
|
|
<strong>Valgte kunder:</strong>
|
|
|
|
|
<ul id="bulk-customer-list" class="mt-2" style="max-height: 200px; overflow-y: auto;"></ul>
|
|
|
|
|
</div>
|
|
|
|
|
</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="updateBulkPrices()">
|
|
|
|
|
<i class="bi bi-check-circle"></i> Opdater priser
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-10 01:37:08 +01:00
|
|
|
<!-- Data Consistency Comparison Modal -->
|
|
|
|
|
<div class="modal fade" id="consistencyModal" tabindex="-1" aria-labelledby="consistencyModalLabel" aria-hidden="true">
|
|
|
|
|
<div class="modal-dialog modal-xl">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
<h5 class="modal-title" id="consistencyModalLabel">
|
|
|
|
|
<i class="bi bi-diagram-3 me-2"></i>Sammenlign Kundedata
|
|
|
|
|
</h5>
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
<div class="alert alert-info">
|
|
|
|
|
<i class="bi bi-info-circle me-2"></i>
|
|
|
|
|
<strong>Vejledning:</strong> Vælg den korrekte værdi for hvert felt med uoverensstemmelser.
|
|
|
|
|
Når du klikker "Synkroniser Valgte", vil de valgte værdier blive opdateret i alle systemer før ordren oprettes.
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
<table class="table table-hover">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th style="width: 20%;">Felt</th>
|
|
|
|
|
<th style="width: 20%;">BMC Hub</th>
|
|
|
|
|
<th style="width: 20%;">vTiger</th>
|
|
|
|
|
<th style="width: 20%;">e-conomic</th>
|
|
|
|
|
<th style="width: 20%;">Vælg Korrekt</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="consistencyTableBody">
|
|
|
|
|
<!-- Populated by JavaScript -->
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="skipConsistencyCheck()">
|
|
|
|
|
<i class="bi bi-x-circle me-2"></i>Spring Over
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" class="btn btn-primary" onclick="syncSelectedFields()">
|
|
|
|
|
<i class="bi bi-arrow-repeat me-2"></i>Synkroniser Valgte
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-10 18:29:13 +01:00
|
|
|
<script>
|
|
|
|
|
let allCustomers = [];
|
|
|
|
|
let defaultRate = 850.00; // Fallback værdi
|
2025-12-23 14:31:10 +01:00
|
|
|
let selectedCustomers = new Set(); // Track selected customer IDs
|
2026-01-10 01:37:08 +01:00
|
|
|
let consistencyData = null; // Store consistency check data
|
|
|
|
|
let pendingOrderCustomerId = null; // Store customer ID for pending order
|
2025-12-10 18:29:13 +01:00
|
|
|
|
|
|
|
|
// Load customers on page load
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
loadConfig();
|
|
|
|
|
loadCustomers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Load configuration
|
|
|
|
|
async function loadConfig() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/v1/timetracking/config');
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const config = await response.json();
|
|
|
|
|
defaultRate = config.default_hourly_rate;
|
|
|
|
|
document.getElementById('default-rate').textContent = defaultRate.toFixed(2);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to load config, using fallback rate:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load all customers
|
|
|
|
|
async function loadCustomers() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/v1/timetracking/customers?include_time_card=true');
|
|
|
|
|
if (!response.ok) throw new Error('Failed to load customers');
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
allCustomers = data.customers || [];
|
|
|
|
|
|
|
|
|
|
renderTable();
|
|
|
|
|
updateStats();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error loading customers:', error);
|
|
|
|
|
document.getElementById('customers-tbody').innerHTML = `
|
|
|
|
|
<tr><td colspan="5" class="text-center text-danger">Fejl ved indlæsning: ${error.message}</td></tr>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Render table
|
|
|
|
|
function renderTable(filteredCustomers = null) {
|
|
|
|
|
const customers = filteredCustomers || allCustomers;
|
|
|
|
|
const tbody = document.getElementById('customers-tbody');
|
|
|
|
|
|
|
|
|
|
if (customers.length === 0) {
|
2025-12-23 14:31:10 +01:00
|
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4">Ingen kunder fundet</td></tr>';
|
2025-12-10 18:29:13 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = customers.map(customer => {
|
|
|
|
|
const rate = customer.hourly_rate || defaultRate;
|
|
|
|
|
const isCustom = customer.hourly_rate !== null;
|
|
|
|
|
const statusBadge = isCustom
|
|
|
|
|
? '<span class="badge bg-primary">Custom</span>'
|
|
|
|
|
: '<span class="badge bg-secondary">Standard</span>';
|
2025-12-23 14:31:10 +01:00
|
|
|
const isChecked = selectedCustomers.has(customer.id) ? 'checked' : '';
|
2025-12-10 18:29:13 +01:00
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<tr class="editable-row" id="row-${customer.id}">
|
2025-12-23 14:31:10 +01:00
|
|
|
<td>
|
|
|
|
|
<input type="checkbox" class="form-check-input customer-checkbox"
|
|
|
|
|
data-customer-id="${customer.id}"
|
|
|
|
|
data-customer-name="${customer.name.replace(/'/g, "\\'")}"
|
|
|
|
|
onchange="toggleCustomerSelection(${customer.id})"
|
|
|
|
|
${isChecked}>
|
|
|
|
|
</td>
|
2025-12-10 18:29:13 +01:00
|
|
|
<td style="cursor: pointer;" onclick="viewTimeEntries(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
|
|
|
|
|
<strong>${customer.name}</strong>
|
|
|
|
|
${customer.uses_time_card ? '<span class="badge bg-warning text-dark ms-2">Klippekort</span>' : ''}
|
|
|
|
|
</td>
|
|
|
|
|
<td><small class="text-muted">${customer.vtiger_id || '-'}</small></td>
|
|
|
|
|
<td class="text-end">
|
|
|
|
|
<span class="rate-display" id="rate-display-${customer.id}">${rate.toFixed(2)}</span>
|
|
|
|
|
<input type="number" class="form-control rate-input d-none"
|
|
|
|
|
id="rate-input-${customer.id}"
|
|
|
|
|
value="${rate}"
|
|
|
|
|
step="50"
|
|
|
|
|
min="0">
|
|
|
|
|
</td>
|
|
|
|
|
<td class="text-center">${statusBadge}</td>
|
|
|
|
|
<td class="text-end">
|
|
|
|
|
<button class="btn btn-sm btn-success me-1"
|
|
|
|
|
onclick="createOrderForCustomer(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
|
|
|
|
|
<i class="bi bi-plus-circle"></i> Ordre
|
|
|
|
|
</button>
|
|
|
|
|
<button class="btn btn-sm btn-info me-1"
|
|
|
|
|
onclick="viewTimeEntries(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
|
|
|
|
|
<i class="bi bi-clock-history"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="btn btn-sm btn-primary edit-btn"
|
|
|
|
|
id="edit-btn-${customer.id}"
|
|
|
|
|
onclick="editRate(${customer.id})">
|
|
|
|
|
<i class="bi bi-pencil"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="btn btn-sm btn-success save-btn d-none"
|
|
|
|
|
id="save-btn-${customer.id}"
|
|
|
|
|
onclick="saveRate(${customer.id})">
|
|
|
|
|
<i class="bi bi-check"></i> Gem
|
|
|
|
|
</button>
|
|
|
|
|
<button class="btn btn-sm btn-secondary cancel-btn d-none"
|
|
|
|
|
id="cancel-btn-${customer.id}"
|
|
|
|
|
onclick="cancelEdit(${customer.id})">
|
|
|
|
|
<i class="bi bi-x"></i> Annuller
|
|
|
|
|
</button>
|
|
|
|
|
${isCustom ? `
|
|
|
|
|
<button class="btn btn-sm btn-outline-danger"
|
|
|
|
|
onclick="resetToDefault(${customer.id})">
|
|
|
|
|
<i class="bi bi-arrow-counterclockwise"></i>
|
|
|
|
|
</button>
|
|
|
|
|
` : ''}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Edit rate
|
|
|
|
|
function editRate(customerId) {
|
|
|
|
|
const row = document.getElementById(`row-${customerId}`);
|
|
|
|
|
row.classList.add('editing');
|
|
|
|
|
|
|
|
|
|
document.getElementById(`rate-display-${customerId}`).classList.add('d-none');
|
|
|
|
|
document.getElementById(`rate-input-${customerId}`).classList.remove('d-none');
|
|
|
|
|
document.getElementById(`rate-input-${customerId}`).focus();
|
|
|
|
|
|
|
|
|
|
document.getElementById(`edit-btn-${customerId}`).classList.add('d-none');
|
|
|
|
|
document.getElementById(`save-btn-${customerId}`).classList.remove('d-none');
|
|
|
|
|
document.getElementById(`cancel-btn-${customerId}`).classList.remove('d-none');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cancel edit
|
|
|
|
|
function cancelEdit(customerId) {
|
|
|
|
|
const row = document.getElementById(`row-${customerId}`);
|
|
|
|
|
row.classList.remove('editing');
|
|
|
|
|
|
|
|
|
|
const customer = allCustomers.find(c => c.id === customerId);
|
|
|
|
|
const originalRate = customer.hourly_rate || defaultRate;
|
|
|
|
|
|
|
|
|
|
document.getElementById(`rate-input-${customerId}`).value = originalRate;
|
|
|
|
|
document.getElementById(`rate-display-${customerId}`).classList.remove('d-none');
|
|
|
|
|
document.getElementById(`rate-input-${customerId}`).classList.add('d-none');
|
|
|
|
|
|
|
|
|
|
document.getElementById(`edit-btn-${customerId}`).classList.remove('d-none');
|
|
|
|
|
document.getElementById(`save-btn-${customerId}`).classList.add('d-none');
|
|
|
|
|
document.getElementById(`cancel-btn-${customerId}`).classList.add('d-none');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save rate
|
|
|
|
|
async function saveRate(customerId) {
|
|
|
|
|
const newRate = parseFloat(document.getElementById(`rate-input-${customerId}`).value);
|
|
|
|
|
|
|
|
|
|
if (newRate < 0) {
|
|
|
|
|
alert('Timepris kan ikke være negativ');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/hourly-rate?hourly_rate=${newRate}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: {'Content-Type': 'application/json'}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) throw new Error('Failed to update rate');
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
// Update local data
|
|
|
|
|
const customer = allCustomers.find(c => c.id === customerId);
|
|
|
|
|
customer.hourly_rate = newRate;
|
|
|
|
|
|
|
|
|
|
// Update display
|
|
|
|
|
document.getElementById(`rate-display-${customerId}`).textContent = newRate.toFixed(2);
|
|
|
|
|
cancelEdit(customerId);
|
|
|
|
|
|
|
|
|
|
// Reload to update badges
|
|
|
|
|
await loadCustomers();
|
|
|
|
|
|
|
|
|
|
// Show success message
|
|
|
|
|
showToast('✅ Timepris opdateret', 'success');
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error saving rate:', error);
|
|
|
|
|
alert('Fejl ved opdatering: ' + error.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset to default
|
|
|
|
|
async function resetToDefault(customerId) {
|
|
|
|
|
if (!confirm('Nulstil til standard timepris?')) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/hourly-rate?hourly_rate=${defaultRate}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: {'Content-Type': 'application/json'}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) throw new Error('Failed to reset rate');
|
|
|
|
|
|
|
|
|
|
// Update local data
|
|
|
|
|
const customer = allCustomers.find(c => c.id === customerId);
|
|
|
|
|
customer.hourly_rate = null; // NULL = uses default
|
|
|
|
|
|
|
|
|
|
await loadCustomers();
|
|
|
|
|
showToast('✅ Nulstillet til standard', 'success');
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error resetting rate:', error);
|
|
|
|
|
alert('Fejl ved nulstilling: ' + error.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filter table
|
|
|
|
|
function filterTable() {
|
|
|
|
|
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
|
|
|
|
const filterType = document.getElementById('filter-select').value;
|
|
|
|
|
|
|
|
|
|
let filtered = allCustomers.filter(customer => {
|
|
|
|
|
const matchesSearch = customer.name.toLowerCase().includes(searchTerm);
|
|
|
|
|
|
|
|
|
|
let matchesFilter = true;
|
|
|
|
|
if (filterType === 'custom') {
|
|
|
|
|
matchesFilter = customer.hourly_rate !== null;
|
|
|
|
|
} else if (filterType === 'standard') {
|
|
|
|
|
matchesFilter = customer.hourly_rate === null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return matchesSearch && matchesFilter;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
renderTable(filtered);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
function updateStats() {
|
|
|
|
|
const total = allCustomers.length;
|
|
|
|
|
const customRates = allCustomers.filter(c => c.hourly_rate !== null).length;
|
|
|
|
|
const standardRates = total - customRates;
|
|
|
|
|
|
|
|
|
|
const rates = allCustomers.map(c => c.hourly_rate || defaultRate);
|
|
|
|
|
const avgRate = rates.reduce((sum, r) => sum + r, 0) / total;
|
|
|
|
|
|
|
|
|
|
document.getElementById('total-customers').textContent = total;
|
|
|
|
|
document.getElementById('custom-rates').textContent = customRates;
|
|
|
|
|
document.getElementById('standard-rates').textContent = standardRates;
|
|
|
|
|
document.getElementById('avg-rate').textContent = avgRate.toFixed(2) + ' DKK';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Toast notification
|
|
|
|
|
function showToast(message, type = 'info') {
|
|
|
|
|
const toast = document.createElement('div');
|
|
|
|
|
toast.className = `alert alert-${type} position-fixed top-0 end-0 m-3`;
|
|
|
|
|
toast.style.zIndex = 9999;
|
|
|
|
|
toast.textContent = message;
|
|
|
|
|
document.body.appendChild(toast);
|
|
|
|
|
setTimeout(() => toast.remove(), 3000);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
// Store current customer ID for modal actions
|
|
|
|
|
let currentModalCustomerId = null;
|
|
|
|
|
|
2025-12-10 18:29:13 +01:00
|
|
|
// View time entries for customer
|
|
|
|
|
async function viewTimeEntries(customerId, customerName) {
|
2025-12-13 12:06:28 +01:00
|
|
|
currentModalCustomerId = customerId;
|
2025-12-10 18:29:13 +01:00
|
|
|
document.getElementById('modal-customer-name').textContent = customerName;
|
|
|
|
|
document.getElementById('time-entries-loading').classList.remove('d-none');
|
|
|
|
|
document.getElementById('time-entries-content').classList.add('d-none');
|
|
|
|
|
document.getElementById('time-entries-empty').classList.add('d-none');
|
|
|
|
|
|
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('timeEntriesModal'));
|
|
|
|
|
modal.show();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
|
|
|
|
|
if (!response.ok) throw new Error('Failed to load time entries');
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
const entries = data.times || [];
|
|
|
|
|
|
|
|
|
|
document.getElementById('time-entries-loading').classList.add('d-none');
|
|
|
|
|
|
|
|
|
|
if (entries.length === 0) {
|
|
|
|
|
document.getElementById('time-entries-empty').classList.remove('d-none');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tbody = document.getElementById('time-entries-tbody');
|
|
|
|
|
tbody.innerHTML = entries.map(entry => {
|
|
|
|
|
const date = new Date(entry.worked_date).toLocaleDateString('da-DK');
|
|
|
|
|
const statusBadge = {
|
|
|
|
|
'pending': '<span class="badge bg-warning">Afventer</span>',
|
|
|
|
|
'approved': '<span class="badge bg-success">Godkendt</span>',
|
|
|
|
|
'rejected': '<span class="badge bg-danger">Afvist</span>',
|
|
|
|
|
'billed': '<span class="badge bg-info">Faktureret</span>'
|
|
|
|
|
}[entry.status] || entry.status;
|
|
|
|
|
|
|
|
|
|
// Build case link
|
|
|
|
|
let caseLink = entry.case_title || 'Ingen case';
|
|
|
|
|
if (entry.case_vtiger_id) {
|
|
|
|
|
const recordId = entry.case_vtiger_id.split('x')[1];
|
|
|
|
|
const vtigerUrl = `https://bmcnetworks.od2.vtiger.com/view/detail?module=Cases&id=${recordId}&viewtype=summary`;
|
|
|
|
|
caseLink = `<a href="${vtigerUrl}" target="_blank" class="text-decoration-none">
|
|
|
|
|
${entry.case_title || 'Case'} <i class="bi bi-box-arrow-up-right"></i>
|
|
|
|
|
</a>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<tr>
|
|
|
|
|
<td>${caseLink}</td>
|
|
|
|
|
<td>${date}</td>
|
2025-12-13 12:06:28 +01:00
|
|
|
<td>
|
|
|
|
|
<strong>${entry.original_hours}t</strong>
|
|
|
|
|
${entry.approved_hours && entry.status === 'approved' ? `
|
|
|
|
|
<br><small class="text-muted">
|
|
|
|
|
Oprundet: <strong>${entry.approved_hours}t</strong>
|
|
|
|
|
${entry.rounded_to ? ` (${entry.rounded_to}t)` : ''}
|
|
|
|
|
</small>
|
|
|
|
|
` : ''}
|
|
|
|
|
</td>
|
2025-12-10 18:29:13 +01:00
|
|
|
<td>${statusBadge}</td>
|
|
|
|
|
<td>${entry.user_name || 'Ukendt'}</td>
|
|
|
|
|
<td>
|
|
|
|
|
${entry.status === 'pending' ? `
|
2025-12-13 12:06:28 +01:00
|
|
|
<a href="/timetracking/wizard?customer_id=${currentModalCustomerId}&time_id=${entry.id}" class="btn btn-sm btn-success">
|
2025-12-10 18:29:13 +01:00
|
|
|
<i class="bi bi-check"></i> Godkend
|
2025-12-13 12:06:28 +01:00
|
|
|
</a>
|
2025-12-10 18:29:13 +01:00
|
|
|
` : ''}
|
|
|
|
|
${entry.status === 'approved' && !entry.billed ? `
|
|
|
|
|
<button class="btn btn-sm btn-outline-danger" onclick="resetTimeEntry(${entry.id})">
|
|
|
|
|
<i class="bi bi-arrow-counterclockwise"></i> Nulstil
|
|
|
|
|
</button>
|
|
|
|
|
` : ''}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
document.getElementById('time-entries-content').classList.remove('d-none');
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error loading time entries:', error);
|
|
|
|
|
document.getElementById('time-entries-loading').classList.add('d-none');
|
|
|
|
|
showToast('Fejl ved indlæsning af tidsregistreringer', 'danger');
|
|
|
|
|
modal.hide();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Approve time entry
|
|
|
|
|
async function approveTimeEntry(timeId) {
|
|
|
|
|
if (!confirm('Godkend denne tidsregistrering?')) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/timetracking/wizard/approve/${timeId}`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {'Content-Type': 'application/json'}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) throw new Error('Failed to approve');
|
|
|
|
|
|
|
|
|
|
showToast('✅ Tidsregistrering godkendt', 'success');
|
|
|
|
|
// Reload modal content
|
|
|
|
|
const modalCustomerId = document.getElementById('modal-customer-name').textContent;
|
|
|
|
|
const customer = allCustomers.find(c => c.name === modalCustomerId);
|
|
|
|
|
if (customer) {
|
|
|
|
|
viewTimeEntries(customer.id, customer.name);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error approving:', error);
|
|
|
|
|
showToast('Fejl ved godkendelse', 'danger');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset time entry back to pending
|
|
|
|
|
async function resetTimeEntry(timeId) {
|
2025-12-13 12:06:28 +01:00
|
|
|
if (!confirm('Nulstil denne tidsregistrering tilbage til pending?\n\nDen vil blive sat tilbage i godkendelses-køen.')) return;
|
2025-12-10 18:29:13 +01:00
|
|
|
|
|
|
|
|
try {
|
2025-12-13 12:06:28 +01:00
|
|
|
const response = await fetch(`/api/v1/timetracking/wizard/reset/${timeId}?reason=${encodeURIComponent('Reset til pending')}`, {
|
2025-12-10 18:29:13 +01:00
|
|
|
method: 'POST',
|
2025-12-13 12:06:28 +01:00
|
|
|
headers: {'Content-Type': 'application/json'}
|
2025-12-10 18:29:13 +01:00
|
|
|
});
|
|
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new Error(error.detail || 'Failed to reset');
|
|
|
|
|
}
|
2025-12-10 18:29:13 +01:00
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
showToast('✅ Tidsregistrering nulstillet til pending', 'success');
|
2025-12-10 18:29:13 +01:00
|
|
|
// Reload modal content
|
|
|
|
|
const modalCustomerId = document.getElementById('modal-customer-name').textContent;
|
|
|
|
|
const customer = allCustomers.find(c => c.name === modalCustomerId);
|
|
|
|
|
if (customer) {
|
|
|
|
|
viewTimeEntries(customer.id, customer.name);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error resetting:', error);
|
|
|
|
|
showToast('Fejl ved nulstilling', 'danger');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create order for customer
|
|
|
|
|
let currentOrderCustomerId = null;
|
|
|
|
|
|
|
|
|
|
async function createOrderForCustomer(customerId, customerName) {
|
|
|
|
|
currentOrderCustomerId = customerId;
|
2026-01-10 01:37:08 +01:00
|
|
|
pendingOrderCustomerId = customerId;
|
2025-12-10 18:29:13 +01:00
|
|
|
document.getElementById('order-customer-name').textContent = customerName;
|
|
|
|
|
document.getElementById('order-loading').classList.remove('d-none');
|
|
|
|
|
document.getElementById('order-content').classList.add('d-none');
|
|
|
|
|
document.getElementById('order-empty').classList.add('d-none');
|
|
|
|
|
document.getElementById('order-creating').classList.add('d-none');
|
|
|
|
|
document.getElementById('confirm-create-order').disabled = true;
|
|
|
|
|
|
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('createOrderModal'));
|
|
|
|
|
modal.show();
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-10 01:37:08 +01:00
|
|
|
// 🔍 STEP 1: Check data consistency first
|
|
|
|
|
const consistencyResponse = await fetch(`/api/v1/timetracking/customers/${customerId}/data-consistency`);
|
|
|
|
|
const consistency = await consistencyResponse.json();
|
|
|
|
|
|
|
|
|
|
// If consistency check is enabled and there are discrepancies, show them first
|
|
|
|
|
if (consistency.enabled && consistency.discrepancy_count > 0) {
|
|
|
|
|
consistencyData = consistency;
|
|
|
|
|
modal.hide(); // Hide order modal
|
|
|
|
|
showConsistencyModal(); // Show consistency modal
|
|
|
|
|
return; // Wait for user to sync fields
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// STEP 2: If no discrepancies (or check disabled), proceed with order creation
|
|
|
|
|
await loadOrderPreview(customerId, customerName);
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error checking consistency or loading order preview:', error);
|
|
|
|
|
document.getElementById('order-loading').classList.add('d-none');
|
|
|
|
|
showToast('Fejl ved indlæsning: ' + error.message, 'danger');
|
|
|
|
|
modal.hide();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadOrderPreview(customerId, customerName) {
|
|
|
|
|
// This is the original order preview logic extracted into a separate function
|
|
|
|
|
try {
|
|
|
|
|
// Show order modal if not already showing
|
|
|
|
|
const orderModal = bootstrap.Modal.getInstance(document.getElementById('createOrderModal')) ||
|
|
|
|
|
new bootstrap.Modal(document.getElementById('createOrderModal'));
|
|
|
|
|
|
|
|
|
|
// Reset states
|
|
|
|
|
document.getElementById('order-loading').classList.remove('d-none');
|
|
|
|
|
document.getElementById('order-content').classList.add('d-none');
|
|
|
|
|
document.getElementById('order-empty').classList.add('d-none');
|
|
|
|
|
document.getElementById('order-creating').classList.add('d-none');
|
|
|
|
|
document.getElementById('confirm-create-order').disabled = true;
|
|
|
|
|
|
2025-12-10 18:29:13 +01:00
|
|
|
// Fetch customer's approved time entries
|
|
|
|
|
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
|
|
|
|
|
if (!response.ok) throw new Error('Failed to load time entries');
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
// Filter for approved and billable entries
|
|
|
|
|
const approvedEntries = (data.times || []).filter(entry =>
|
|
|
|
|
entry.status === 'approved' && entry.billable !== false
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
document.getElementById('order-loading').classList.add('d-none');
|
|
|
|
|
|
|
|
|
|
if (approvedEntries.length === 0) {
|
|
|
|
|
document.getElementById('order-empty').classList.remove('d-none');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build summary
|
|
|
|
|
const totalHours = approvedEntries.reduce((sum, entry) =>
|
|
|
|
|
sum + parseFloat(entry.approved_hours || entry.original_hours || 0), 0
|
|
|
|
|
);
|
|
|
|
|
const customer = allCustomers.find(c => c.id === customerId);
|
|
|
|
|
const hourlyRate = customer?.hourly_rate || defaultRate;
|
|
|
|
|
const subtotal = totalHours * hourlyRate;
|
|
|
|
|
const vat = subtotal * 0.25;
|
|
|
|
|
const total = subtotal + vat;
|
|
|
|
|
|
|
|
|
|
// Group by case
|
|
|
|
|
const caseGroups = {};
|
|
|
|
|
approvedEntries.forEach(entry => {
|
|
|
|
|
const caseId = entry.case_id || 'no_case';
|
|
|
|
|
if (!caseGroups[caseId]) {
|
|
|
|
|
caseGroups[caseId] = {
|
|
|
|
|
title: entry.case_title || 'Ingen case',
|
|
|
|
|
entries: []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
caseGroups[caseId].entries.push(entry);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const summaryHtml = `
|
|
|
|
|
<div class="card mb-3">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<h6 class="card-title mb-3">Ordre oversigt</h6>
|
|
|
|
|
<div class="row mb-3">
|
|
|
|
|
<div class="col-6">
|
|
|
|
|
<strong>Antal godkendte tider:</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 text-end">
|
|
|
|
|
${approvedEntries.length} stk
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row mb-3">
|
|
|
|
|
<div class="col-6">
|
|
|
|
|
<strong>Total timer:</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 text-end">
|
|
|
|
|
${totalHours.toFixed(2)} timer
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row mb-3">
|
|
|
|
|
<div class="col-6">
|
|
|
|
|
<strong>Timepris:</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 text-end">
|
|
|
|
|
${hourlyRate.toFixed(2)} DKK
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<hr>
|
|
|
|
|
<div class="row mb-2">
|
|
|
|
|
<div class="col-6">
|
|
|
|
|
<strong>Subtotal (ekskl. moms):</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 text-end">
|
|
|
|
|
${subtotal.toFixed(2)} DKK
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row mb-2">
|
|
|
|
|
<div class="col-6">
|
|
|
|
|
Moms (25%):
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 text-end">
|
|
|
|
|
${vat.toFixed(2)} DKK
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div class="col-6">
|
|
|
|
|
<strong>Total (inkl. moms):</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-6 text-end">
|
|
|
|
|
<strong>${total.toFixed(2)} DKK</strong>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<h6 class="card-title mb-3">Cases inkluderet</h6>
|
|
|
|
|
${Object.entries(caseGroups).map(([caseId, group]) => `
|
|
|
|
|
<div class="mb-2">
|
|
|
|
|
<strong>${group.title}</strong>
|
|
|
|
|
<span class="badge bg-secondary">${group.entries.length} tidsregistreringer</span>
|
|
|
|
|
<span class="badge bg-info">${group.entries.reduce((sum, e) => sum + parseFloat(e.approved_hours || e.original_hours || 0), 0).toFixed(2)} timer</span>
|
|
|
|
|
</div>
|
|
|
|
|
`).join('')}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
document.getElementById('order-summary').innerHTML = summaryHtml;
|
|
|
|
|
document.getElementById('order-content').classList.remove('d-none');
|
|
|
|
|
document.getElementById('confirm-create-order').disabled = false;
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error loading order preview:', error);
|
|
|
|
|
document.getElementById('order-loading').classList.add('d-none');
|
|
|
|
|
showToast('Fejl ved indlæsning af ordre forhåndsvisning', 'danger');
|
|
|
|
|
modal.hide();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Confirm order creation
|
|
|
|
|
document.getElementById('confirm-create-order')?.addEventListener('click', async function() {
|
|
|
|
|
if (!currentOrderCustomerId) return;
|
|
|
|
|
|
|
|
|
|
// Hide summary, show creating state
|
|
|
|
|
document.getElementById('order-content').classList.add('d-none');
|
|
|
|
|
document.getElementById('order-creating').classList.remove('d-none');
|
|
|
|
|
this.disabled = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/timetracking/orders/generate/${currentOrderCustomerId}`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {'Content-Type': 'application/json'}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorData = await response.json();
|
|
|
|
|
throw new Error(errorData.detail || 'Failed to create order');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const order = await response.json();
|
|
|
|
|
|
|
|
|
|
// Close modal
|
|
|
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createOrderModal'));
|
|
|
|
|
modal.hide();
|
|
|
|
|
|
|
|
|
|
// Show success and redirect
|
|
|
|
|
showToast(`✅ Ordre ${order.order_number} oprettet!`, 'success');
|
|
|
|
|
|
|
|
|
|
// Reload customers to update stats
|
|
|
|
|
await loadCustomers();
|
|
|
|
|
|
|
|
|
|
// Redirect to order detail after 1 second
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
window.location.href = `/timetracking/orders?order_id=${order.id}`;
|
|
|
|
|
}, 1500);
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error creating order:', error);
|
|
|
|
|
document.getElementById('order-creating').classList.add('d-none');
|
|
|
|
|
document.getElementById('order-content').classList.remove('d-none');
|
|
|
|
|
this.disabled = false;
|
|
|
|
|
showToast(`Fejl ved oprettelse af ordre: ${error.message}`, 'danger');
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-23 14:31:10 +01:00
|
|
|
|
|
|
|
|
// Bulk selection functions
|
|
|
|
|
function toggleCustomerSelection(customerId) {
|
|
|
|
|
if (selectedCustomers.has(customerId)) {
|
|
|
|
|
selectedCustomers.delete(customerId);
|
|
|
|
|
} else {
|
|
|
|
|
selectedCustomers.add(customerId);
|
|
|
|
|
}
|
|
|
|
|
updateBulkUI();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleSelectAll() {
|
|
|
|
|
const selectAllCheckbox = document.getElementById('select-all');
|
|
|
|
|
const checkboxes = document.querySelectorAll('.customer-checkbox');
|
|
|
|
|
|
|
|
|
|
if (selectAllCheckbox.checked) {
|
|
|
|
|
checkboxes.forEach(cb => {
|
|
|
|
|
selectedCustomers.add(parseInt(cb.dataset.customerId));
|
|
|
|
|
cb.checked = true;
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
selectedCustomers.clear();
|
|
|
|
|
checkboxes.forEach(cb => cb.checked = false);
|
|
|
|
|
}
|
|
|
|
|
updateBulkUI();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateBulkUI() {
|
|
|
|
|
const count = selectedCustomers.size;
|
|
|
|
|
document.getElementById('selected-count').textContent = count;
|
|
|
|
|
document.getElementById('bulk-price-btn').disabled = count === 0;
|
|
|
|
|
|
|
|
|
|
// Update select-all checkbox state
|
|
|
|
|
const totalVisible = document.querySelectorAll('.customer-checkbox').length;
|
|
|
|
|
const selectAllCheckbox = document.getElementById('select-all');
|
|
|
|
|
if (selectAllCheckbox) {
|
|
|
|
|
selectAllCheckbox.checked = count > 0 && count === totalVisible;
|
|
|
|
|
selectAllCheckbox.indeterminate = count > 0 && count < totalVisible;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openBulkPriceModal() {
|
|
|
|
|
if (selectedCustomers.size === 0) return;
|
|
|
|
|
|
|
|
|
|
// Update customer count
|
|
|
|
|
document.getElementById('bulk-customer-count').textContent = selectedCustomers.size;
|
|
|
|
|
|
|
|
|
|
// Build list of selected customers
|
|
|
|
|
const customerList = document.getElementById('bulk-customer-list');
|
|
|
|
|
const selectedCustomerData = allCustomers.filter(c => selectedCustomers.has(c.id));
|
|
|
|
|
|
|
|
|
|
customerList.innerHTML = selectedCustomerData.map(customer =>
|
|
|
|
|
`<li>${customer.name} (nuværende: ${(customer.hourly_rate || defaultRate).toFixed(2)} DKK)</li>`
|
|
|
|
|
).join('');
|
|
|
|
|
|
|
|
|
|
// Clear previous input
|
|
|
|
|
document.getElementById('bulk-price-input').value = '';
|
|
|
|
|
|
|
|
|
|
// Show modal
|
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('bulkPriceModal'));
|
|
|
|
|
modal.show();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateBulkPrices() {
|
|
|
|
|
const newPrice = parseFloat(document.getElementById('bulk-price-input').value);
|
|
|
|
|
|
|
|
|
|
if (!newPrice || newPrice < 0) {
|
|
|
|
|
showToast('Indtast venligst en gyldig pris', 'warning');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const customerIds = Array.from(selectedCustomers);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-23 15:03:49 +01:00
|
|
|
// Use window.location.origin to ensure absolute URL
|
|
|
|
|
const apiUrl = `${window.location.origin}/api/v1/timetracking/customers/bulk-update-rate`;
|
|
|
|
|
const response = await fetch(apiUrl, {
|
2025-12-23 14:31:10 +01:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
customer_ids: customerIds,
|
|
|
|
|
hourly_rate: newPrice
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorData = await response.json();
|
|
|
|
|
throw new Error(errorData.detail || 'Fejl ved opdatering');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
// Close modal
|
|
|
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('bulkPriceModal'));
|
|
|
|
|
modal.hide();
|
|
|
|
|
|
|
|
|
|
// Clear selection
|
|
|
|
|
selectedCustomers.clear();
|
|
|
|
|
document.getElementById('select-all').checked = false;
|
|
|
|
|
|
|
|
|
|
// Reload data
|
|
|
|
|
await loadCustomers();
|
|
|
|
|
|
|
|
|
|
showToast(`✅ Opdateret pris for ${result.updated} kunder`, 'success');
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error updating bulk prices:', error);
|
|
|
|
|
showToast(`Fejl ved opdatering: ${error.message}`, 'danger');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-10 01:37:08 +01:00
|
|
|
|
|
|
|
|
// Data Consistency Functions
|
|
|
|
|
function showConsistencyModal() {
|
|
|
|
|
if (!consistencyData) {
|
|
|
|
|
console.error('No consistency data available');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tbody = document.getElementById('consistencyTableBody');
|
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
// Field labels in Danish
|
|
|
|
|
const fieldLabels = {
|
|
|
|
|
'name': 'Navn',
|
|
|
|
|
'cvr_number': 'CVR Nummer',
|
|
|
|
|
'address': 'Adresse',
|
|
|
|
|
'city': 'By',
|
|
|
|
|
'postal_code': 'Postnummer',
|
|
|
|
|
'country': 'Land',
|
|
|
|
|
'phone': 'Telefon',
|
|
|
|
|
'mobile_phone': 'Mobil',
|
|
|
|
|
'email': 'Email',
|
|
|
|
|
'website': 'Hjemmeside',
|
|
|
|
|
'invoice_email': 'Faktura Email'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Only show fields with discrepancies
|
|
|
|
|
for (const [fieldName, fieldData] of Object.entries(consistencyData.discrepancies)) {
|
|
|
|
|
if (!fieldData.discrepancy) continue;
|
|
|
|
|
|
|
|
|
|
const row = document.createElement('tr');
|
|
|
|
|
row.className = 'table-warning';
|
|
|
|
|
|
|
|
|
|
// Field name
|
|
|
|
|
const fieldCell = document.createElement('td');
|
|
|
|
|
fieldCell.innerHTML = `<strong>${fieldLabels[fieldName] || fieldName}</strong>`;
|
|
|
|
|
row.appendChild(fieldCell);
|
|
|
|
|
|
|
|
|
|
// Hub value
|
|
|
|
|
const hubCell = document.createElement('td');
|
|
|
|
|
hubCell.innerHTML = `
|
|
|
|
|
<div class="form-check">
|
|
|
|
|
<input class="form-check-input" type="radio" name="field_${fieldName}"
|
|
|
|
|
id="hub_${fieldName}" value="hub" data-value="${fieldData.hub || ''}">
|
|
|
|
|
<label class="form-check-label" for="hub_${fieldName}">
|
|
|
|
|
${fieldData.hub || '<em class="text-muted">Tom</em>'}
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
row.appendChild(hubCell);
|
|
|
|
|
|
|
|
|
|
// vTiger value
|
|
|
|
|
const vtigerCell = document.createElement('td');
|
|
|
|
|
if (consistencyData.systems_available.vtiger) {
|
|
|
|
|
vtigerCell.innerHTML = `
|
|
|
|
|
<div class="form-check">
|
|
|
|
|
<input class="form-check-input" type="radio" name="field_${fieldName}"
|
|
|
|
|
id="vtiger_${fieldName}" value="vtiger" data-value="${fieldData.vtiger || ''}">
|
|
|
|
|
<label class="form-check-label" for="vtiger_${fieldName}">
|
|
|
|
|
${fieldData.vtiger || '<em class="text-muted">Tom</em>'}
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
} else {
|
|
|
|
|
vtigerCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
|
|
|
|
|
}
|
|
|
|
|
row.appendChild(vtigerCell);
|
|
|
|
|
|
|
|
|
|
// e-conomic value
|
|
|
|
|
const economicCell = document.createElement('td');
|
|
|
|
|
if (consistencyData.systems_available.economic) {
|
|
|
|
|
economicCell.innerHTML = `
|
|
|
|
|
<div class="form-check">
|
|
|
|
|
<input class="form-check-input" type="radio" name="field_${fieldName}"
|
|
|
|
|
id="economic_${fieldName}" value="economic" data-value="${fieldData.economic || ''}">
|
|
|
|
|
<label class="form-check-label" for="economic_${fieldName}">
|
|
|
|
|
${fieldData.economic || '<em class="text-muted">Tom</em>'}
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
} else {
|
|
|
|
|
economicCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
|
|
|
|
|
}
|
|
|
|
|
row.appendChild(economicCell);
|
|
|
|
|
|
|
|
|
|
// Action cell (which system to use)
|
|
|
|
|
const actionCell = document.createElement('td');
|
|
|
|
|
actionCell.innerHTML = '<span class="text-muted">← Vælg</span>';
|
|
|
|
|
row.appendChild(actionCell);
|
|
|
|
|
|
|
|
|
|
tbody.appendChild(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('consistencyModal'));
|
|
|
|
|
modal.show();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function syncSelectedFields() {
|
|
|
|
|
const selections = [];
|
|
|
|
|
|
|
|
|
|
// Gather all selected values
|
|
|
|
|
const radioButtons = document.querySelectorAll('#consistencyTableBody input[type="radio"]:checked');
|
|
|
|
|
|
|
|
|
|
if (radioButtons.length === 0) {
|
|
|
|
|
alert('Vælg venligst mindst ét felt at synkronisere, eller klik "Spring Over"');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
radioButtons.forEach(radio => {
|
|
|
|
|
const fieldName = radio.name.replace('field_', '');
|
|
|
|
|
const sourceSystem = radio.value;
|
|
|
|
|
const sourceValue = radio.dataset.value;
|
|
|
|
|
|
|
|
|
|
selections.push({
|
|
|
|
|
field_name: fieldName,
|
|
|
|
|
source_system: sourceSystem,
|
|
|
|
|
source_value: sourceValue
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Confirm action
|
|
|
|
|
if (!confirm(`Du er ved at synkronisere ${selections.length} felt(er) på tværs af alle systemer. Fortsæt?`)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sync each field
|
|
|
|
|
let successCount = 0;
|
|
|
|
|
let failCount = 0;
|
|
|
|
|
|
|
|
|
|
for (const selection of selections) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`/api/v1/timetracking/customers/${pendingOrderCustomerId}/sync-field`,
|
|
|
|
|
{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
|
|
|
|
body: JSON.stringify(selection)
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
successCount++;
|
|
|
|
|
} else {
|
|
|
|
|
failCount++;
|
|
|
|
|
console.error(`Failed to sync ${selection.field_name}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
failCount++;
|
|
|
|
|
console.error(`Error syncing ${selection.field_name}:`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Close consistency modal
|
|
|
|
|
const consistencyModal = bootstrap.Modal.getInstance(document.getElementById('consistencyModal'));
|
|
|
|
|
consistencyModal.hide();
|
|
|
|
|
|
|
|
|
|
// Show result
|
|
|
|
|
if (failCount === 0) {
|
|
|
|
|
showToast(`✓ ${successCount} felt(er) synkroniseret succesfuldt!`, 'success');
|
|
|
|
|
} else {
|
|
|
|
|
showToast(`⚠️ ${successCount} felt(er) synkroniseret, ${failCount} fejlede`, 'warning');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Now proceed with order creation - reopen order modal and load preview
|
|
|
|
|
const customerName = allCustomers.find(c => c.id === pendingOrderCustomerId)?.name || 'Kunde';
|
|
|
|
|
const orderModal = new bootstrap.Modal(document.getElementById('createOrderModal'));
|
|
|
|
|
document.getElementById('order-customer-name').textContent = customerName;
|
|
|
|
|
orderModal.show();
|
|
|
|
|
await loadOrderPreview(pendingOrderCustomerId, customerName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function skipConsistencyCheck() {
|
|
|
|
|
// User chose to skip consistency check and proceed with order anyway
|
|
|
|
|
const customerName = allCustomers.find(c => c.id === pendingOrderCustomerId)?.name || 'Kunde';
|
|
|
|
|
const orderModal = new bootstrap.Modal(document.getElementById('createOrderModal'));
|
|
|
|
|
document.getElementById('order-customer-name').textContent = customerName;
|
|
|
|
|
orderModal.show();
|
|
|
|
|
loadOrderPreview(pendingOrderCustomerId, customerName);
|
|
|
|
|
}
|
2025-12-10 18:29:13 +01:00
|
|
|
</script>
|
2025-12-13 12:06:28 +01:00
|
|
|
</div>
|
|
|
|
|
{% endblock %}
|