bmc_hub/app/modules/calendar/templates/index.html
Christian 0831715d3a feat: add SMS service and frontend integration
- Implement SmsService class for sending SMS via CPSMS API.
- Add SMS sending functionality in the frontend with validation and user feedback.
- Create database migrations for SMS message storage and telephony features.
- Introduce telephony settings and user-specific configurations for click-to-call functionality.
- Enhance user experience with toast notifications for incoming calls and actions.
2026-02-14 02:26:29 +01:00

889 lines
29 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.4rem;
background: var(--calendar-bg);
border-radius: 999px;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
color: var(--calendar-subtle);
cursor: pointer;
}
.type-tag input {
accent-color: var(--calendar-sea);
}
.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">
<input type="checkbox" class="type-filter" value="deadline" checked>
Deadline
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="deferred" checked>
Deferred
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="meeting" checked>
Moede
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="technician_visit" checked>
Teknikerbesoeg
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="obs" checked>
OBS
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="reminder" checked>
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>Moede</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sun);"></span>Teknikerbesoeg</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">Moede</option>
<option value="technician_visit">Teknikerbesoeg</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 Moede 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 %}