- Implemented subscription creation, updating, and rendering in script_9.js. - Added functions for handling subscription line items, product selection, and total calculations. - Integrated AnyDesk API for session management in test_anydesk.py. - Created REST client test requests for API endpoints in api.http. - Developed a script to check ESET machine status and save details in tmp_check_eset_machine.py.
544 lines
24 KiB
JavaScript
544 lines
24 KiB
JavaScript
|
|
const subscriptionCaseId = {{ case.id }};
|
|
let currentSubscription = null;
|
|
let subscriptionProducts = [];
|
|
let lastCreatedSubscriptionProductId = null;
|
|
|
|
function formatSubscriptionInterval(interval) {
|
|
const map = {
|
|
'daily': 'Daglig',
|
|
'biweekly': '14-dage',
|
|
'monthly': 'Maaned',
|
|
'quarterly': 'Kvartal',
|
|
'yearly': 'Aar'
|
|
};
|
|
return map[interval] || interval || '-';
|
|
}
|
|
|
|
function formatSubscriptionCurrency(amount) {
|
|
return new Intl.NumberFormat('da-DK', {
|
|
style: 'currency',
|
|
currency: 'DKK',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0
|
|
}).format(amount || 0);
|
|
}
|
|
|
|
function formatSubscriptionDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('da-DK');
|
|
}
|
|
|
|
function setSubscriptionBadge(status) {
|
|
const badge = document.getElementById('subscriptionStatusBadge');
|
|
if (!badge) return;
|
|
const classes = {
|
|
'draft': 'bg-light text-dark',
|
|
'active': 'bg-success',
|
|
'paused': 'bg-warning',
|
|
'cancelled': 'bg-secondary'
|
|
};
|
|
const label = {
|
|
'draft': 'Kladde',
|
|
'active': 'Aktiv',
|
|
'paused': 'Pauset',
|
|
'cancelled': 'Opsagt'
|
|
};
|
|
badge.className = `badge ${classes[status] || 'bg-light text-dark'}`;
|
|
badge.textContent = label[status] || status || 'Ingen';
|
|
}
|
|
|
|
function showSubscriptionCreateForm() {
|
|
const empty = document.getElementById('subscriptionEmpty');
|
|
const form = document.getElementById('subscriptionCreateForm');
|
|
const details = document.getElementById('subscriptionDetails');
|
|
if (empty) empty.classList.remove('d-none');
|
|
if (form) form.classList.remove('d-none');
|
|
if (details) details.classList.add('d-none');
|
|
setSubscriptionBadge(null);
|
|
|
|
const startDateInput = document.getElementById('subscriptionStartDateInput');
|
|
if (startDateInput && !startDateInput.value) {
|
|
startDateInput.value = new Date().toISOString().split('T')[0];
|
|
}
|
|
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|
if (body) {
|
|
body.innerHTML = `
|
|
<tr>
|
|
<td>
|
|
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
|
<option value="">Vælg produkt</option>
|
|
</select>
|
|
</td>
|
|
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
|
|
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
|
|
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
|
|
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
|
<td class="text-end">
|
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
populateSubscriptionProductSelects();
|
|
updateSubscriptionLineTotals();
|
|
}
|
|
|
|
function populateSubscriptionProductSelects() {
|
|
const selects = document.querySelectorAll('.subscriptionProductSelect');
|
|
selects.forEach(select => {
|
|
const currentValue = select.value;
|
|
select.innerHTML = '<option value="">Vælg produkt</option>';
|
|
subscriptionProducts.forEach(product => {
|
|
const option = document.createElement('option');
|
|
option.value = product.id;
|
|
option.textContent = product.name;
|
|
option.dataset.salesPrice = product.sales_price ?? '';
|
|
option.dataset.description = product.short_description ?? '';
|
|
select.appendChild(option);
|
|
});
|
|
if (currentValue) {
|
|
select.value = currentValue;
|
|
} else if (lastCreatedSubscriptionProductId) {
|
|
select.value = String(lastCreatedSubscriptionProductId);
|
|
}
|
|
});
|
|
lastCreatedSubscriptionProductId = null;
|
|
}
|
|
|
|
function applySubscriptionProduct(select) {
|
|
const row = select.closest('tr');
|
|
if (!row) return;
|
|
const descriptionInput = row.querySelector('input[type="text"]');
|
|
const unitPriceInput = row.querySelectorAll('input[type="number"]')[1];
|
|
const selected = select.options[select.selectedIndex];
|
|
if (!selected) return;
|
|
|
|
const description = selected.dataset.description || selected.textContent || '';
|
|
const salesPrice = selected.dataset.salesPrice;
|
|
|
|
if (descriptionInput && !descriptionInput.value.trim()) {
|
|
descriptionInput.value = description;
|
|
}
|
|
if (unitPriceInput && salesPrice !== '') {
|
|
unitPriceInput.value = salesPrice;
|
|
}
|
|
updateSubscriptionLineTotals();
|
|
}
|
|
|
|
function addSubscriptionLine() {
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|
if (!body) return;
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>
|
|
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
|
<option value="">Vælg produkt</option>
|
|
</select>
|
|
</td>
|
|
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse"></td>
|
|
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
|
|
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
|
|
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
|
<td class="text-end">
|
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
|
</td>
|
|
`;
|
|
body.appendChild(row);
|
|
populateSubscriptionProductSelects();
|
|
updateSubscriptionLineTotals();
|
|
}
|
|
|
|
function removeSubscriptionLine(button) {
|
|
const row = button.closest('tr');
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|
if (!row || !body) return;
|
|
if (body.children.length <= 1) {
|
|
row.querySelectorAll('input').forEach(input => {
|
|
input.value = input.type === 'number' ? 0 : '';
|
|
});
|
|
} else {
|
|
row.remove();
|
|
}
|
|
updateSubscriptionLineTotals();
|
|
}
|
|
|
|
function updateSubscriptionLineTotals() {
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|
const totalEl = document.getElementById('subscriptionLinesTotal');
|
|
if (!body || !totalEl) return;
|
|
|
|
let total = 0;
|
|
Array.from(body.querySelectorAll('tr')).forEach(row => {
|
|
const inputs = row.querySelectorAll('input');
|
|
const description = inputs[0]?.value || '';
|
|
const qty = parseFloat(inputs[1]?.value || 0);
|
|
const unit = parseFloat(inputs[2]?.value || 0);
|
|
const lineTotal = (qty > 0 ? qty : 0) * (unit > 0 ? unit : 0);
|
|
total += lineTotal;
|
|
const lineTotalEl = row.querySelector('.subscriptionLineTotal');
|
|
if (lineTotalEl) {
|
|
lineTotalEl.textContent = formatSubscriptionCurrency(lineTotal);
|
|
}
|
|
if (!description && qty === 0 && unit === 0) {
|
|
if (lineTotalEl) {
|
|
lineTotalEl.textContent = formatSubscriptionCurrency(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
totalEl.textContent = formatSubscriptionCurrency(total);
|
|
}
|
|
|
|
function collectSubscriptionLineItems() {
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|
if (!body) return [];
|
|
const items = [];
|
|
Array.from(body.querySelectorAll('tr')).forEach(row => {
|
|
const productSelect = row.querySelector('.subscriptionProductSelect');
|
|
const inputs = row.querySelectorAll('input');
|
|
const description = (inputs[0]?.value || '').trim();
|
|
const quantity = parseFloat(inputs[1]?.value || 0);
|
|
const unitPrice = parseFloat(inputs[2]?.value || 0);
|
|
if (!description && quantity === 0 && unitPrice === 0) {
|
|
return;
|
|
}
|
|
items.push({
|
|
product_id: productSelect && productSelect.value ? parseInt(productSelect.value, 10) : null,
|
|
description,
|
|
quantity,
|
|
unit_price: unitPrice
|
|
});
|
|
});
|
|
return items;
|
|
}
|
|
|
|
async function loadSubscriptionProducts() {
|
|
try {
|
|
const res = await fetch('/api/v1/products');
|
|
if (!res.ok) {
|
|
throw new Error('Kunne ikke hente produkter');
|
|
}
|
|
subscriptionProducts = await res.json();
|
|
} catch (e) {
|
|
console.error('Error loading products:', e);
|
|
subscriptionProducts = [];
|
|
}
|
|
populateSubscriptionProductSelects();
|
|
}
|
|
|
|
function openSubscriptionProductModal() {
|
|
const form = document.getElementById('subscriptionProductForm');
|
|
if (form) form.reset();
|
|
new bootstrap.Modal(document.getElementById('subscriptionProductModal')).show();
|
|
}
|
|
|
|
async function createSubscriptionProduct() {
|
|
const payload = {
|
|
name: document.getElementById('subscriptionProductName').value.trim(),
|
|
type: document.getElementById('subscriptionProductType').value.trim() || null,
|
|
status: document.getElementById('subscriptionProductStatus').value,
|
|
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
|
|
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
|
|
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null
|
|
};
|
|
|
|
if (!payload.name) {
|
|
alert('Navn er paakraevet');
|
|
return;
|
|
}
|
|
|
|
const res = await fetch('/api/v1/products', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
alert(error.detail || 'Kunne ikke oprette produkt');
|
|
return;
|
|
}
|
|
|
|
const product = await res.json();
|
|
lastCreatedSubscriptionProductId = product.id;
|
|
bootstrap.Modal.getInstance(document.getElementById('subscriptionProductModal')).hide();
|
|
await loadSubscriptionProducts();
|
|
updateSubscriptionLineTotals();
|
|
}
|
|
|
|
function renderSubscription(subscription) {
|
|
currentSubscription = subscription;
|
|
const empty = document.getElementById('subscriptionEmpty');
|
|
const form = document.getElementById('subscriptionCreateForm');
|
|
const details = document.getElementById('subscriptionDetails');
|
|
if (empty) empty.classList.add('d-none');
|
|
if (form) form.classList.add('d-none');
|
|
if (details) details.classList.remove('d-none');
|
|
|
|
document.getElementById('subscriptionNumber').textContent = subscription.subscription_number || `#${subscription.id}`;
|
|
document.getElementById('subscriptionProduct').textContent = subscription.product_name || '-';
|
|
document.getElementById('subscriptionInterval').textContent = formatSubscriptionInterval(subscription.billing_interval);
|
|
document.getElementById('subscriptionPrice').textContent = formatSubscriptionCurrency(subscription.price);
|
|
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
|
|
document.getElementById('subscriptionStatusText').textContent = subscription.status || '-';
|
|
|
|
// New fields
|
|
const periodStartEl = document.getElementById('subscriptionPeriodStart');
|
|
const nextInvoiceEl = document.getElementById('subscriptionNextInvoice');
|
|
if (periodStartEl) {
|
|
periodStartEl.textContent = subscription.period_start ? formatSubscriptionDate(subscription.period_start) : '-';
|
|
}
|
|
if (nextInvoiceEl) {
|
|
const nextDate = subscription.next_invoice_date ? formatSubscriptionDate(subscription.next_invoice_date) : '-';
|
|
nextInvoiceEl.textContent = nextDate;
|
|
// Highlight if invoice is due soon
|
|
if (subscription.next_invoice_date) {
|
|
const daysUntil = Math.ceil((new Date(subscription.next_invoice_date) - new Date()) / (1000 * 60 * 60 * 24));
|
|
if (daysUntil <= 7 && daysUntil >= 0) {
|
|
nextInvoiceEl.innerHTML = `${nextDate} <span class="badge bg-warning text-dark">Om ${daysUntil} dage</span>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
setSubscriptionBadge(subscription.status);
|
|
|
|
const itemsBody = document.getElementById('subscriptionItemsBody');
|
|
const itemsTotal = document.getElementById('subscriptionItemsTotal');
|
|
if (itemsBody) {
|
|
const items = subscription.line_items || [];
|
|
if (!items.length) {
|
|
itemsBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen linjer</td></tr>';
|
|
} else {
|
|
itemsBody.innerHTML = items.map(item => `
|
|
<tr>
|
|
<td>${item.product_name || '-'}</td>
|
|
<td>${item.description}</td>
|
|
<td class="text-end">${parseFloat(item.quantity).toFixed(2)}</td>
|
|
<td class="text-end">${formatSubscriptionCurrency(item.unit_price)}</td>
|
|
<td class="text-end">${formatSubscriptionCurrency(item.line_total)}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
}
|
|
if (itemsTotal) {
|
|
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0);
|
|
}
|
|
|
|
const actions = document.getElementById('subscriptionActions');
|
|
if (!actions) return;
|
|
|
|
const buttons = [];
|
|
if (subscription.status === 'draft' || subscription.status === 'paused') {
|
|
buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`);
|
|
}
|
|
if (subscription.status === 'active') {
|
|
buttons.push(`<button class="btn btn-sm btn-warning" onclick="updateSubscriptionStatus('paused')"><i class="bi bi-pause-circle me-1"></i>Pause</button>`);
|
|
}
|
|
if (subscription.status !== 'cancelled') {
|
|
buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`);
|
|
}
|
|
actions.innerHTML = buttons.join(' ');
|
|
}
|
|
|
|
async function loadSubscriptionForCase() {
|
|
try {
|
|
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`);
|
|
if (res.status === 404) {
|
|
showSubscriptionCreateForm();
|
|
setModuleContentState('subscription', false);
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error('Kunne ikke hente abonnement');
|
|
}
|
|
const subscription = await res.json();
|
|
renderSubscription(subscription);
|
|
setModuleContentState('subscription', true);
|
|
} catch (e) {
|
|
console.error('Error loading subscription:', e);
|
|
showSubscriptionCreateForm();
|
|
setModuleContentState('subscription', true);
|
|
}
|
|
}
|
|
|
|
async function createSubscription() {
|
|
const billingInterval = document.getElementById('subscriptionIntervalInput').value;
|
|
const billingDay = parseInt(document.getElementById('subscriptionBillingDayInput').value, 10);
|
|
const startDate = document.getElementById('subscriptionStartDateInput').value;
|
|
const notes = document.getElementById('subscriptionNotesInput').value.trim();
|
|
|
|
const lineItems = collectSubscriptionLineItems();
|
|
|
|
if (!billingInterval || !billingDay || !startDate) {
|
|
alert('Udfyld venligst alle paakraevet felter');
|
|
return;
|
|
}
|
|
if (!lineItems.length) {
|
|
alert('Du skal angive mindst en varelinje');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/v1/sag-subscriptions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sag_id: subscriptionCaseId,
|
|
billing_interval: billingInterval,
|
|
billing_day: billingDay,
|
|
start_date: startDate,
|
|
notes: notes || null,
|
|
line_items: lineItems
|
|
})
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Fejl ved oprettelse');
|
|
}
|
|
|
|
const subscription = await res.json();
|
|
renderSubscription(subscription);
|
|
} catch (e) {
|
|
alert(e.message || e);
|
|
}
|
|
}
|
|
|
|
async function updateSubscriptionStatus(status) {
|
|
if (!currentSubscription) return;
|
|
if (status === 'cancelled' && !confirm('Er du sikker paa, at abonnementet skal opsiges?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Kunne ikke opdatere status');
|
|
}
|
|
|
|
const updated = await res.json();
|
|
renderSubscription(updated);
|
|
} catch (e) {
|
|
alert(e.message || e);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadSubscriptionProducts();
|
|
loadSubscriptionForCase();
|
|
});
|
|
|
|
// === Quick Time Entry Functions (for inline time tracking) ===
|
|
function toggleQuickTimeForm() {
|
|
const container = document.getElementById('quickTimeFormContainer');
|
|
if (container) {
|
|
container.classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
// Make function globally available for onclick handler
|
|
window.toggleQuickTimeForm = toggleQuickTimeForm;
|
|
|
|
async function quickAddTime(event) {
|
|
event.preventDefault();
|
|
|
|
const form = document.getElementById('quickAddTimeForm');
|
|
const formData = new FormData(form);
|
|
|
|
// Parse hours and minutes
|
|
const hours = parseInt(formData.get('hours')) || 0;
|
|
const minutes = parseInt(formData.get('minutes')) || 0;
|
|
const totalHours = hours + (minutes / 60);
|
|
|
|
if (totalHours === 0) {
|
|
alert('Angiv venligst timer eller minutter');
|
|
return;
|
|
}
|
|
|
|
const billingSelect = document.getElementById('quickTimeBillingMethod');
|
|
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
|
|
let prepaidCardId = null;
|
|
let fixedPriceAgreementId = null;
|
|
|
|
if (billingMethod.startsWith('card_')) {
|
|
prepaidCardId = parseInt(billingMethod.split('_')[1]);
|
|
billingMethod = 'prepaid';
|
|
}
|
|
|
|
if (billingMethod.startsWith('fpa_')) {
|
|
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
|
|
billingMethod = 'fixed_price';
|
|
}
|
|
|
|
const isInternal = billingMethod === 'internal';
|
|
|
|
// Build payload
|
|
const payload = {
|
|
sag_id: {{ case.id }},
|
|
worked_date: formData.get('date'),
|
|
original_hours: totalHours,
|
|
description: formData.get('description'),
|
|
billing_method: billingMethod,
|
|
is_internal: isInternal
|
|
};
|
|
|
|
if (prepaidCardId) {
|
|
payload.prepaid_card_id = prepaidCardId;
|
|
}
|
|
|
|
if (fixedPriceAgreementId) {
|
|
payload.fixed_price_agreement_id = fixedPriceAgreementId;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/timetracking/entries/internal', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.detail || 'Kunne ikke gemme tidsregistrering');
|
|
}
|
|
|
|
// Success - reload page to show new entry
|
|
window.location.reload();
|
|
} catch (error) {
|
|
alert('Fejl: ' + error.message);
|
|
console.error('Quick add time error:', error);
|
|
}
|
|
}
|
|
|
|
// Set today's date as default for quick time form
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const dateInput = document.getElementById('quickTimeDate');
|
|
if (dateInput && !dateInput.value) {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
dateInput.value = today;
|
|
}
|
|
// Activate tab from ?tab= URL parameter (used when navigating from relation tree QA menu)
|
|
const tabParam = new URLSearchParams(window.location.search).get('tab');
|
|
if (tabParam) {
|
|
const tabBtn = document.getElementById(tabParam + '-tab')
|
|
|| document.querySelector(`[data-module-tab="${tabParam}"]`);
|
|
if (tabBtn) {
|
|
setTimeout(() => {
|
|
bootstrap.Tab.getOrCreateInstance(tabBtn).show();
|
|
forceCaseTabActivation(tabParam);
|
|
}, 300);
|
|
}
|
|
}
|
|
});
|
|
|