bmc_hub/app/timetracking/frontend/service_contract_wizard.html

1432 lines
47 KiB
HTML
Raw Permalink Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Service Contract Migration Wizard - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.wizard-header {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
color: white;
padding: 2rem 1.5rem;
border-radius: var(--border-radius);
margin-bottom: 2rem;
}
.wizard-header h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 700;
}
.wizard-header .subtitle {
font-size: 0.95rem;
opacity: 0.9;
margin-top: 0.5rem;
}
.dry-run-toggle {
display: flex;
align-items: center;
gap: 1rem;
background: #fff3cd;
border: 1px solid #ffc107;
padding: 1rem 1.5rem;
border-radius: var(--border-radius);
margin-bottom: 2rem;
}
.dry-run-toggle input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.dry-run-toggle label {
margin: 0;
cursor: pointer;
flex: 1;
}
.dry-run-badge {
display: inline-block;
background: #ffc107;
color: #000;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
margin-left: 0.5rem;
}
.progress-container {
background: white;
padding: 2rem 1.5rem;
border-radius: var(--border-radius);
margin-bottom: 2rem;
border: 1px solid var(--border-color);
}
.progress-bar-container {
margin-bottom: 1rem;
}
.progress-bar {
height: 8px;
background-color: var(--accent);
}
.progress-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 0.3rem;
}
.contract-selector {
background: white;
padding: 2rem 1.5rem;
border-radius: var(--border-radius);
margin-bottom: 2rem;
border: 1px solid var(--border-color);
}
.contract-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 1.5rem;
}
.info-field {
display: flex;
flex-direction: column;
}
.info-field label {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.info-field input,
.info-field select {
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 1rem;
}
.field-hint {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 0.4rem;
}
.current-item-card {
background: white;
border: 2px solid var(--accent);
border-radius: var(--border-radius);
padding: 2rem 1.5rem;
margin-bottom: 2rem;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 1.5rem;
}
.item-type-badge {
display: inline-block;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.item-type-badge.case {
background: #e3f2fd;
color: #1976d2;
}
.item-type-badge.timelog {
background: #f3e5f5;
color: #7b1fa2;
}
.item-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.item-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
padding: 1.5rem 0;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.detail-item {
display: flex;
flex-direction: column;
}
.detail-label {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 600;
margin-bottom: 0.3rem;
}
.detail-value {
font-size: 1rem;
color: var(--text-primary);
}
.description-box {
background: var(--accent-light);
padding: 1rem;
border-radius: 6px;
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 1.5rem;
}
.klippekort-selector {
background: #f5f5f5;
padding: 1.5rem;
border-radius: 6px;
margin-bottom: 1.5rem;
}
.klippekort-selector label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-secondary);
}
.klippekort-selector select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 1rem;
}
.klippekort-hint {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.cases-table-card {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 1.5rem;
margin-bottom: 2rem;
}
.cases-table-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.cases-table-header h3 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
font-weight: 700;
}
.cases-count {
font-size: 0.9rem;
color: var(--text-secondary);
}
.cases-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.cases-actions label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.cases-table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
.cases-table th,
.cases-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--border-color);
text-align: left;
vertical-align: top;
}
.cases-table th {
font-weight: 600;
color: var(--text-secondary);
background: #f8f9fa;
}
.cases-table tbody tr:hover {
background: #fafafa;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.btn-primary, .btn-secondary, .btn-danger {
padding: 0.8rem 1.5rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s ease;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-dark);
}
.btn-secondary {
background: #f5f5f5;
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: #e9e9e9;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.loading-spinner {
text-align: center;
padding: 2rem;
}
.spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.summary-card {
background: white;
border: 2px solid var(--border-color);
border-radius: var(--border-radius);
padding: 2rem 1.5rem;
}
.summary-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.summary-header .icon {
font-size: 2.5rem;
color: var(--accent);
}
.summary-header.error .icon {
color: #dc3545;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.summary-stat {
background: var(--accent-light);
padding: 1.5rem;
border-radius: 8px;
text-align: center;
}
.summary-stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
}
.summary-stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.message-box {
padding: 1rem 1.5rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.message-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.empty-state {
text-align: center;
padding: 3rem 2rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.state-title {
margin: 0;
font-size: 1.2rem;
color: var(--text-primary);
}
.state-subtitle {
margin: 0.5rem 0 0 0;
font-size: 0.95rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="wizard-header">
<h1>Service Contract Migration Wizard</h1>
<div class="subtitle">Migrerer cases og timelogs fra vTiger service contracts til Hub</div>
</div>
<!-- Dry-Run Toggle -->
<div class="dry-run-toggle">
<input type="checkbox" id="dryRunToggle" checked>
<label for="dryRunToggle">
🔍 Preview Mode (ingen ændringer vil blive gemt)
</label>
</div>
<!-- Main Content -->
<div id="mainContent">
<!-- Contract Selection -->
<div class="contract-selector">
<h3 style="margin-top: 0;">Vælg Service Contract</h3>
<div class="contract-info">
<div class="info-field">
<label for="contractSelect">Service Contract *</label>
<select id="contractSelect" required>
<option value="">-- Vælg contract --</option>
</select>
</div>
<div class="info-field">
<label for="customerSelect">Kunde (Hub) *</label>
<select id="customerSelect">
<option value="">-- Vælg kunde --</option>
</select>
<div class="field-hint" id="customerSelectHint">
Vælg kunde hvis kontrakten ikke er koblet i Hub.
</div>
</div>
</div>
</div>
<!-- Progress -->
<div id="progressSection" style="display: none;">
<div class="progress-container">
<div class="progress-bar-container">
<div class="progress">
<div class="progress-bar" id="progressBar" style="width: 0%;"></div>
</div>
</div>
<div class="progress-stats">
<div class="stat-item">
<div class="stat-value" id="totalProcessed">0</div>
<div class="stat-label">Behandlet</div>
</div>
<div class="stat-item">
<div class="stat-value" id="casesArchived">0</div>
<div class="stat-label">Cases arkiveret</div>
</div>
<div class="stat-item">
<div class="stat-value" id="timersTransferred">0</div>
<div class="stat-label">Timer overført</div>
</div>
</div>
</div>
<!-- Cases Table -->
<div id="casesTableSection" style="display: none;">
<div class="cases-table-card">
<div class="cases-table-header">
<div>
<h3>Cases</h3>
<div class="cases-count">Antal: <span id="casesCount">0</span></div>
</div>
<div class="cases-actions">
<label>
<input type="checkbox" id="casesSelectAll">
Vælg alle
</label>
<button class="btn-secondary" id="casesApproveBtn" onclick="approveSelectedCases()">
Godkend markerede
</button>
<button class="btn-primary" id="casesContinueBtn" onclick="startTimelogFlow()">
Fortsæt til timelogs
</button>
</div>
</div>
<div class="table-responsive">
<table class="cases-table">
<thead>
<tr>
<th></th>
<th>Titel</th>
<th>Status</th>
<th>Prioritet</th>
<th>vTiger ID</th>
</tr>
</thead>
<tbody id="casesTableBody"></tbody>
</table>
</div>
</div>
</div>
<!-- Timelogs Table -->
<div id="timelogsTableSection" style="display: none;">
<div class="cases-table-card">
<div class="cases-table-header">
<div>
<h3>Timelogs</h3>
<div class="cases-count">Antal: <span id="timelogsCount">0</span></div>
</div>
<div class="cases-actions">
<label>
<input type="checkbox" id="timelogsSelectAll">
Vælg alle
</label>
<button class="btn-secondary" id="timelogsTransferBtn" onclick="transferSelectedTimelogs()">
Overfør markerede
</button>
</div>
</div>
<div class="klippekort-selector">
<label for="klippekortSelect">Vælg klippekort til overførsel *</label>
<select id="klippekortSelect" required>
<option value="">-- Vælg klippekort --</option>
</select>
<div class="klippekort-hint" id="klippekortHint"></div>
</div>
<div class="table-responsive">
<table class="cases-table">
<thead>
<tr>
<th></th>
<th>Timer</th>
<th>Dato</th>
<th>Case Nr.</th>
<th>Rel. Case ID</th>
<th>Beskrivelse</th>
<th>vTiger ID</th>
</tr>
</thead>
<tbody id="timelogsTableBody"></tbody>
</table>
</div>
</div>
</div>
<!-- Current Item -->
<div id="currentItemSection" style="display: none;">
<div class="current-item-card">
<div class="item-header">
<div>
<div class="item-type-badge" id="itemTypeBadge"></div>
<div class="item-title" id="itemTitle"></div>
</div>
<div id="dryRunBadge" style="display: none;">
<span class="dry-run-badge">DRY RUN</span>
</div>
</div>
<div class="item-details" id="itemDetails"></div>
<div id="descriptionSection" style="display: none;">
<div style="font-weight: 600; color: var(--text-secondary); margin-bottom: 0.5rem;">Beskrivelse:</div>
<div class="description-box" id="itemDescription"></div>
</div>
<div class="action-buttons">
<button class="btn-secondary" id="skipBtn" onclick="skipItem()">Spring over</button>
<button class="btn-primary" id="actionBtn" onclick="processItem()">Gem & Næste</button>
</div>
</div>
</div>
<!-- Summary on Completion -->
<div id="summarySection" style="display: none;">
<div class="summary-card" id="summaryCardContent"></div>
</div>
</div>
</div>
</div>
<script>
let wizardState = {
contractData: null,
timelogIndex: 0,
actions: [],
dryRun: true,
customerId: null,
totalItems: 0,
approvedCaseIds: [],
filteredTimelogs: null,
allCases: [],
};
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
await loadContracts();
await loadCustomers();
document.getElementById('dryRunToggle').addEventListener('change', (e) => {
wizardState.dryRun = e.target.checked;
updateDryRunBadge();
});
document.getElementById('contractSelect').addEventListener('change', (e) => {
selectContract(e.target.value);
});
document.getElementById('customerSelect').addEventListener('change', (e) => {
const selected = e.target.value;
wizardState.customerId = selected ? parseInt(selected, 10) : null;
updateCustomerHint();
loadAvailableCards();
});
document.getElementById('casesSelectAll').addEventListener('change', (e) => {
toggleSelectAllCases(e.target.checked);
});
document.getElementById('timelogsSelectAll').addEventListener('change', (e) => {
toggleSelectAllTimelogs(e.target.checked);
});
});
async function loadContracts() {
try {
const response = await fetch('/api/v1/timetracking/service-contracts');
const contracts = await response.json();
const select = document.getElementById('contractSelect');
contracts.forEach(contract => {
const option = document.createElement('option');
option.value = JSON.stringify({
id: contract.id,
account_id: contract.account_id
});
option.textContent = `${contract.contract_number} - ${contract.subject}`;
select.appendChild(option);
});
} catch (error) {
console.error('Error loading contracts:', error);
showError('Fejl ved indlæsning af service contracts');
}
}
async function loadCustomers() {
try {
const response = await fetch('/api/v1/customers?limit=1000');
const payload = await response.json();
const customers = Array.isArray(payload) ? payload : (payload.customers || []);
const select = document.getElementById('customerSelect');
const hint = document.getElementById('customerSelectHint');
if (!Array.isArray(customers) || customers.length === 0) {
if (hint) {
hint.textContent = 'Ingen kunder fundet. Tjek adgang eller prøv igen.';
}
return;
}
customers.forEach(customer => {
const option = document.createElement('option');
option.value = customer.id;
option.textContent = customer.name || customer.company_name || customer.customer_name || `Kunde #${customer.id}`;
select.appendChild(option);
});
if (hint) {
hint.textContent = 'Vælg kunde hvis kontrakten ikke er koblet i Hub.';
}
} catch (error) {
console.error('Error loading customers:', error);
}
}
async function selectContract(value) {
if (!value) {
document.getElementById('progressSection').style.display = 'none';
return;
}
const contractInfo = JSON.parse(value);
const dryRun = wizardState.dryRun;
try {
const response = await fetch(
`/api/v1/timetracking/service-contracts/wizard/load?dry_run=${dryRun}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contract_id: contractInfo.id,
account_id: contractInfo.account_id
})
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail || 'Fejl ved indlæsning af contract data';
showError(message);
return;
}
const data = await response.json();
data.cases = Array.isArray(data.cases) ? data.cases : [];
data.timelogs = Array.isArray(data.timelogs) ? data.timelogs : [];
wizardState.contractData = data;
wizardState.allCases = Array.isArray(data.cases) ? [...data.cases] : [];
if (data.customer_id) {
setCustomerSelection(data.customer_id);
} else {
updateCustomerHint();
}
wizardState.timelogIndex = 0;
wizardState.actions = [];
wizardState.totalItems = data.cases.length + data.timelogs.length;
wizardState.approvedCaseIds = [];
wizardState.filteredTimelogs = null;
document.getElementById('progressSection').style.display = 'block';
renderCasesTable();
if (data.cases.length === 0 && data.timelogs.length > 0) {
startTimelogFlow();
}
if (data.cases.length === 0 && data.timelogs.length === 0) {
showSummary();
}
} catch (error) {
console.error('Error loading contract data:', error);
showError('Fejl ved indlæsning af contract data');
}
}
function setCustomerSelection(customerId) {
const select = document.getElementById('customerSelect');
if (!select) return;
if (select.value) {
wizardState.customerId = parseInt(select.value, 10);
updateCustomerHint();
return;
}
const value = String(customerId);
const existingOption = select.querySelector(`option[value="${value}"]`);
if (existingOption) {
select.value = value;
wizardState.customerId = customerId;
}
updateCustomerHint();
}
function updateCustomerHint() {
const hint = document.getElementById('customerSelectHint');
if (!hint) return;
if (wizardState.customerId) {
hint.textContent = `Valgt kunde ID: ${wizardState.customerId}`;
} else {
hint.textContent = 'Vælg kunde hvis kontrakten ikke er koblet i Hub.';
}
}
function getTimelogRelatedId(item) {
return item.relatedto || item.related_to || item.parent_id || '';
}
function getTimelogHours(item) {
const value = item.time_spent || item.duration || item.total_hours || item.hours || '';
const field = item.time_spent ? 'time_spent' :
item.duration ? 'duration' :
item.total_hours ? 'total_hours' :
item.hours ? 'hours' : '';
return { value, field };
}
function parseTimelogMinutes(rawValue, fieldName) {
if (rawValue === null || rawValue === undefined || rawValue === '') {
return NaN;
}
// vTiger's 'duration' field is in seconds, convert to minutes
if (fieldName === 'duration') {
const seconds = typeof rawValue === 'number' ? rawValue : parseFloat(String(rawValue));
if (Number.isFinite(seconds)) {
return seconds / 60;
}
}
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
return rawValue;
}
const rawStr = String(rawValue).trim().toLowerCase();
if (!rawStr) {
return NaN;
}
const timeMatch = rawStr.match(/^(\d+):(\d+)(?::(\d+))?$/);
if (timeMatch) {
const hours = parseInt(timeMatch[1], 10);
const minutes = parseInt(timeMatch[2], 10);
const seconds = parseInt(timeMatch[3] || '0', 10);
return (hours * 60) + minutes + (seconds / 60);
}
let totalMinutes = 0;
let hasUnit = false;
const hourMatch = rawStr.match(/(\d+(?:[\.,]\d+)?)\s*(?:h|hour|hours)\b/);
if (hourMatch) {
hasUnit = true;
totalMinutes += parseFloat(hourMatch[1].replace(',', '.')) * 60;
}
const minuteMatch = rawStr.match(/(\d+(?:[\.,]\d+)?)\s*(?:m|min|minute|minutes)\b/);
if (minuteMatch) {
hasUnit = true;
totalMinutes += parseFloat(minuteMatch[1].replace(',', '.'));
}
const secondMatch = rawStr.match(/(\d+(?:[\.,]\d+)?)\s*(?:s|sec|second|seconds)\b/);
if (secondMatch) {
hasUnit = true;
totalMinutes += parseFloat(secondMatch[1].replace(',', '.')) / 60;
}
if (hasUnit) {
return totalMinutes;
}
const numeric = parseFloat(rawStr.replace(',', '.'));
return Number.isFinite(numeric) ? numeric : NaN;
}
function normalizeTimelogHours(rawValue, fieldName) {
const minutes = parseTimelogMinutes(rawValue, fieldName);
if (!Number.isFinite(minutes)) {
return '';
}
return (minutes / 60).toFixed(2).replace(/\.00$/, '');
}
function getTimelogDate(item) {
return item.workdate || item.work_date || item.date || item.createdtime || '-';
}
function getTimelogTitle(item) {
const hoursData = getTimelogHours(item);
const hours = normalizeTimelogHours(hoursData.value, hoursData.field);
const description = item.description || item.subject || item.note || '';
if (hours && description) {
return `${hours}h - ${description}`;
}
if (hours) {
return `${hours}h`;
}
return description || item.id || '-';
}
function getTimelogList() {
if (Array.isArray(wizardState.filteredTimelogs)) {
return wizardState.filteredTimelogs;
}
const data = wizardState.contractData;
return data && Array.isArray(data.timelogs) ? data.timelogs : [];
}
function loadNextItem() {
const data = wizardState.contractData;
if (!data) return;
const timelogs = getTimelogList();
if (wizardState.timelogIndex >= timelogs.length) {
showSummary();
return;
}
const item = timelogs[wizardState.timelogIndex];
// Update UI
const typeBadge = document.getElementById('itemTypeBadge');
typeBadge.className = 'item-type-badge timelog';
typeBadge.textContent = '⏱️ TIMELOG';
document.getElementById('itemTitle').textContent = getTimelogTitle(item);
// Details
const detailsDiv = document.getElementById('itemDetails');
detailsDiv.innerHTML = '';
const hoursData = getTimelogHours(item);
const hours = normalizeTimelogHours(hoursData.value, hoursData.field);
addDetail(detailsDiv, 'Timer', hours ? `${hours}h` : '-');
addDetail(detailsDiv, 'Dato', getTimelogDate(item));
addDetail(detailsDiv, 'vTiger ID', item.id);
// Description
if (item.description) {
document.getElementById('descriptionSection').style.display = 'block';
document.getElementById('itemDescription').textContent = item.description;
} else {
document.getElementById('descriptionSection').style.display = 'none';
}
document.getElementById('currentItemSection').style.display = 'none';
updateProgress();
updateDryRunBadge();
}
function renderTimelogTable() {
const data = wizardState.contractData;
const section = document.getElementById('timelogsTableSection');
const tbody = document.getElementById('timelogsTableBody');
const timelogs = getTimelogList();
const cases = Array.isArray(wizardState.allCases) ? wizardState.allCases : [];
const caseMap = new Map(cases.map(caseItem => [caseItem.id, caseItem]));
if (!data || timelogs.length === 0) {
section.style.display = 'none';
tbody.innerHTML = '';
document.getElementById('timelogsCount').textContent = '0';
return;
}
section.style.display = 'block';
document.getElementById('timelogsCount').textContent = String(timelogs.length);
document.getElementById('timelogsSelectAll').checked = false;
tbody.innerHTML = '';
timelogs.forEach(item => {
const row = document.createElement('tr');
const hoursData = getTimelogHours(item);
const hours = normalizeTimelogHours(hoursData.value, hoursData.field);
const relatedId = getTimelogRelatedId(item) || '-';
const relatedCase = caseMap.get(relatedId);
const caseNumber = relatedCase
? (relatedCase.case_no || relatedCase.ticket_no || relatedCase.ticket_number || relatedCase.caseid || relatedCase.id)
: '-';
const description = item.description || item.subject || item.note || '-';
row.innerHTML = `
<td>
<input type="checkbox" class="timelog-checkbox" data-timelog-id="${item.id}">
</td>
<td>${hours ? `${hours}h` : '-'}</td>
<td>${getTimelogDate(item)}</td>
<td>${caseNumber}</td>
<td>${relatedId}</td>
<td>${description}</td>
<td>${item.id}</td>
`;
tbody.appendChild(row);
});
}
function toggleSelectAllTimelogs(checked) {
const checkboxes = document.querySelectorAll('.timelog-checkbox');
checkboxes.forEach(box => {
box.checked = checked;
});
}
function renderCasesTable() {
const data = wizardState.contractData;
const section = document.getElementById('casesTableSection');
const tbody = document.getElementById('casesTableBody');
const cases = data && Array.isArray(data.cases) ? data.cases : [];
if (!data || cases.length === 0) {
section.style.display = 'none';
tbody.innerHTML = '';
document.getElementById('casesCount').textContent = '0';
return;
}
section.style.display = 'block';
document.getElementById('casesCount').textContent = String(cases.length);
document.getElementById('casesSelectAll').checked = false;
tbody.innerHTML = '';
cases.forEach(caseItem => {
const row = document.createElement('tr');
row.innerHTML = `
<td>
<input type="checkbox" class="case-checkbox" data-case-id="${caseItem.id}">
</td>
<td>${caseItem.title || caseItem.subject || '-'}</td>
<td>${caseItem.ticketstatus || caseItem.status || '-'}</td>
<td>${caseItem.priority || '-'}</td>
<td>${caseItem.id}</td>
`;
tbody.appendChild(row);
});
}
function toggleSelectAllCases(checked) {
const checkboxes = document.querySelectorAll('.case-checkbox');
checkboxes.forEach(box => {
box.checked = checked;
});
}
async function approveSelectedCases() {
const data = wizardState.contractData;
if (!data) return;
const selected = Array.from(document.querySelectorAll('.case-checkbox:checked'));
if (selected.length === 0) {
showError('Vælg mindst én case');
return;
}
const approveBtn = document.getElementById('casesApproveBtn');
approveBtn.disabled = true;
try {
const approvedIds = new Set();
for (const checkbox of selected) {
const caseId = checkbox.dataset.caseId;
const caseData = data.cases.find(item => item.id === caseId);
if (!caseData) {
continue;
}
const response = await fetch(
`/api/v1/timetracking/service-contracts/wizard/archive-case?dry_run=${wizardState.dryRun}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
case_id: caseId,
case_data: caseData,
contract_id: data.contract_id,
})
}
);
const action = await response.json();
wizardState.actions.push(action);
if (action && action.success) {
approvedIds.add(caseId);
}
}
wizardState.approvedCaseIds = Array.from(
new Set([...wizardState.approvedCaseIds, ...approvedIds])
);
data.cases = data.cases.filter(item => !approvedIds.has(item.id));
renderCasesTable();
updateProgress();
if (data.cases.length === 0 && data.timelogs.length > 0) {
startTimelogFlow();
}
} catch (error) {
console.error('Error approving cases:', error);
showError('Fejl ved godkendelse af cases');
} finally {
approveBtn.disabled = false;
}
}
function startTimelogFlow() {
const data = wizardState.contractData;
if (!data) return;
if (!wizardState.customerId) {
showError('Kunden er ikke knyttet til en Hub-kunde endnu.');
return;
}
if (data.timelogs.length === 0) {
showSummary();
return;
}
if (data.cases.length > 0 && wizardState.approvedCaseIds.length === 0) {
showError('Godkend mindst én case før timelogs vises.');
return;
}
const approvedSet = new Set(wizardState.approvedCaseIds);
const filtered = data.timelogs.filter(item => approvedSet.has(getTimelogRelatedId(item)));
if (filtered.length === 0) {
showError('Ingen timelogs knyttet til de godkendte cases.');
return;
}
wizardState.filteredTimelogs = filtered;
wizardState.timelogIndex = 0;
wizardState.totalItems = wizardState.actions.length + filtered.length;
document.getElementById('casesTableSection').style.display = 'none';
document.getElementById('currentItemSection').style.display = 'none';
document.getElementById('timelogsTableSection').style.display = 'block';
loadAvailableCards();
renderTimelogTable();
}
async function transferSelectedTimelogs() {
const data = wizardState.contractData;
if (!data) return;
const selected = Array.from(document.querySelectorAll('.timelog-checkbox:checked'));
if (selected.length === 0) {
showError('Vælg mindst én timelog');
return;
}
const cardId = document.getElementById('klippekortSelect').value;
if (!cardId) {
showError('Vælg venligst et klippekort');
return;
}
const transferBtn = document.getElementById('timelogsTransferBtn');
transferBtn.disabled = true;
try {
const transferredIds = new Set();
const timelogs = getTimelogList();
for (const checkbox of selected) {
const timelogId = checkbox.dataset.timelogId;
const timelogData = timelogs.find(item => item.id === timelogId);
if (!timelogData) {
continue;
}
const response = await fetch(
`/api/v1/timetracking/service-contracts/wizard/transfer-timelog?dry_run=${wizardState.dryRun}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timelog_id: timelogId,
card_id: parseInt(cardId, 10),
customer_id: wizardState.customerId,
contract_id: data.contract_id,
dry_run: wizardState.dryRun
})
}
);
const action = await response.json();
wizardState.actions.push(action);
if (action && action.success) {
transferredIds.add(timelogId);
}
}
if (Array.isArray(wizardState.filteredTimelogs)) {
wizardState.filteredTimelogs = wizardState.filteredTimelogs.filter(item => !transferredIds.has(item.id));
}
data.timelogs = data.timelogs.filter(item => !transferredIds.has(item.id));
renderTimelogTable();
updateProgress();
if (getTimelogList().length === 0) {
showSummary();
}
} catch (error) {
console.error('Error transferring timelogs:', error);
showError('Fejl ved overførsel af timelogs');
} finally {
transferBtn.disabled = false;
}
}
function addDetail(container, label, value) {
const div = document.createElement('div');
div.className = 'detail-item';
div.innerHTML = `
<div class="detail-label">${label}</div>
<div class="detail-value">${value}</div>
`;
container.appendChild(div);
}
async function loadAvailableCards() {
if (wizardState.customerId) {
try {
const response = await fetch(`/api/v1/timetracking/service-contracts/wizard/customer-cards/${wizardState.customerId}`);
const cards = await response.json();
const select = document.getElementById('klippekortSelect');
const hint = document.getElementById('klippekortHint');
const actionBtn = document.getElementById('actionBtn');
select.innerHTML = '<option value="">-- Vælg klippekort --</option>';
if (!Array.isArray(cards) || cards.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = 'Ingen klippekort fundet for kunden';
option.disabled = true;
select.appendChild(option);
if (hint) {
hint.textContent = 'Opret et klippekort før du kan overføre timelogs.';
}
if (actionBtn) {
actionBtn.disabled = true;
}
return;
}
cards.forEach(card => {
const option = document.createElement('option');
option.value = card.id;
option.textContent = `${card.card_number} (${card.remaining_hours}h tilbage)`;
select.appendChild(option);
});
if (hint) {
hint.textContent = '';
}
if (actionBtn) {
actionBtn.disabled = false;
}
} catch (error) {
console.error('Error loading cards:', error);
}
} else {
const select = document.getElementById('klippekortSelect');
const hint = document.getElementById('klippekortHint');
const actionBtn = document.getElementById('actionBtn');
if (select) {
select.innerHTML = '<option value="">-- Vælg klippekort --</option>';
}
if (hint) {
hint.textContent = 'Kunden er ikke knyttet til en Hub-kunde endnu.';
}
if (actionBtn) {
actionBtn.disabled = true;
}
}
}
async function processItem() {
const data = wizardState.contractData;
if (!data) return;
const timelogs = getTimelogList();
const item = timelogs[wizardState.timelogIndex];
if (!item) {
showSummary();
return;
}
try {
const cardId = document.getElementById('klippekortSelect').value;
if (!cardId) {
showError('Vælg venligst et klippekort');
return;
}
const response = await fetch(
`/api/v1/timetracking/service-contracts/wizard/transfer-timelog?dry_run=${wizardState.dryRun}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timelog_id: item.id,
card_id: parseInt(cardId),
customer_id: wizardState.customerId,
contract_id: data.contract_id,
dry_run: wizardState.dryRun
})
}
);
const action = await response.json();
wizardState.actions.push(action);
wizardState.timelogIndex++;
loadNextItem();
} catch (error) {
console.error('Error processing item:', error);
showError('Fejl ved behandling af item');
}
}
function skipItem() {
wizardState.timelogIndex++;
loadNextItem();
}
function updateProgress() {
const data = wizardState.contractData;
const total = wizardState.totalItems;
const processed = wizardState.actions.length;
document.getElementById('totalProcessed').textContent = processed;
document.getElementById('casesArchived').textContent = wizardState.actions.filter(a => a.type === 'archive' && a.success).length;
document.getElementById('timersTransferred').textContent = wizardState.actions.filter(a => a.type === 'transfer' && a.success).length;
const percentage = total > 0 ? (processed / total) * 100 : 0;
document.getElementById('progressBar').style.width = percentage + '%';
}
function updateDryRunBadge() {
const badge = document.getElementById('dryRunBadge');
if (wizardState.dryRun) {
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
}
function showSummary() {
document.getElementById('currentItemSection').style.display = 'none';
document.getElementById('casesTableSection').style.display = 'none';
const data = wizardState.contractData;
const archivedCount = wizardState.actions.filter(a => a.type === 'archive' && a.success).length;
const transferredCount = wizardState.actions.filter(a => a.type === 'transfer' && a.success).length;
const failedCount = wizardState.actions.filter(a => !a.success).length;
const html = `
<div class="summary-header${failedCount > 0 ? ' error' : ''}">
<div class="icon">${failedCount > 0 ? '⚠️' : '✅'}</div>
<div>
<h2 style="margin: 0;">${wizardState.dryRun ? 'Wizard Preview Afsluttet' : 'Migration Afsluttet'}</h2>
<p style="margin: 0.5rem 0 0 0; opacity: 0.9;">${data.contract_number} - ${data.subject}</p>
</div>
</div>
<div class="summary-stats">
<div class="summary-stat">
<div class="summary-stat-value">${archivedCount}</div>
<div class="summary-stat-label">Cases Arkiveret</div>
</div>
<div class="summary-stat">
<div class="summary-stat-value">${transferredCount}</div>
<div class="summary-stat-label">Timer Overført</div>
</div>
<div class="summary-stat">
<div class="summary-stat-value">${failedCount}</div>
<div class="summary-stat-label">Fejl</div>
</div>
</div>
${wizardState.dryRun ? '<div class="message-box message-success">🔍 Dette var et preview. Klik "Start Igen" for at køre med ægte data.</div>' : '<div class="message-box message-success">✅ Alle ændringer er blevet gemt.</div>'}
<div style="text-align: center; margin-top: 2rem;">
<button class="btn-primary" onclick="location.reload();">Start Igen</button>
</div>
`;
document.getElementById('summaryCardContent').innerHTML = html;
document.getElementById('summarySection').style.display = 'block';
}
function showError(message) {
const mainContent = document.getElementById('mainContent');
const errorDiv = document.createElement('div');
errorDiv.className = 'message-box message-error';
errorDiv.textContent = '❌ ' + message;
mainContent.insertBefore(errorDiv, mainContent.firstChild);
setTimeout(() => errorDiv.remove(), 5000);
}
</script>
{% endblock %}