- Removed opportunity detail page route from views.py. - Deleted opportunity_service.py as it is no longer needed. - Updated router.py to seed new setting for case_type_module_defaults. - Enhanced settings.html to include standard modules per case type with UI for selection. - Implemented JavaScript functions to manage case type module defaults. - Added RelationService for handling case relations with a tree structure. - Created migration scripts (128 and 129) for new pipeline fields and descriptions. - Added script to fix relation types in the database.
915 lines
30 KiB
HTML
915 lines
30 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Kalender - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css" rel="stylesheet">
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap');
|
|
|
|
:root {
|
|
--calendar-bg: #f1f5f9;
|
|
--calendar-ink: #0f172a;
|
|
--calendar-subtle: #5b6b80;
|
|
--calendar-glow: rgba(15, 76, 117, 0.18);
|
|
--calendar-card: #ffffff;
|
|
--calendar-border: rgba(15, 23, 42, 0.12);
|
|
--calendar-sun: #ffb703;
|
|
--calendar-sea: #0f4c75;
|
|
--calendar-mint: #2a9d8f;
|
|
--calendar-ember: #e63946;
|
|
--calendar-violet: #5f0f40;
|
|
--calendar-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
|
|
}
|
|
|
|
.calendar-page {
|
|
font-family: 'IBM Plex Sans', sans-serif;
|
|
color: var(--calendar-ink);
|
|
}
|
|
|
|
.calendar-hero {
|
|
background: radial-gradient(circle at top left, rgba(15, 76, 117, 0.15), transparent 45%),
|
|
linear-gradient(135deg, #e3edf7, #fdfbff 55%, #edf2f7);
|
|
border-radius: 20px;
|
|
padding: 2rem clamp(1.5rem, 3vw, 3rem);
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
gap: 2rem;
|
|
align-items: center;
|
|
box-shadow: var(--calendar-shadow);
|
|
margin-bottom: 2rem;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.calendar-hero::after {
|
|
content: "";
|
|
position: absolute;
|
|
top: -120px;
|
|
right: -140px;
|
|
width: 280px;
|
|
height: 280px;
|
|
background: radial-gradient(circle, rgba(255, 183, 3, 0.35), transparent 70%);
|
|
filter: blur(0px);
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.hero-kicker {
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.2em;
|
|
font-size: 0.7rem;
|
|
color: var(--calendar-sea);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.calendar-hero h1 {
|
|
font-family: 'Space Grotesk', sans-serif;
|
|
font-size: clamp(2rem, 2.5vw, 3rem);
|
|
margin: 0.5rem 0 0.8rem;
|
|
}
|
|
|
|
.calendar-hero p {
|
|
color: var(--calendar-subtle);
|
|
margin-bottom: 1rem;
|
|
max-width: 46ch;
|
|
}
|
|
|
|
.hero-meta {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
font-size: 0.9rem;
|
|
color: var(--calendar-subtle);
|
|
}
|
|
|
|
.hero-meta span {
|
|
font-weight: 600;
|
|
color: var(--calendar-ink);
|
|
}
|
|
|
|
.hero-ical {
|
|
margin-top: 0.75rem;
|
|
font-size: 0.85rem;
|
|
color: var(--calendar-subtle);
|
|
word-break: break-all;
|
|
}
|
|
|
|
.hero-ical a {
|
|
color: var(--calendar-sea);
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.calendar-filter-card {
|
|
background: var(--calendar-card);
|
|
border-radius: 16px;
|
|
padding: 1.5rem;
|
|
border: 1px solid var(--calendar-border);
|
|
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.filter-title {
|
|
font-weight: 600;
|
|
margin-bottom: 1rem;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.toggle-group {
|
|
display: inline-flex;
|
|
background: var(--calendar-bg);
|
|
border-radius: 999px;
|
|
padding: 0.25rem;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.toggle-group button {
|
|
border: none;
|
|
background: transparent;
|
|
padding: 0.45rem 1rem;
|
|
border-radius: 999px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--calendar-subtle);
|
|
}
|
|
|
|
.toggle-group button.active {
|
|
background: var(--calendar-sea);
|
|
color: #ffffff;
|
|
box-shadow: 0 6px 12px rgba(15, 76, 117, 0.3);
|
|
}
|
|
|
|
.filter-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.filter-block label {
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--calendar-subtle);
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
|
|
.filter-block select,
|
|
.filter-block input {
|
|
border-radius: 10px;
|
|
border: 1px solid var(--calendar-border);
|
|
padding: 0.5rem 0.75rem;
|
|
width: 100%;
|
|
background: #ffffff;
|
|
color: var(--calendar-ink);
|
|
}
|
|
|
|
.type-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.type-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: #ffffff;
|
|
border: 1px solid var(--calendar-border);
|
|
border-radius: 8px;
|
|
padding: 0.35rem 0.6rem;
|
|
font-size: 0.8rem;
|
|
color: var(--calendar-ink);
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
user-select: none;
|
|
}
|
|
|
|
.type-tag:hover {
|
|
background: var(--calendar-bg);
|
|
border-color: rgba(15, 76, 117, 0.2);
|
|
}
|
|
|
|
.type-tag input {
|
|
accent-color: var(--calendar-sea);
|
|
width: 1rem;
|
|
height: 1rem;
|
|
cursor: pointer;
|
|
margin: 0;
|
|
}
|
|
|
|
.type-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.action-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.btn-calendar-action {
|
|
border: none;
|
|
border-radius: 999px;
|
|
background: var(--calendar-ink);
|
|
color: #ffffff;
|
|
padding: 0.5rem 1.2rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.case-search-box {
|
|
position: relative;
|
|
}
|
|
|
|
.case-search-results {
|
|
position: absolute;
|
|
top: calc(100% + 6px);
|
|
left: 0;
|
|
right: 0;
|
|
background: #ffffff;
|
|
border: 1px solid var(--calendar-border);
|
|
border-radius: 12px;
|
|
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
|
|
max-height: 280px;
|
|
overflow-y: auto;
|
|
z-index: 20;
|
|
display: none;
|
|
}
|
|
|
|
.case-search-results.show {
|
|
display: block;
|
|
}
|
|
|
|
.case-search-item {
|
|
padding: 0.65rem 0.85rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
|
|
.case-search-item strong {
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.case-search-item small {
|
|
color: var(--calendar-subtle);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.case-search-item:hover,
|
|
.case-search-item.active {
|
|
background: var(--accent-light);
|
|
}
|
|
|
|
.calendar-shell {
|
|
background: var(--calendar-card);
|
|
border-radius: 20px;
|
|
padding: 1.5rem;
|
|
border: 1px solid var(--calendar-border);
|
|
box-shadow: var(--calendar-shadow);
|
|
}
|
|
|
|
.calendar-toolbar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.view-buttons {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.view-buttons button {
|
|
border: 1px solid var(--calendar-border);
|
|
background: var(--calendar-bg);
|
|
border-radius: 999px;
|
|
padding: 0.4rem 0.9rem;
|
|
font-weight: 600;
|
|
font-size: 0.85rem;
|
|
color: var(--calendar-subtle);
|
|
}
|
|
|
|
.view-buttons button.active {
|
|
background: var(--calendar-ink);
|
|
color: #ffffff;
|
|
border-color: transparent;
|
|
}
|
|
|
|
.calendar-legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1rem;
|
|
margin-top: 1.5rem;
|
|
color: var(--calendar-subtle);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.legend-item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.status-bar {
|
|
font-size: 0.85rem;
|
|
color: var(--calendar-subtle);
|
|
}
|
|
|
|
.fc {
|
|
font-family: 'IBM Plex Sans', sans-serif;
|
|
}
|
|
|
|
.fc .fc-toolbar-title {
|
|
font-family: 'Space Grotesk', sans-serif;
|
|
font-size: 1.3rem;
|
|
}
|
|
|
|
.fc .fc-daygrid-day-number {
|
|
color: var(--calendar-subtle);
|
|
}
|
|
|
|
.fc .fc-day-today {
|
|
background: rgba(255, 183, 3, 0.12) !important;
|
|
}
|
|
|
|
.fc-event {
|
|
border: none;
|
|
border-radius: 10px;
|
|
padding: 2px 6px;
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.event-case_deadline,
|
|
.event-deadline {
|
|
background: rgba(230, 57, 70, 0.18);
|
|
color: #8b1d29;
|
|
}
|
|
|
|
.event-case_deferred,
|
|
.event-deferred {
|
|
background: rgba(95, 15, 64, 0.15);
|
|
color: #5f0f40;
|
|
}
|
|
|
|
.event-case_reminder,
|
|
.event-reminder {
|
|
background: rgba(42, 157, 143, 0.2);
|
|
color: #1f6f66;
|
|
}
|
|
|
|
.event-meeting {
|
|
background: rgba(15, 76, 117, 0.18);
|
|
color: #0f4c75;
|
|
}
|
|
|
|
.event-technician_visit {
|
|
background: rgba(255, 183, 3, 0.22);
|
|
color: #8a5b00;
|
|
}
|
|
|
|
.event-obs {
|
|
background: rgba(90, 96, 168, 0.2);
|
|
color: #3d3f7a;
|
|
}
|
|
|
|
.fade-up {
|
|
animation: fadeUp 0.6s ease both;
|
|
}
|
|
|
|
@keyframes fadeUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(16px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.calendar-hero {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.calendar-shell {
|
|
padding: 1rem;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid px-4 py-4 calendar-page">
|
|
<div class="calendar-hero fade-up">
|
|
<div>
|
|
<div class="hero-kicker">BMC Hub Kalender</div>
|
|
<h1>Kalender for drift og overblik</h1>
|
|
<p>Samlet visning af sag-deadlines, deferred datoer og reminders. Brug det som et kontrolpanel, ikke som pynt.</p>
|
|
<div class="hero-meta">
|
|
<div>Events i interval: <span id="eventCount">0</span></div>
|
|
<div>|</div>
|
|
<div>Status: <span id="calendarStatus">Klar</span></div>
|
|
</div>
|
|
<div class="hero-ical">
|
|
iCal: <a href="{{ request.base_url }}api/v1/calendar/ical">{{ request.base_url }}api/v1/calendar/ical</a>
|
|
</div>
|
|
</div>
|
|
<div class="calendar-filter-card">
|
|
<div class="filter-title">Filtre og fokus</div>
|
|
<div class="toggle-group" role="group" aria-label="Mine eller alle">
|
|
<button type="button" id="mineToggle" class="active">Mine</button>
|
|
<button type="button" id="allToggle">Alle</button>
|
|
</div>
|
|
<div class="filter-grid">
|
|
<div class="filter-block">
|
|
<label for="customerSelect">Kunde</label>
|
|
<input type="text" id="customerSearch" placeholder="Sog kunde..." class="form-control mb-2">
|
|
<select id="customerSelect">
|
|
<option value="">Alle kunder</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-block">
|
|
<label>Event typer</label>
|
|
<div class="type-tags">
|
|
<label class="type-tag" title="Vis deadlines">
|
|
<input type="checkbox" class="type-filter" value="deadline" checked>
|
|
<span class="type-dot" style="background: var(--calendar-ember);"></span>
|
|
Deadline
|
|
</label>
|
|
<label class="type-tag" title="Vis udsatte sager">
|
|
<input type="checkbox" class="type-filter" value="deferred" checked>
|
|
<span class="type-dot" style="background: var(--calendar-violet);"></span>
|
|
Deferred
|
|
</label>
|
|
<label class="type-tag" title="Vis møder">
|
|
<input type="checkbox" class="type-filter" value="meeting" checked>
|
|
<span class="type-dot" style="background: var(--calendar-sea);"></span>
|
|
Møde
|
|
</label>
|
|
<label class="type-tag" title="Vis teknikerbesøg">
|
|
<input type="checkbox" class="type-filter" value="technician_visit" checked>
|
|
<span class="type-dot" style="background: var(--calendar-sun);"></span>
|
|
Tekniker
|
|
</label>
|
|
<label class="type-tag" title="Vis OBS punkter">
|
|
<input type="checkbox" class="type-filter" value="obs" checked>
|
|
<span class="type-dot" style="background: #5a60a8;"></span>
|
|
OBS
|
|
</label>
|
|
<label class="type-tag" title="Vis reminders">
|
|
<input type="checkbox" class="type-filter" value="reminder" checked>
|
|
<span class="type-dot" style="background: var(--calendar-mint);"></span>
|
|
Reminder
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="action-row">
|
|
<div class="text-muted small">Opret aftaler direkte i kalenderen.</div>
|
|
<button class="btn-calendar-action" type="button" onclick="openCalendarModal()">Opret aftale</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="calendar-shell fade-up">
|
|
<div class="calendar-toolbar">
|
|
<div class="view-buttons" id="viewButtons">
|
|
<button type="button" data-view="dayGridMonth" class="active">Maaned</button>
|
|
<button type="button" data-view="timeGridWeek">Uge</button>
|
|
<button type="button" data-view="timeGridDay">Dag</button>
|
|
<button type="button" data-view="listWeek">Agenda</button>
|
|
</div>
|
|
<div class="status-bar" id="rangeLabel">Indlaeser periode...</div>
|
|
</div>
|
|
<div id="calendar"></div>
|
|
<div class="calendar-legend">
|
|
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-ember);"></span>Deadline</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-violet);"></span>Deferred</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Møde</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sun);"></span>Teknikerbesøg</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background: #5a60a8;"></span>OBS</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-mint);"></span>Reminder</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="calendarCreateModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Opret kalenderaftale</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row g-3">
|
|
<div class="col-12 case-search-box">
|
|
<label class="form-label">Sag *</label>
|
|
<input type="text" class="form-control" id="caseSearch" placeholder="Sog sag...">
|
|
<div id="caseResults" class="case-search-results"></div>
|
|
<div class="form-text" id="caseSelectedHint">Ingen sag valgt</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Type *</label>
|
|
<select class="form-select" id="calendarEventType">
|
|
<option value="meeting">Møde</option>
|
|
<option value="technician_visit">Teknikerbesøg</option>
|
|
<option value="obs">OBS</option>
|
|
<option value="reminder">Reminder</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Tidspunkt *</label>
|
|
<input type="datetime-local" class="form-control" id="calendarEventTime">
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Titel *</label>
|
|
<input type="text" class="form-control" id="calendarEventTitle" placeholder="Fx Møde om status">
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Besked</label>
|
|
<textarea class="form-control" id="calendarEventMessage" rows="3"></textarea>
|
|
</div>
|
|
<div class="col-12">
|
|
<div class="alert alert-warning small d-none" id="calendarEventWarning">
|
|
Mangler bruger-id. Log ind igen eller opdater siden.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveCalendarEvent()">Gem aftale</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script>
|
|
<script>
|
|
const calendarEl = document.getElementById('calendar');
|
|
const eventCountEl = document.getElementById('eventCount');
|
|
const calendarStatusEl = document.getElementById('calendarStatus');
|
|
const rangeLabelEl = document.getElementById('rangeLabel');
|
|
const customerSelect = document.getElementById('customerSelect');
|
|
const customerSearch = document.getElementById('customerSearch');
|
|
const mineToggle = document.getElementById('mineToggle');
|
|
const allToggle = document.getElementById('allToggle');
|
|
const viewButtons = document.getElementById('viewButtons');
|
|
const typeFilters = Array.from(document.querySelectorAll('.type-filter'));
|
|
const customerOptions = [];
|
|
const caseResults = document.getElementById('caseResults');
|
|
const caseSearch = document.getElementById('caseSearch');
|
|
const caseSelectedHint = document.getElementById('caseSelectedHint');
|
|
const calendarEventWarning = document.getElementById('calendarEventWarning');
|
|
let selectedCaseId = null;
|
|
let caseOptions = [];
|
|
let caseActiveIndex = -1;
|
|
|
|
let onlyMine = true;
|
|
|
|
function setToggle(activeMine) {
|
|
onlyMine = activeMine;
|
|
mineToggle.classList.toggle('active', activeMine);
|
|
allToggle.classList.toggle('active', !activeMine);
|
|
calendar.refetchEvents();
|
|
}
|
|
|
|
function getSelectedTypes() {
|
|
return typeFilters.filter(input => input.checked).map(input => input.value);
|
|
}
|
|
|
|
function getCalendarUserId() {
|
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
|
if (token) {
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
return payload.sub || payload.user_id;
|
|
} catch (e) {
|
|
console.warn('Could not decode token for calendar user_id');
|
|
}
|
|
}
|
|
const metaTag = document.querySelector('meta[name="user-id"]');
|
|
if (metaTag) return metaTag.getAttribute('content');
|
|
return null;
|
|
}
|
|
|
|
async function loadCustomers() {
|
|
try {
|
|
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
const customers = data.customers || [];
|
|
customers.forEach(customer => {
|
|
customerOptions.push({ id: customer.id, name: customer.name });
|
|
const option = document.createElement('option');
|
|
option.value = customer.id;
|
|
option.textContent = customer.name;
|
|
customerSelect.appendChild(option);
|
|
});
|
|
} catch (err) {
|
|
console.warn('Customer load failed', err);
|
|
}
|
|
}
|
|
|
|
function filterCustomerOptions() {
|
|
const query = customerSearch.value.trim().toLowerCase();
|
|
customerSelect.innerHTML = '';
|
|
|
|
const allOption = document.createElement('option');
|
|
allOption.value = '';
|
|
allOption.textContent = 'Alle kunder';
|
|
customerSelect.appendChild(allOption);
|
|
|
|
customerOptions
|
|
.filter(item => !query || item.name.toLowerCase().includes(query))
|
|
.forEach(item => {
|
|
const option = document.createElement('option');
|
|
option.value = item.id;
|
|
option.textContent = item.name;
|
|
customerSelect.appendChild(option);
|
|
});
|
|
}
|
|
|
|
const calendar = new FullCalendar.Calendar(calendarEl, {
|
|
initialView: 'dayGridMonth',
|
|
height: 'auto',
|
|
dayMaxEvents: true,
|
|
nowIndicator: true,
|
|
locale: 'da',
|
|
eventTimeFormat: {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
},
|
|
slotLabelFormat: {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
},
|
|
events: async (info, successCallback, failureCallback) => {
|
|
calendarStatusEl.textContent = 'Henter data...';
|
|
const params = new URLSearchParams();
|
|
params.set('start', info.startStr);
|
|
params.set('end', info.endStr);
|
|
params.set('only_mine', onlyMine ? 'true' : 'false');
|
|
const selectedTypes = getSelectedTypes();
|
|
if (selectedTypes.length) {
|
|
params.set('types', selectedTypes.join(','));
|
|
}
|
|
if (customerSelect.value) {
|
|
params.set('customer_id', customerSelect.value);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/calendar/events?${params.toString()}`);
|
|
if (!response.ok) {
|
|
throw new Error('Request failed');
|
|
}
|
|
const data = await response.json();
|
|
calendarStatusEl.textContent = 'Opdateret';
|
|
successCallback(data.events || []);
|
|
} catch (error) {
|
|
calendarStatusEl.textContent = 'Fejl';
|
|
failureCallback(error);
|
|
}
|
|
},
|
|
eventClassNames: (arg) => {
|
|
const kind = arg.event.extendedProps.event_kind || arg.event.extendedProps.event_type;
|
|
return [`event-${kind || arg.event.extendedProps.event_type}`];
|
|
},
|
|
eventDidMount: (info) => {
|
|
const customerName = info.event.extendedProps.customer_name;
|
|
if (customerName) {
|
|
info.el.title = `${info.event.title} - ${customerName}`;
|
|
}
|
|
},
|
|
datesSet: (info) => {
|
|
rangeLabelEl.textContent = `${info.startStr} til ${info.endStr}`;
|
|
},
|
|
eventsSet: (events) => {
|
|
eventCountEl.textContent = events.length;
|
|
}
|
|
});
|
|
|
|
calendar.render();
|
|
loadCustomers();
|
|
|
|
mineToggle.addEventListener('click', () => setToggle(true));
|
|
allToggle.addEventListener('click', () => setToggle(false));
|
|
|
|
typeFilters.forEach(input => {
|
|
input.addEventListener('change', () => calendar.refetchEvents());
|
|
});
|
|
|
|
customerSelect.addEventListener('change', () => calendar.refetchEvents());
|
|
|
|
customerSearch.addEventListener('input', () => {
|
|
filterCustomerOptions();
|
|
});
|
|
|
|
function renderCaseResults() {
|
|
caseResults.innerHTML = '';
|
|
if (!caseOptions.length) {
|
|
caseResults.classList.remove('show');
|
|
return;
|
|
}
|
|
caseOptions.forEach((item, index) => {
|
|
const div = document.createElement('div');
|
|
div.className = `case-search-item${index === caseActiveIndex ? ' active' : ''}`;
|
|
div.innerHTML = `<strong>${item.title}</strong><small>Sag #${item.id}</small>`;
|
|
div.addEventListener('click', () => selectCase(item));
|
|
caseResults.appendChild(div);
|
|
});
|
|
caseResults.classList.add('show');
|
|
}
|
|
|
|
function selectCase(item) {
|
|
selectedCaseId = item.id;
|
|
caseSearch.value = item.title;
|
|
caseSelectedHint.textContent = `Valgt sag #${item.id}`;
|
|
caseResults.classList.remove('show');
|
|
caseOptions = [];
|
|
caseActiveIndex = -1;
|
|
}
|
|
|
|
async function searchCases(query) {
|
|
if (!query || query.length < 1) {
|
|
caseOptions = [];
|
|
renderCaseResults();
|
|
return;
|
|
}
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.set('q', query);
|
|
params.set('limit', '12');
|
|
const response = await fetch(`/api/v1/sag?${params.toString()}`);
|
|
if (!response.ok) {
|
|
throw new Error('Request failed');
|
|
}
|
|
const data = await response.json();
|
|
caseOptions = (data || []).map(item => ({ id: item.id, title: item.titel || item.title || `Sag #${item.id}` }))
|
|
.sort((a, b) => a.title.localeCompare(b.title, 'da'))
|
|
.slice(0, 12);
|
|
caseActiveIndex = -1;
|
|
renderCaseResults();
|
|
} catch (err) {
|
|
console.warn('Case search failed', err);
|
|
caseOptions = [];
|
|
renderCaseResults();
|
|
}
|
|
}
|
|
|
|
let caseSearchTimer = null;
|
|
caseSearch.addEventListener('input', () => {
|
|
const query = caseSearch.value.trim();
|
|
selectedCaseId = null;
|
|
caseSelectedHint.textContent = 'Ingen sag valgt';
|
|
if (caseSearchTimer) {
|
|
clearTimeout(caseSearchTimer);
|
|
}
|
|
caseSearchTimer = setTimeout(() => searchCases(query), 200);
|
|
});
|
|
|
|
caseSearch.addEventListener('keydown', (event) => {
|
|
if (!caseOptions.length) return;
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
caseActiveIndex = Math.min(caseActiveIndex + 1, caseOptions.length - 1);
|
|
renderCaseResults();
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
caseActiveIndex = Math.max(caseActiveIndex - 1, 0);
|
|
renderCaseResults();
|
|
} else if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
if (caseActiveIndex >= 0) {
|
|
selectCase(caseOptions[caseActiveIndex]);
|
|
}
|
|
}
|
|
});
|
|
|
|
function openCalendarModal() {
|
|
const userId = getCalendarUserId();
|
|
if (calendarEventWarning) {
|
|
calendarEventWarning.classList.toggle('d-none', !!userId);
|
|
}
|
|
selectedCaseId = null;
|
|
caseSearch.value = '';
|
|
caseSelectedHint.textContent = 'Ingen sag valgt';
|
|
document.getElementById('calendarEventType').value = 'meeting';
|
|
document.getElementById('calendarEventTime').value = '';
|
|
document.getElementById('calendarEventTitle').value = '';
|
|
document.getElementById('calendarEventMessage').value = '';
|
|
caseOptions = [];
|
|
caseActiveIndex = -1;
|
|
caseResults.classList.remove('show');
|
|
new bootstrap.Modal(document.getElementById('calendarCreateModal')).show();
|
|
}
|
|
|
|
async function saveCalendarEvent() {
|
|
const userId = getCalendarUserId();
|
|
if (!userId) {
|
|
alert('Mangler bruger-id. Log ind igen.');
|
|
return;
|
|
}
|
|
const eventType = document.getElementById('calendarEventType').value;
|
|
const eventTime = document.getElementById('calendarEventTime').value;
|
|
const title = document.getElementById('calendarEventTitle').value.trim();
|
|
const message = document.getElementById('calendarEventMessage').value.trim();
|
|
|
|
if (!selectedCaseId) {
|
|
alert('Vælg en sag');
|
|
return;
|
|
}
|
|
if (!eventTime) {
|
|
alert('Vælg tidspunkt');
|
|
return;
|
|
}
|
|
if (!title) {
|
|
alert('Titel er påkrævet');
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
title,
|
|
message: message || null,
|
|
priority: 'normal',
|
|
event_type: eventType,
|
|
trigger_type: 'time_based',
|
|
trigger_config: {},
|
|
recipient_user_ids: [Number(userId)],
|
|
recipient_emails: [],
|
|
notify_mattermost: false,
|
|
notify_email: false,
|
|
notify_frontend: true,
|
|
override_user_preferences: false,
|
|
recurrence_type: 'once',
|
|
recurrence_day_of_week: null,
|
|
recurrence_day_of_month: null,
|
|
scheduled_at: new Date(eventTime).toISOString()
|
|
};
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${selectedCaseId}/reminders?user_id=${userId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Kunne ikke oprette aftale');
|
|
}
|
|
bootstrap.Modal.getInstance(document.getElementById('calendarCreateModal')).hide();
|
|
calendar.refetchEvents();
|
|
} catch (err) {
|
|
alert('Fejl: ' + err.message);
|
|
}
|
|
}
|
|
|
|
viewButtons.addEventListener('click', (event) => {
|
|
const target = event.target.closest('button[data-view]');
|
|
if (!target) return;
|
|
const view = target.dataset.view;
|
|
calendar.changeView(view);
|
|
Array.from(viewButtons.querySelectorAll('button')).forEach(btn => {
|
|
btn.classList.toggle('active', btn === target);
|
|
});
|
|
});
|
|
|
|
const autoRefreshMs = 5 * 60 * 1000;
|
|
setInterval(() => {
|
|
if (document.visibilityState === 'visible') {
|
|
calendar.refetchEvents();
|
|
}
|
|
}, autoRefreshMs);
|
|
|
|
window.addEventListener('focus', () => {
|
|
calendar.refetchEvents();
|
|
});
|
|
</script>
|
|
{% endblock %}
|