158 lines
17 KiB
JavaScript
158 lines
17 KiB
JavaScript
(function (root) {
|
|
'use strict';
|
|
const labels = {onboarding: 'Onboarding', offboarding: 'Offboarding'};
|
|
const statuses = {draft: 'Dato gemt · automatik slået fra', scheduled: 'Planlagt', needs_parent: 'Vælg hovedsag', created: 'Sag oprettet', manual_review: 'Kræver manuel behandling', cancelled: 'Annulleret', error: 'Oprettelse fejlede'};
|
|
const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
const date = value => value ? new Intl.DateTimeFormat('da-DK', {timeZone:'Europe/Copenhagen', dateStyle:'medium'}).format(new Date(value.length === 10 ? value + 'T12:00:00Z' : value)) : 'Ikke planlagt';
|
|
const schedule = value => value ? new Intl.DateTimeFormat('da-DK', {timeZone:'Europe/Copenhagen', dateStyle:'medium', timeStyle:'short'}).format(new Date(value)) : '';
|
|
function templateOptions(templates, customerId, type) {
|
|
return templates.filter(item => (['global','internal'].includes(item.template_type) || !item.customer_id || Number(item.customer_id) === Number(customerId)) && (!item.category || item.category === type));
|
|
}
|
|
function editPayload(payload, created) {
|
|
if (!created) return payload;
|
|
return {event_date:payload.event_date,lead_days:payload.lead_days,auto_create:payload.auto_create};
|
|
}
|
|
root.ContactLifecycle = {escape, templateOptions, editPayload};
|
|
if (typeof document === 'undefined') return;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const cards = document.getElementById('contactLifecycleCards');
|
|
if (!cards) return;
|
|
const contactId = Number(location.pathname.split('/').filter(Boolean).pop());
|
|
const base = `/api/v1/contacts/${contactId}/lifecycle`;
|
|
let data = {items:[],companies:[],users:[],templates:[]};
|
|
let current = null;
|
|
let previewVersion = 0;
|
|
const feedback = document.getElementById('contactLifecycleFeedback');
|
|
async function request(url, method = 'GET', body) {
|
|
const headers = {'Content-Type':'application/json'};
|
|
const token = localStorage.getItem('access_token');
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
const response = await fetch(url, {method, headers, credentials:'same-origin', ...(body === undefined ? {} : {body:JSON.stringify(body)})});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Kontrollér oplysningerne, og prøv igen.');
|
|
return result;
|
|
}
|
|
function notify(message, danger = false) {
|
|
feedback.className = `alert ${danger ? 'alert-danger' : 'alert-success'} py-2`;
|
|
feedback.textContent = message;
|
|
}
|
|
async function load() {
|
|
try {
|
|
data = await request(base);
|
|
cards.innerHTML = Object.entries(labels).map(([type,label]) => {
|
|
const items = data.items.filter(item => item.event_type === type);
|
|
const live = items.filter(item => item.status !== 'cancelled');
|
|
return `<div class="col-md-6"><div class="border rounded-3 p-3 h-100"><div class="d-flex align-items-center justify-content-between gap-2 mb-3"><h6 class="fw-bold mb-0">${label}</h6><button type="button" class="btn btn-sm btn-outline-primary" data-lifecycle-new="${type}"><i class="bi bi-plus-lg me-1"></i>Nyt forløb</button></div>${live.length ? live.map(item => `<div class="${live.length > 1 ? 'border-top pt-2 mt-2' : ''}"><div class="fw-semibold">${escape(date(item.event_date))}</div><div class="small text-muted">${escape((data.companies.find(company => Number(company.id) === Number(item.customer_id)) || {}).name || '')}</div><div class="small mt-1 ${['error','needs_parent','manual_review'].includes(item.status) ? 'text-warning-emphasis' : 'text-muted'}">${escape(statuses[item.status] || item.status)}</div>${item.auto_create && item.scheduled_at && !item.case_id ? `<div class="small text-muted">Oprettelse: ${escape(schedule(item.scheduled_at))}</div>` : ''}${item.last_error ? `<div class="small text-danger mt-1">${escape(item.last_error)}</div>` : ''}<div class="d-flex flex-wrap gap-2 mt-2">${item.case_id ? `<a class="btn btn-sm btn-outline-primary" href="/sag/${Number(item.case_id)}">Åbn sag #${Number(item.case_id)}</a>` : ''}<button type="button" class="btn btn-sm btn-outline-secondary" data-lifecycle-edit="${Number(item.id)}">${item.status === 'needs_parent' ? 'Vælg hovedsag' : 'Rediger'}</button><button type="button" class="btn btn-sm btn-link text-muted" data-lifecycle-cancel="${Number(item.id)}">Annuller forløb</button></div></div>`).join('') : '<div class="small text-muted">Ingen dato planlagt</div>'}${items.some(item => item.status === 'cancelled') ? `<div class="small text-muted mt-3">${items.filter(item => item.status === 'cancelled').length} annulleret · eksisterende sager er bevaret</div>` : ''}</div></div>`;
|
|
}).join('');
|
|
if (data.can_edit === false) cards.querySelectorAll('[data-lifecycle-new],[data-lifecycle-edit],[data-lifecycle-cancel]').forEach(button => button.remove());
|
|
} catch (error) {
|
|
cards.innerHTML = '<div class="col-12"><button type="button" class="btn btn-outline-secondary btn-sm" data-lifecycle-reload>Prøv igen</button></div>';
|
|
notify(`Forløbene kunne ikke hentes: ${error.message}`, true);
|
|
}
|
|
}
|
|
cards.addEventListener('click', async event => {
|
|
const button = event.target.closest('button');
|
|
if (!button) return;
|
|
if (button.hasAttribute('data-lifecycle-reload')) return load();
|
|
if (button.dataset.lifecycleNew) return open(button.dataset.lifecycleNew);
|
|
if (button.dataset.lifecycleEdit) {
|
|
const item = data.items.find(item => Number(item.id) === Number(button.dataset.lifecycleEdit));
|
|
if (item) open(item.event_type, item);
|
|
}
|
|
if (button.dataset.lifecycleCancel) {
|
|
const item = data.items.find(item => Number(item.id) === Number(button.dataset.lifecycleCancel));
|
|
if (!item || !confirm(`Annuller ${labels[item.event_type].toLowerCase()} den ${date(item.event_date)}? Eksisterende sager bevares.`)) return;
|
|
button.disabled = true;
|
|
try { await request(`${base}/${item.id}/cancel`, 'POST'); await load(); notify('Forløbet er annulleret. Eksisterende sager er bevaret.'); }
|
|
catch (error) { notify(error.message, true); button.disabled = false; }
|
|
}
|
|
});
|
|
function open(type, item = null) {
|
|
if (data.can_edit === false) return;
|
|
current = item;
|
|
previewVersion++;
|
|
document.getElementById('contactLifecycleModal')?.remove();
|
|
const element = document.createElement('div');
|
|
element.id = 'contactLifecycleModal';
|
|
element.className = 'modal fade';
|
|
element.tabIndex = -1;
|
|
element.setAttribute('aria-labelledby','contactLifecycleModalTitle');
|
|
element.innerHTML = `<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"><form class="modal-content" id="contactLifecycleForm"><div class="modal-header"><div><h5 class="modal-title" id="contactLifecycleModalTitle">${item ? 'Rediger' : 'Planlæg'} ${labels[type].toLowerCase()}</h5><div class="small text-muted">Dato og arbejdsopgaver for denne kontakt</div></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body"><div class="alert alert-danger d-none" id="lifecycleError" role="alert"></div><div class="row g-3"><div class="col-md-6"><label class="form-label" for="lifecycleDate">${type === 'onboarding' ? 'Startdato' : 'Fratrædelsesdato'}</label><input class="form-control" type="date" id="lifecycleDate" name="event_date" required></div><div class="col-md-6"><label class="form-label" for="lifecycleCompany">Kunde</label><select class="form-select" id="lifecycleCompany" name="customer_id" required><option value="">Vælg kunde</option>${data.companies.map(company => `<option value="${Number(company.id)}">${escape(company.name)}</option>`).join('')}</select></div><div class="col-12"><div class="form-check form-switch"><input class="form-check-input" id="lifecycleAuto" name="auto_create" type="checkbox"><label class="form-check-label fw-semibold" for="lifecycleAuto">Opret automatisk en sag før datoen</label></div></div><div class="col-12" id="lifecycleAutoFields"><div class="row g-3"><div class="col-md-6"><label class="form-label" for="lifecycleLead">Kalenderdage før</label><input class="form-control" id="lifecycleLead" name="lead_days" type="number" min="0" max="3650" required><div class="d-flex gap-2 mt-2">${[7,14,30].map(days => `<button type="button" class="btn btn-sm btn-outline-secondary" data-lead="${days}">${days} dage</button>`).join('')}</div></div><div class="col-md-6"><label class="form-label" for="lifecycleResponsible">Ansvarlig</label><select class="form-select" id="lifecycleResponsible" name="responsible_user_id"><option value="">Ikke tildelt</option>${data.users.map(user => `<option value="${Number(user.id)}">${escape(user.name)}</option>`).join('')}</select></div><div class="col-12"><label class="form-label" for="lifecycleTemplate">Opgaveskabelon</label><select class="form-select" id="lifecycleTemplate" name="template_id"></select><div class="form-text">Uden skabelon bruges kontaktens navn, kunde og dato.</div></div><div class="col-12"><label class="form-label" for="lifecycleParent">Hovedsag</label><select class="form-select" id="lifecycleParent" name="parent_case_id"><option value="">Finder relevante åbne sager…</option></select><div class="form-text" id="lifecycleParentHelp"></div></div></div></div><div class="col-12"><div class="bg-light border rounded-3 p-3 small" id="lifecyclePreview" role="status" aria-live="polite">Vælg dato og kunde.</div></div></div></div><div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button><button type="submit" class="btn btn-primary" id="lifecycleSave">Gem forløb</button></div></form></div>`;
|
|
document.body.append(element);
|
|
const form = element.querySelector('form');
|
|
const field = name => form.elements.namedItem(name);
|
|
field('event_date').value = item?.event_date || '';
|
|
field('customer_id').value = item?.customer_id || (data.companies.length === 1 ? data.companies[0].id : '');
|
|
field('lead_days').value = item?.lead_days ?? 14;
|
|
field('auto_create').checked = Boolean(item?.auto_create);
|
|
field('responsible_user_id').value = item?.responsible_user_id || '';
|
|
if (item?.case_id) ['customer_id','template_id','parent_case_id','responsible_user_id'].forEach(name => { field(name).disabled = true; });
|
|
let templateId = item?.template_id || '';
|
|
function templates() {
|
|
const available = templateOptions(data.templates, field('customer_id').value, type);
|
|
field('template_id').innerHTML = '<option value="">Ingen skabelon</option>' + available.map(template => `<option value="${Number(template.id)}">${escape(template.name)}</option>`).join('');
|
|
field('template_id').value = available.some(template => Number(template.id) === Number(templateId)) ? templateId : '';
|
|
}
|
|
async function preview() {
|
|
const version = ++previewVersion;
|
|
const container = element.querySelector('#lifecyclePreview');
|
|
element.querySelector('#lifecycleAutoFields').classList.toggle('d-none', !field('auto_create').checked);
|
|
const customer = field('customer_id').value;
|
|
const eventDate = field('event_date').value;
|
|
const days = Number(field('lead_days').value);
|
|
if (!customer || !eventDate || !Number.isInteger(days) || days < 0) { container.textContent = 'Vælg dato og kunde.'; return; }
|
|
if (current?.case_id) {
|
|
field('parent_case_id').innerHTML = `<option value="">${current.parent_case_id ? `Hovedsag #${Number(current.parent_case_id)}` : 'Selvstændig sag'}</option>`;
|
|
element.querySelector('#lifecycleParentHelp').textContent = 'Sagens tilknytninger bevares.';
|
|
container.textContent = `Sag #${current.case_id} bevares. Deadline ændres til ${date(eventDate)}. Afsluttede sager kræver manuel behandling.`;
|
|
return;
|
|
}
|
|
const selectedParent = field('parent_case_id').value || current?.parent_case_id;
|
|
container.textContent = 'Henter forslag…';
|
|
try {
|
|
const query = new URLSearchParams({customer_id:customer,event_type:type,event_date:eventDate,lead_days:String(days)});
|
|
const result = await request(`${base}/preview?${query}`);
|
|
if (version !== previewVersion || !element.isConnected) return;
|
|
const candidates = result.candidates || [];
|
|
field('parent_case_id').innerHTML = `<option value="">${candidates.length ? 'Automatisk valg' : 'Selvstændig sag'}</option>` + candidates.map(candidate => `<option value="${Number(candidate.id)}">#${Number(candidate.id)} · ${escape(candidate.titel || candidate.title || '')}</option>`).join('');
|
|
field('parent_case_id').value = candidates.some(candidate => Number(candidate.id) === Number(selectedParent)) ? selectedParent : (result.parent_case_id || '');
|
|
element.querySelector('#lifecycleParentHelp').textContent = candidates.length > 1 ? 'Flere relevante sager: vælg hovedsag. Uden valg afventer automatikken.' : candidates.length === 1 ? 'Der oprettes en undersag til denne åbne sag.' : 'Ingen relevant åben sag. Der oprettes en selvstændig sag.';
|
|
container.textContent = current?.case_id ? `Sag #${current.case_id} bevares. Deadline ændres til ${date(eventDate)}. Afsluttede sager kræver manuel behandling.` : field('auto_create').checked ? `Sagen oprettes ${schedule(result.scheduled_at)} med deadline ${date(eventDate)}.${candidates.length > 1 && !field('parent_case_id').value ? ' Afventer valg af hovedsag.' : ''}` : `Datoen ${date(eventDate)} gemmes. Ingen automatisk sagsoprettelse.`;
|
|
} catch (error) { if (version === previewVersion) container.textContent = `Forslag kunne ikke hentes: ${error.message}`; }
|
|
}
|
|
templates();
|
|
form.addEventListener('change', event => {
|
|
if (event.target.name === 'customer_id') templates();
|
|
if (event.target.name === 'template_id') templateId = event.target.value;
|
|
if (!['template_id','responsible_user_id'].includes(event.target.name)) preview();
|
|
});
|
|
form.addEventListener('click', event => {
|
|
const button = event.target.closest('[data-lead]');
|
|
if (button) { field('lead_days').value = button.dataset.lead; preview(); }
|
|
});
|
|
form.addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
if (!form.reportValidity()) return;
|
|
const button = element.querySelector('#lifecycleSave');
|
|
const errorBox = element.querySelector('#lifecycleError');
|
|
button.disabled = true;
|
|
errorBox.classList.add('d-none');
|
|
const payload = {customer_id:Number(field('customer_id').value),event_date:field('event_date').value,lead_days:Number(field('lead_days').value),auto_create:field('auto_create').checked,template_id:Number(field('template_id').value)||null,responsible_user_id:Number(field('responsible_user_id').value)||null,parent_case_id:Number(field('parent_case_id').value)||null};
|
|
if (!current) payload.event_type = type;
|
|
try {
|
|
const saved = await request(current ? `${base}/${current.id}` : base, current ? 'PATCH' : 'POST', editPayload(payload, Boolean(current?.case_id)));
|
|
modal.hide();
|
|
await load();
|
|
notify(saved.status === 'needs_parent' ? 'Forløbet er gemt. Vælg en hovedsag for at aktivere sagsoprettelsen.' : 'Forløbet er gemt.');
|
|
} catch (error) { errorBox.textContent = error.message; errorBox.classList.remove('d-none'); button.disabled = false; }
|
|
});
|
|
const modal = new bootstrap.Modal(element);
|
|
element.addEventListener('shown.bs.modal', () => field('event_date').focus(), {once:true});
|
|
element.addEventListener('hidden.bs.modal', () => { previewVersion++; modal.dispose(); element.remove(); }, {once:true});
|
|
modal.show();
|
|
preview();
|
|
}
|
|
load();
|
|
});
|
|
})(typeof window === 'undefined' ? globalThis : window);
|