This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
export function initCsrf() {
|
||||
const tokenInput = document.querySelector('input[name="csrf_token"]');
|
||||
const token = tokenInput ? tokenInput.value : null;
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('form[method="post"]').forEach((form) => {
|
||||
if (!form.querySelector('input[name="csrf_token"]')) {
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'csrf_token';
|
||||
hidden.value = token;
|
||||
form.appendChild(hidden);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
function isInteractiveTouchTarget(target) {
|
||||
if (!target || typeof target.closest !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest('a, button, input, select, textarea, summary, details, label, form'));
|
||||
}
|
||||
|
||||
function attachSwipeNavigation(target, prevUrl, nextUrl) {
|
||||
if (!target || !prevUrl || !nextUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minSwipeDistance = 60;
|
||||
const maxVerticalRatio = 1.25;
|
||||
const maxSwipeDuration = 900;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startAt = 0;
|
||||
let tracking = false;
|
||||
let navigating = false;
|
||||
|
||||
target.addEventListener('touchstart', (event) => {
|
||||
if (event.touches.length !== 1 || isInteractiveTouchTarget(event.target)) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
startAt = Date.now();
|
||||
tracking = true;
|
||||
}, { passive: true });
|
||||
|
||||
target.addEventListener('touchend', (event) => {
|
||||
if (!tracking || navigating || event.changedTouches.length !== 1) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
tracking = false;
|
||||
const touch = event.changedTouches[0];
|
||||
const deltaX = touch.clientX - startX;
|
||||
const deltaY = touch.clientY - startY;
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
const duration = Date.now() - startAt;
|
||||
|
||||
if (duration > maxSwipeDuration || absX < minSwipeDistance || absX <= absY * maxVerticalRatio) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigating = true;
|
||||
if (deltaX < 0) {
|
||||
window.location.assign(nextUrl);
|
||||
} else {
|
||||
window.location.assign(prevUrl);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function initSwipeNavigation() {
|
||||
if (!window.matchMedia('(pointer: coarse)').matches && !('ontouchstart' in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-component="swipe-nav"]').forEach((node) => {
|
||||
attachSwipeNavigation(node, node.dataset.prevUrl, node.dataset.nextUrl);
|
||||
});
|
||||
}
|
||||
|
||||
function initWarningBanner() {
|
||||
const warningBanner = document.querySelector('[data-component="workhours-warning"]');
|
||||
if (!warningBanner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const warningKey = warningBanner.getAttribute('data-workhours-warning') || '';
|
||||
const storageKey = warningKey ? `workhours-warning-dismissed:${warningKey}` : '';
|
||||
|
||||
if (storageKey && window.localStorage.getItem(storageKey) === '1') {
|
||||
warningBanner.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const closeButton = warningBanner.querySelector('[data-action="warning-close"]');
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', () => {
|
||||
warningBanner.remove();
|
||||
if (storageKey) {
|
||||
window.localStorage.setItem(storageKey, '1');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initWeeklyTargetEditor() {
|
||||
const form = document.querySelector('.weekly-target-form');
|
||||
const editor = document.querySelector('[data-component="weekly-target-editor"]');
|
||||
const toggleButtons = document.querySelectorAll('.js-toggle-weekly-target-editor');
|
||||
|
||||
if (form && toggleButtons.length && editor) {
|
||||
toggleButtons.forEach((toggleButton) => {
|
||||
toggleButton.addEventListener('click', () => {
|
||||
editor.classList.toggle('is-hidden');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
const scopeSelect = form.querySelector("select[name='scope']");
|
||||
const hoursInput = form.querySelector("input[name='weekly_target_hours']");
|
||||
if (!scopeSelect || !hoursInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = scopeSelect.value;
|
||||
const hours = hoursInput.value;
|
||||
let scopeText = '';
|
||||
|
||||
if (scope === 'current_week') {
|
||||
scopeText = 'Nur die aktuell ausgewählte Woche';
|
||||
} else if (scope === 'all_weeks') {
|
||||
scopeText = 'Alle Wochen (Vergangenheit und Zukunft)';
|
||||
} else if (scope === 'from_current_week') {
|
||||
scopeText = 'Aktuelle Woche und alle zukünftigen Wochen';
|
||||
}
|
||||
|
||||
if (!scopeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`Wochen-Soll wirklich ändern?\nNeuer Wert: ${hours} h\nGültigkeit: ${scopeText}`);
|
||||
if (!confirmed) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor) {
|
||||
editor.classList.add('is-hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initDashboard() {
|
||||
initSwipeNavigation();
|
||||
initWarningBanner();
|
||||
initWeeklyTargetEditor();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function initFlash() {
|
||||
document.querySelectorAll('[data-component="flash"]').forEach((flash) => {
|
||||
flash.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && target.dataset.action === 'flash-close') {
|
||||
flash.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
async function refreshCurrentViewPreservingScroll() {
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
const response = await fetch(window.location.href, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'fetch' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`refresh_failed_${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const nextDocument = new DOMParser().parseFromString(html, 'text/html');
|
||||
['.site-header', '.app-page-actions-wrap', 'main.page'].forEach((selector) => {
|
||||
const currentNode = document.querySelector(selector);
|
||||
const nextNode = nextDocument.querySelector(selector);
|
||||
if (currentNode && nextNode) {
|
||||
currentNode.replaceWith(nextNode);
|
||||
}
|
||||
});
|
||||
|
||||
window.scrollTo({ left: scrollX, top: scrollY });
|
||||
if (typeof window.__stundenfuchsInitApp === 'function') {
|
||||
window.__stundenfuchsInitApp();
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimeToMinutes(value) {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(value || '');
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
function formatMinutesToTime(value) {
|
||||
const minutes = Math.max(0, Math.min(24 * 60 - 1, Number(value) || 0));
|
||||
const hoursPart = String(Math.floor(minutes / 60)).padStart(2, '0');
|
||||
const minutesPart = String(minutes % 60).padStart(2, '0');
|
||||
return `${hoursPart}:${minutesPart}`;
|
||||
}
|
||||
|
||||
function requiredBreakMinutesForSpan(spanMinutes) {
|
||||
if (spanMinutes > 9 * 60) {
|
||||
return 45;
|
||||
}
|
||||
if (spanMinutes > 6 * 60) {
|
||||
return 30;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function requiredBreakMinutesForNetMinutes(netMinutes) {
|
||||
if (netMinutes > (9 * 60 - 45)) {
|
||||
return 45;
|
||||
}
|
||||
if (netMinutes > (6 * 60 - 30)) {
|
||||
return 30;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function initFullDayButtons() {
|
||||
document.querySelectorAll('[data-action="entry-apply-full-day"]').forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement) || button.dataset.fullDayBound === 'true') {
|
||||
return;
|
||||
}
|
||||
button.dataset.fullDayBound = 'true';
|
||||
|
||||
const form = button.closest('form[data-component="break-rules-form"]');
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startInput = form.querySelector('[data-break-input="start"]');
|
||||
const endInput = form.querySelector('[data-break-input="end"]');
|
||||
if (!(startInput instanceof HTMLInputElement) || !(endInput instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const netMinutes = Number(form.dataset.fullDayNetMinutes || '');
|
||||
if (!Number.isFinite(netMinutes) || netMinutes <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultStartValue = form.dataset.defaultStartTime || '08:30';
|
||||
const startMinutes = parseTimeToMinutes(startInput.value) ?? parseTimeToMinutes(defaultStartValue);
|
||||
if (startMinutes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoBreakEnabled = form.dataset.autoBreakEnabled === 'true';
|
||||
const configuredBreakMinutes = Number(form.dataset.defaultBreakMinutes || '0');
|
||||
const breakMinutes = autoBreakEnabled
|
||||
? requiredBreakMinutesForNetMinutes(netMinutes)
|
||||
: Math.max(0, configuredBreakMinutes);
|
||||
const endMinutes = startMinutes + netMinutes + breakMinutes;
|
||||
|
||||
startInput.value = formatMinutesToTime(startMinutes);
|
||||
endInput.value = formatMinutesToTime(endMinutes);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initBreakRuleForms() {
|
||||
document.querySelectorAll('form[data-component="break-rules-form"]').forEach((form) => {
|
||||
if (form.dataset.breakBound === 'true') {
|
||||
return;
|
||||
}
|
||||
form.dataset.breakBound = 'true';
|
||||
const autoBreakEnabled = form.dataset.autoBreakEnabled === 'true';
|
||||
|
||||
const modeInput = form.querySelector('[data-break-mode]');
|
||||
const startInput = form.querySelector('[data-break-input="start"]');
|
||||
const endInput = form.querySelector('[data-break-input="end"]');
|
||||
const breakInput = form.querySelector('[data-break-input="minutes"]');
|
||||
const statusNode = form.querySelector('[data-break-status]');
|
||||
const resetButton = form.querySelector('[data-action="break-reset-auto"]');
|
||||
|
||||
if (!(modeInput instanceof HTMLInputElement) || !(startInput instanceof HTMLInputElement) || !(endInput instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateStatus = () => {
|
||||
if (!statusNode) {
|
||||
return;
|
||||
}
|
||||
statusNode.textContent = modeInput.value === 'manual'
|
||||
? 'Pause manuell gesetzt. Gesetzliche Mindestpause wird nicht automatisch überschrieben.'
|
||||
: 'Gesetzliche Mindestpause nach deutschem Arbeitsrecht wird automatisch vorgeschlagen.';
|
||||
};
|
||||
|
||||
const applyAutoBreak = () => {
|
||||
if (!(breakInput instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
const startMinutes = parseTimeToMinutes(startInput.value);
|
||||
const endMinutes = parseTimeToMinutes(endInput.value);
|
||||
if (startMinutes === null || endMinutes === null || endMinutes <= startMinutes) {
|
||||
return;
|
||||
}
|
||||
modeInput.value = 'auto';
|
||||
breakInput.value = String(requiredBreakMinutesForSpan(endMinutes - startMinutes));
|
||||
updateStatus();
|
||||
};
|
||||
|
||||
const setManualMode = () => {
|
||||
modeInput.value = 'manual';
|
||||
updateStatus();
|
||||
};
|
||||
|
||||
startInput.addEventListener('input', () => {
|
||||
if (modeInput.value === 'auto') {
|
||||
applyAutoBreak();
|
||||
}
|
||||
});
|
||||
endInput.addEventListener('input', () => {
|
||||
if (modeInput.value === 'auto') {
|
||||
applyAutoBreak();
|
||||
}
|
||||
});
|
||||
if (breakInput instanceof HTMLInputElement) {
|
||||
breakInput.addEventListener('input', setManualMode);
|
||||
}
|
||||
if (resetButton) {
|
||||
resetButton.addEventListener('click', applyAutoBreak);
|
||||
}
|
||||
|
||||
if (!autoBreakEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!modeInput.value) {
|
||||
modeInput.value = 'auto';
|
||||
}
|
||||
if (modeInput.value === 'auto') {
|
||||
applyAutoBreak();
|
||||
} else {
|
||||
updateStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initAsyncRefreshForms() {
|
||||
document.querySelectorAll('form[data-async-refresh="view"]').forEach((form) => {
|
||||
if (form.dataset.asyncBound === 'true') {
|
||||
return;
|
||||
}
|
||||
form.dataset.asyncBound = 'true';
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const submitter = event.submitter instanceof HTMLElement ? event.submitter : null;
|
||||
if (submitter) {
|
||||
submitter.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: new FormData(form),
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'fetch' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`submit_failed_${response.status}`);
|
||||
}
|
||||
await refreshCurrentViewPreservingScroll();
|
||||
} catch (error) {
|
||||
window.location.assign(window.location.href);
|
||||
} finally {
|
||||
if (submitter) {
|
||||
submitter.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initBreakSettingsForms() {
|
||||
document.querySelectorAll('form[data-component="break-settings-form"]').forEach((form) => {
|
||||
if (form.dataset.breakSettingsBound === 'true') {
|
||||
return;
|
||||
}
|
||||
form.dataset.breakSettingsBound = 'true';
|
||||
|
||||
const toggle = form.querySelector('[data-break-settings-toggle]');
|
||||
const minutesInput = form.querySelector('[data-break-settings-minutes]');
|
||||
|
||||
if (!(toggle instanceof HTMLInputElement) || !(minutesInput instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncDisabledState = () => {
|
||||
minutesInput.disabled = toggle.checked;
|
||||
};
|
||||
|
||||
toggle.addEventListener('change', syncDisabledState);
|
||||
syncDisabledState();
|
||||
});
|
||||
}
|
||||
|
||||
export function initForms() {
|
||||
document.querySelectorAll('form[data-confirm]').forEach((form) => {
|
||||
form.addEventListener('submit', (event) => {
|
||||
const message = form.getAttribute('data-confirm') || 'Aktion wirklich ausführen?';
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
initAsyncRefreshForms();
|
||||
initFullDayButtons();
|
||||
initBreakRuleForms();
|
||||
initBreakSettingsForms();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function initModal() {
|
||||
const modals = document.querySelectorAll('[data-component="modal"]');
|
||||
modals.forEach((modal) => {
|
||||
modal.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (target.dataset.action === 'modal-close') {
|
||||
modal.setAttribute('hidden', 'hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-action="modal-open"]').forEach((trigger) => {
|
||||
trigger.addEventListener('click', () => {
|
||||
const id = trigger.getAttribute('data-target');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.removeAttribute('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
const STORAGE_KEY = 'stundenfuchs:settingsSection';
|
||||
const DESKTOP_SYNC_MEDIA_QUERY = '(min-width: 51.26em)';
|
||||
|
||||
function shouldSyncGroups() {
|
||||
return window.matchMedia(DESKTOP_SYNC_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
function syncGroupState(section, isOpen) {
|
||||
const groupName = section.dataset.syncGroup || '';
|
||||
if (!groupName || !shouldSyncGroups()) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll(`[data-component="settings-section"][data-sync-group="${groupName}"]`).forEach((peer) => {
|
||||
if (peer instanceof HTMLDetailsElement && peer !== section) {
|
||||
peer.open = isOpen;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openSectionById(sectionId) {
|
||||
if (!sectionId) {
|
||||
return;
|
||||
}
|
||||
const target = document.getElementById(sectionId);
|
||||
if (!(target instanceof HTMLDetailsElement)) {
|
||||
return;
|
||||
}
|
||||
target.open = true;
|
||||
syncGroupState(target, true);
|
||||
}
|
||||
|
||||
export function initSettingsSections() {
|
||||
const sections = Array.from(document.querySelectorAll('[data-component="settings-section"]'));
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hashTarget = window.location.hash ? window.location.hash.slice(1) : '';
|
||||
const storedTarget = window.sessionStorage.getItem(STORAGE_KEY) || '';
|
||||
openSectionById(hashTarget || storedTarget);
|
||||
if (storedTarget) {
|
||||
window.sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (!(section instanceof HTMLDetailsElement) || !section.id) {
|
||||
return;
|
||||
}
|
||||
section.addEventListener('toggle', () => {
|
||||
syncGroupState(section, section.open);
|
||||
});
|
||||
section.querySelectorAll('form').forEach((form) => {
|
||||
form.addEventListener('submit', () => {
|
||||
window.sessionStorage.setItem(STORAGE_KEY, section.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user