diff --git a/assets/js/darkmode.js b/assets/js/darkmode.js index 3c8887357..264117459 100644 --- a/assets/js/darkmode.js +++ b/assets/js/darkmode.js @@ -65,7 +65,7 @@ label = button.dataset.switchToAuto; } - const iconElement = button.querySelector(".material-symbols-rounded"); + const iconElement = button.querySelector(".a-icon"); if (iconElement) { iconElement.dataset.icon = icon; } diff --git a/assets/js/dropdown.js b/assets/js/dropdown.js index 3a4bf267c..271ed0e43 100644 --- a/assets/js/dropdown.js +++ b/assets/js/dropdown.js @@ -31,6 +31,7 @@ document.addEventListener("DOMContentLoaded", function () { registerEventListeners("navbar-country-selection"); registerEventListeners("navbar-operator-selection"); [ + "taxation-issuer", "fip-validity-issuer", "fip-validity-dialog-fip-coupon", "fip-validity-dialog-fip-reduced-ticket", diff --git a/assets/js/main.js b/assets/js/main.js index b9c687e29..b64d9adb8 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -11,3 +11,5 @@ import "./interactiveMap.js"; import "./expander.js"; import "./dialog.js"; import "./fipValidityComparison.js"; +import "./taxationIssuerSelection.js"; +import "./taxationCalculator.js"; diff --git a/assets/js/taxationCalculator.js b/assets/js/taxationCalculator.js new file mode 100644 index 000000000..e84dc5517 --- /dev/null +++ b/assets/js/taxationCalculator.js @@ -0,0 +1,1280 @@ +import { + buildOptimizationPlan as buildPlanningPlan, + clearOptimization as clearPlanning, + optimizePlan as optimizePlanning, + renderOptimization as renderPlanning, +} from "./taxationPlanning.js"; +import { initStickySidebar as initStickySidebarUI } from "./taxationUi.js"; +import { renderPersonLimitControl as renderPersonLimitControlInput } from "./taxationInputs.js"; +import { + formatCurrency, + createIcon, + createOperatorLogo, + normalizeText, + clampCounter, +} from "./taxationUtils.js"; + +const STORAGE_KEY = "fipguide-taxation"; +const MAX_PERSONS = 10; + +function init() { + const container = document.querySelector("[data-taxation-calculator]"); + if (!container) return; + + const config = JSON.parse(container.dataset.taxationConfig); + const i18n = getI18n(container); + const state = loadState(config); + + const operatorsWrapper = container.querySelector("[data-taxation-operators]"); + const nationalWrapper = container.querySelector("[data-taxation-national]"); + const resetBtns = container.querySelectorAll("[data-taxation-reset]"); + + const ctx = { container, config, i18n, state }; + const mobileMq = window.matchMedia("(max-width: 992px)"); + + function scrollCalculatorToTopOnMobile() { + if (!mobileMq.matches) return; + const MOBILE_HEADER_OFFSET = 88; + const targetTop = window.scrollY + container.getBoundingClientRect().top; + window.scrollTo({ + top: Math.max(0, targetTop - MOBILE_HEADER_OFFSET), + behavior: "smooth", + }); + } + + ctx.scrollCalculatorToTopOnMobile = scrollCalculatorToTopOnMobile; + + function rerenderAll() { + renderPersonLimitControlInput(ctx, saveState); + operatorsWrapper.innerHTML = ""; + nationalWrapper.innerHTML = ""; + const otherWrapper = container.querySelector("[data-taxation-other]"); + if (otherWrapper) { + otherWrapper.innerHTML = ""; + } + renderOperators(ctx, operatorsWrapper); + renderNational(ctx, nationalWrapper); + renderOther(ctx); + updateSummary(container, config, i18n, state); + runOptimization(); + } + + function runOptimization() { + const plan = buildPlanningPlan(config, state, i18n); + renderPlanning(container, plan, i18n, { + onOptimize() { + optimizePlanning(config, state, i18n); + saveState(state); + runOptimization(); + }, + onMove(itemId, monthIndex) { + if (monthIndex > 0) { + state.planningAssignments[itemId] = monthIndex; + state.planningManualAssignments[itemId] = true; + state.planningMonthCount = Math.max( + parseInt(state.planningMonthCount || 0, 10) || 0, + monthIndex, + ); + } else { + delete state.planningAssignments[itemId]; + delete state.planningManualAssignments[itemId]; + } + saveState(state); + runOptimization(); + }, + onUnlock(itemId) { + delete state.planningManualAssignments[itemId]; + delete state.planningAssignments[itemId]; + saveState(state); + runOptimization(); + }, + onAddMonth() { + var current = parseInt(state.planningMonthCount || 0, 10) || 0; + state.planningMonthCount = current + 1; + saveState(state); + runOptimization(); + }, + onCloseMonth(monthIndex) { + for (const itemId in state.planningAssignments) { + var assignedMonth = parseInt(state.planningAssignments[itemId], 10); + if (assignedMonth === monthIndex) { + delete state.planningAssignments[itemId]; + delete state.planningManualAssignments[itemId]; + } else if (assignedMonth > monthIndex) { + state.planningAssignments[itemId] = assignedMonth - 1; + } + } + + var current = parseInt(state.planningMonthCount || 0, 10) || 0; + state.planningMonthCount = Math.max(current - 1, 0); + + saveState(state); + runOptimization(); + }, + }); + updateSummary(container, config, i18n, state); + } + + function onPersonLimitChange() { + for (const op of config.operators) { + const opState = state.operators[op.slug]; + if (!hasActiveState(opState)) continue; + const row = operatorsWrapper.querySelector( + '[data-taxation-row="' + op.slug + '"]', + ); + if (!row) continue; + updateRowState( + row, + opState, + op.firstClass || 0, + op.secondClass, + state.personLimit, + true, + op.singlePersonOnly || false, + ); + updateRowMultipliers( + row, + op.singlePersonOnly ? 1 : state.personLimit, + i18n, + ); + updateRowWarning( + row, + op.singleClassOnly || false, + op.singlePersonOnly || false, + state.personLimit, + i18n, + ); + } + + for (const nat of config.national) { + const natState = state.national[nat.key]; + if (!hasActiveState(natState)) continue; + const row = nationalWrapper.querySelector( + '[data-taxation-row="' + nat.key + '"]', + ); + if (!row) continue; + updateRowState( + row, + natState, + nat.firstClass, + nat.secondClass, + state.personLimit, + false, + false, + ); + } + + runPlanning(ctx); + updateSummary(container, config, i18n, state); + } + + ctx.onPersonLimitChange = onPersonLimitChange; + + ctx.rerenderAll = rerenderAll; + ctx.runOptimization = runOptimization; + + rerenderAll(); + + if (resetBtns.length > 0 && operatorsWrapper && nationalWrapper) { + const resetCalculator = function () { + state.operators = {}; + state.national = {}; + state.other = 0; + state.personLimit = 1; + state.planningAssignments = {}; + state.planningManualAssignments = {}; + state.planningMonthCount = 0; + + clearPlanning(container, state); + rerenderAll(); + saveState(state); + }; + + for (const resetBtn of resetBtns) { + resetBtn.addEventListener("click", resetCalculator); + } + } + + initStickySidebarUI(container); +} + +function getI18n(container) { + return { + firstClass: container.dataset.i18nFirstClass, + secondClass: container.dataset.i18nSecondClass, + noFirstClass: container.dataset.i18nNoFirstClass, + addOperator: container.dataset.i18nAddOperator, + addNational: container.dataset.i18nAddNational, + remove: container.dataset.i18nRemove, + increase: container.dataset.i18nIncrease, + decrease: container.dataset.i18nDecrease, + categoryOther: container.dataset.i18nCategoryOther, + otherPlaceholder: container.dataset.i18nOtherPlaceholder, + highlightImportant: container.dataset.i18nHighlightImportant, + planning: container.dataset.i18nPlanning, + planningDescription: container.dataset.i18nPlanningDescription, + planningFocus: container.dataset.i18nPlanningFocus, + optimize: container.dataset.i18nOptimize, + addMonth: container.dataset.i18nAddMonth, + optimizationMonth: container.dataset.i18nOptimizationMonth, + personLimit: container.dataset.i18nPersonLimit, + unassigned: container.dataset.i18nUnassigned, + person: container.dataset.i18nPerson, + personOne: container.dataset.i18nPersonOne, + personOther: container.dataset.i18nPersonOther, + unlock: container.dataset.i18nUnlock, + personLimitNotSupportedWarning: + container.dataset.i18nPersonLimitNotSupportedWarning, + summaryUnderLimit: container.dataset.i18nSummaryUnderLimit, + summaryOverLimitUnassigned: + container.dataset.i18nSummaryOverLimitUnassigned, + summaryAllAssignedOk: container.dataset.i18nSummaryAllAssignedOk, + summaryAllAssignedOverMonth: + container.dataset.i18nSummaryAllAssignedOverMonth, + }; +} + +function getPersonLabelByCount(i18n, count) { + const lang = document.documentElement.lang || "en"; + const category = new Intl.PluralRules(lang).select(count); + if (category === "one") { + return i18n.personOne || i18n.person; + } + return i18n.personOther || i18n.person; +} + +function loadState(config) { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed && parsed.year === config.year) { + sanitizeState(parsed); + return parsed; + } + } + } catch { + /* ignore */ + } + return { + year: config.year, + operators: {}, + national: {}, + other: 0, + personLimit: 1, + planningAssignments: {}, + planningManualAssignments: {}, + planningMonthCount: 0, + }; +} + +function saveState(state) { + try { + var hasOperatorEntries = Object.keys(state.operators || {}).some( + function (key) { + var op = state.operators[key]; + return op && (op.first > 0 || op.second > 0); + }, + ); + var hasNationalEntries = Object.keys(state.national || {}).some( + function (key) { + var nat = state.national[key]; + return nat && (nat.first > 0 || nat.second > 0); + }, + ); + var hasData = hasOperatorEntries || hasNationalEntries || state.other > 0; + + if (!hasData) { + localStorage.removeItem(STORAGE_KEY); + return; + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + /* ignore */ + } +} + +function sanitizeState(state) { + state.personLimit = Math.min( + Math.max(Number(state.personLimit) || 1, 1), + MAX_PERSONS, + ); + if ( + !state.planningAssignments || + typeof state.planningAssignments !== "object" + ) { + state.planningAssignments = {}; + } + if ( + !state.planningManualAssignments || + typeof state.planningManualAssignments !== "object" + ) { + state.planningManualAssignments = {}; + } + if (!Number.isFinite(parseInt(state.planningMonthCount, 10))) { + state.planningMonthCount = 0; + } + + if ( + state.optimizationLocks && + typeof state.optimizationLocks === "object" && + Object.keys(state.planningAssignments).length === 0 + ) { + state.planningAssignments = state.optimizationLocks; + for (const key in state.optimizationLocks) { + state.planningManualAssignments[key] = true; + } + } + if (state.operators && typeof state.operators === "object") { + for (const key in state.operators) { + const op = state.operators[key]; + if (!op || typeof op !== "object") continue; + op.first = clampCounter(op.first); + op.second = clampCounter(op.second); + } + } + + if (state.national && typeof state.national === "object") { + for (const key in state.national) { + const nat = state.national[key]; + if (!nat || typeof nat !== "object") continue; + nat.first = clampCounter(nat.first); + nat.second = clampCounter(nat.second); + } + } +} + +function getOperatorState(state, slug) { + if (!state.operators[slug]) { + state.operators[slug] = { first: 0, second: 0 }; + } + return state.operators[slug]; +} + +function getNationalState(state, key) { + if (!state.national[key]) { + state.national[key] = { first: 0, second: 0 }; + } + return state.national[key]; +} + +function calculateTotal(config, state) { + let total = 0; + + for (const op of config.operators) { + const opState = state.operators[op.slug]; + if (!opState) continue; + const personMultiplier = op.singlePersonOnly ? 1 : state.personLimit; + if (opState.first > 0 && op.firstClass) { + total += op.firstClass * opState.first * personMultiplier; + } + if (opState.second > 0) { + total += op.secondClass * opState.second * personMultiplier; + } + } + + for (const nat of config.national) { + const natState = state.national[nat.key]; + if (!natState) continue; + if (natState.first > 0) { + total += nat.firstClass * natState.first; + } + if (natState.second > 0) { + total += nat.secondClass * natState.second; + } + } + + if (state.other > 0) { + total += state.other; + } + + return total; +} + +function calculateRowTotal( + stateObj, + firstClassValue, + secondClassValue, + personLimit, + applyPersonLimit, +) { + var multiplier = applyPersonLimit ? personLimit : 1; + let total = 0; + if (stateObj.first > 0 && firstClassValue) { + total += firstClassValue * stateObj.first * multiplier; + } + if (stateObj.second > 0) { + total += secondClassValue * stateObj.second * multiplier; + } + return total; +} + +function canIncreaseByClassLimit(stateObj, maxFields, field) { + if (!maxFields) return true; + if (field === "first") { + if (stateObj.second > 0) return false; + return stateObj.first < maxFields; + } + if (stateObj.first > 0) return false; + return stateObj.second < maxFields; +} + +function createHighlight(type, rooflineLabel, iconName, content) { + const wrapper = document.createElement("div"); + wrapper.className = "m-text-highlight m-text-highlight--" + type; + + const roofline = document.createElement("div"); + roofline.className = "m-text-highlight__roofline"; + roofline.appendChild(createIcon(iconName)); + roofline.appendChild(document.createTextNode(" " + rooflineLabel)); + wrapper.appendChild(roofline); + + const body = document.createElement("div"); + body.textContent = content; + wrapper.appendChild(body); + + return wrapper; +} + +function updateRowState( + row, + stateObj, + firstClassValue, + secondClassValue, + personLimit, + applyPersonLimit, + forceSinglePerson, +) { + var effectivePersonLimit = forceSinglePerson ? 1 : personLimit; + const rowTotal = calculateRowTotal( + stateObj, + firstClassValue, + secondClassValue, + effectivePersonLimit, + applyPersonLimit, + ); + + const rowTotalEl = row.querySelector("[data-taxation-row-total]"); + if (rowTotalEl) { + rowTotalEl.textContent = formatCurrency(rowTotal); + rowTotalEl.classList.toggle( + "o-taxation-calculator__row-total--active", + rowTotal > 0, + ); + } +} + +function createRow(options) { + const { + title, + slug, + logoUrl, + firstClassValue, + secondClassValue, + noFirstClass, + singleClassOnly, + maxFields, + personLimit, + showMultiplier, + applyPersonLimit, + singlePersonOnly, + stateObj, + i18n, + onUpdate, + onRemove, + } = options; + + const row = document.createElement("div"); + row.className = "o-taxation-calculator__row"; + row.dataset.taxationRow = slug; + + const header = document.createElement("div"); + header.className = "o-taxation-calculator__row-header"; + + const titleWrap = document.createElement("div"); + titleWrap.className = "o-taxation-calculator__row-title"; + + if (logoUrl) { + titleWrap.appendChild(createOperatorLogo(logoUrl)); + } + + const titleText = document.createElement("span"); + titleText.textContent = title; + titleWrap.appendChild(titleText); + + header.appendChild(titleWrap); + + if (onRemove) { + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "o-taxation-calculator__remove-btn"; + removeBtn.title = i18n.remove; + removeBtn.setAttribute("aria-label", i18n.remove); + removeBtn.appendChild(createIcon("close")); + removeBtn.addEventListener("click", function () { + onRemove(); + }); + header.appendChild(removeBtn); + } + + row.appendChild(header); + + const body = document.createElement("div"); + body.className = "o-taxation-calculator__row-body"; + + var leftCol = document.createElement("div"); + leftCol.className = + "o-taxation-calculator__row-col o-taxation-calculator__row-col--left"; + + var firstLineCtrl; + var secondLineCtrl; + + function refreshClassLocks() { + if (firstLineCtrl && typeof firstLineCtrl.refresh === "function") { + firstLineCtrl.refresh(); + } + if (secondLineCtrl && typeof secondLineCtrl.refresh === "function") { + secondLineCtrl.refresh(); + } + } + + firstLineCtrl = createClassLine({ + label: i18n.firstClass, + price: firstClassValue, + value: stateObj.first, + disabled: noFirstClass, + disabledHint: i18n.noFirstClass, + i18n, + personLimit, + effectivePersonLimit: singlePersonOnly ? 1 : personLimit, + showMultiplier, + canIncrease() { + return canIncreaseByClassLimit(stateObj, maxFields, "first"); + }, + onChange(val) { + stateObj.first = val; + refreshClassLocks(); + onUpdate(); + }, + }); + leftCol.appendChild(firstLineCtrl.el); + + secondLineCtrl = createClassLine({ + label: i18n.secondClass, + price: secondClassValue, + value: stateObj.second, + disabled: false, + i18n, + personLimit, + effectivePersonLimit: singlePersonOnly ? 1 : personLimit, + showMultiplier, + canIncrease() { + return canIncreaseByClassLimit(stateObj, maxFields, "second"); + }, + onChange(val) { + stateObj.second = val; + refreshClassLocks(); + onUpdate(); + }, + }); + leftCol.appendChild(secondLineCtrl.el); + refreshClassLocks(); + + body.appendChild(leftCol); + + var rightCol = document.createElement("div"); + rightCol.className = + "o-taxation-calculator__row-col o-taxation-calculator__row-col--right"; + + var rowTotalEl = document.createElement("span"); + rowTotalEl.className = "o-taxation-calculator__row-total"; + rowTotalEl.setAttribute("data-taxation-row-total", ""); + var initialTotal = calculateRowTotal( + stateObj, + firstClassValue, + secondClassValue, + singlePersonOnly ? 1 : personLimit, + applyPersonLimit, + ); + rowTotalEl.textContent = formatCurrency(initialTotal); + if (initialTotal > 0) { + rowTotalEl.classList.add("o-taxation-calculator__row-total--active"); + } + rightCol.appendChild(rowTotalEl); + + body.appendChild(rightCol); + row.appendChild(body); + + var warningEl = document.createElement("div"); + warningEl.className = "o-taxation-calculator__warning-wrapper"; + row.appendChild(warningEl); + + return row; +} + +function createClassLine(options) { + const { + label, + price, + value, + disabled, + disabledHint, + i18n, + personLimit, + effectivePersonLimit, + showMultiplier, + onChange, + canIncrease, + } = options; + + const line = document.createElement("div"); + line.className = "o-taxation-calculator__class-line"; + + const labelEl = document.createElement("span"); + labelEl.className = "o-taxation-calculator__class-label"; + labelEl.textContent = label; + line.appendChild(labelEl); + + if (!disabled) { + const priceEl = document.createElement("span"); + priceEl.className = "o-taxation-calculator__class-value"; + priceEl.textContent = formatCurrency(price); + line.appendChild(priceEl); + } + + if (disabled) { + const priceSpacer = document.createElement("span"); + priceSpacer.className = "o-taxation-calculator__class-value"; + line.appendChild(priceSpacer); + + const hint = document.createElement("span"); + hint.className = + "o-taxation-calculator__field-value o-taxation-calculator__field-value--text"; + hint.textContent = disabledHint; + line.appendChild(hint); + + return { + el: line, + refresh() {}, + }; + } + + const controlsWrap = document.createElement("div"); + controlsWrap.className = "o-taxation-calculator__field-controls"; + + const multiplySymbol = document.createElement("span"); + multiplySymbol.className = "o-taxation-calculator__multiply-symbol"; + multiplySymbol.textContent = "x"; + + const decreaseBtn = document.createElement("button"); + decreaseBtn.type = "button"; + decreaseBtn.className = + "o-taxation-calculator__btn o-taxation-calculator__btn--decrease"; + decreaseBtn.appendChild(createIcon("remove")); + decreaseBtn.title = i18n.decrease; + decreaseBtn.setAttribute("aria-label", i18n.decrease); + + const display = document.createElement("span"); + display.className = "o-taxation-calculator__field-value"; + display.textContent = value; + + const increaseBtn = document.createElement("button"); + increaseBtn.type = "button"; + increaseBtn.className = + "o-taxation-calculator__btn o-taxation-calculator__btn--increase"; + increaseBtn.appendChild(createIcon("add")); + increaseBtn.title = i18n.increase; + increaseBtn.setAttribute("aria-label", i18n.increase); + + function updateIncreaseState() { + if (typeof canIncrease === "function") { + increaseBtn.disabled = !canIncrease(); + return; + } + increaseBtn.disabled = false; + } + + decreaseBtn.disabled = value <= 0; + updateIncreaseState(); + + decreaseBtn.addEventListener("click", function () { + let current = parseInt(display.textContent, 10); + if (current > 0) { + current--; + display.textContent = current; + decreaseBtn.disabled = current <= 0; + onChange(current); + updateIncreaseState(); + } + }); + + increaseBtn.addEventListener("click", function () { + let current = parseInt(display.textContent, 10); + if (increaseBtn.disabled) return; + current++; + display.textContent = current; + decreaseBtn.disabled = current <= 0; + onChange(current); + updateIncreaseState(); + }); + + controlsWrap.appendChild(decreaseBtn); + controlsWrap.appendChild(display); + controlsWrap.appendChild(increaseBtn); + controlsWrap.insertBefore(multiplySymbol, decreaseBtn); + line.appendChild(controlsWrap); + + if (showMultiplier) { + const multiplier = document.createElement("span"); + multiplier.className = "o-taxation-calculator__multiplier"; + multiplier.textContent = + "x " + + effectivePersonLimit + + " " + + getPersonLabelByCount(i18n, effectivePersonLimit); + line.appendChild(multiplier); + } + + return { + el: line, + refresh: updateIncreaseState, + }; +} + +function hasActiveState(stateObj) { + return stateObj && (stateObj.first > 0 || stateObj.second > 0); +} + +function createSearchSelect(options) { + const { placeholder, items, onSelect } = options; + + const wrapper = document.createElement("div"); + wrapper.className = "o-taxation-calculator__search-select"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "o-taxation-calculator__search-input"; + input.placeholder = placeholder; + input.setAttribute("role", "combobox"); + input.setAttribute("aria-expanded", "false"); + input.setAttribute("aria-autocomplete", "list"); + input.setAttribute("autocomplete", "off"); + + const list = document.createElement("ul"); + list.className = "o-taxation-calculator__search-list"; + list.setAttribute("role", "listbox"); + list.setAttribute("aria-hidden", "true"); + list.inert = true; + + const activeKeys = new Set(); + + function buildItems() { + list.innerHTML = ""; + const query = normalizeText(input.value.trim()); + let visibleCount = 0; + + for (const item of items) { + if (activeKeys.has(item.key)) continue; + if (query && !normalizeText(item.label).includes(query)) continue; + + const li = document.createElement("li"); + li.className = "o-taxation-calculator__search-item"; + li.setAttribute("role", "option"); + + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "o-taxation-calculator__search-item-btn"; + btn.setAttribute("aria-label", item.label); + + const addIcon = createIcon("add_circle"); + addIcon.classList.add("o-taxation-calculator__search-add-icon"); + btn.appendChild(addIcon); + + if (item.logoUrl) { + btn.appendChild(createOperatorLogo(item.logoUrl)); + } + + const labelSpan = document.createElement("span"); + labelSpan.textContent = item.label; + btn.appendChild(labelSpan); + + btn.addEventListener("click", function () { + activeKeys.add(item.key); + input.value = ""; + closeList(); + onSelect(item); + }); + + li.appendChild(btn); + list.appendChild(li); + visibleCount++; + } + + return visibleCount; + } + + function ensureDropdownSpace() { + const wrapperRect = wrapper.getBoundingClientRect(); + const header = document.getElementById("header"); + const headerHeight = header ? header.getBoundingClientRect().height : 60; + const extraTopGap = 12; + const targetTop = + window.scrollY + wrapperRect.top - (headerHeight + extraTopGap); + const currentTop = window.scrollY; + if (Math.abs(targetTop - currentTop) > 2) { + window.scrollTo({ + top: Math.max(0, targetTop), + behavior: "smooth", + }); + } + } + + function openList() { + const count = buildItems(); + if (count > 0) { + list.inert = false; + list.setAttribute("aria-hidden", "false"); + input.setAttribute("aria-expanded", "true"); + wrapper.classList.add("o-taxation-calculator__search-select--open"); + requestAnimationFrame(ensureDropdownSpace); + } + } + + function closeList() { + if (list.contains(document.activeElement)) { + input.focus({ preventScroll: true }); + } + list.inert = true; + list.setAttribute("aria-hidden", "true"); + input.setAttribute("aria-expanded", "false"); + wrapper.classList.remove("o-taxation-calculator__search-select--open"); + } + + input.addEventListener("focus", function () { + openList(); + }); + + input.addEventListener("input", function () { + openList(); + }); + + document.addEventListener("click", function (event) { + if (!wrapper.contains(event.target)) { + closeList(); + } + }); + + document.addEventListener("keydown", function (event) { + if ( + event.key === "Escape" && + input.getAttribute("aria-expanded") === "true" + ) { + closeList(); + input.blur(); + } + }); + + wrapper.appendChild(input); + wrapper.appendChild(list); + + return { + el: wrapper, + markActive(key) { + activeKeys.add(key); + }, + markInactive(key) { + activeKeys.delete(key); + }, + }; +} + +function addOperatorCard(ctx, wrapper, searchSelect, op) { + const { container, config, i18n, state } = ctx; + const opState = getOperatorState(state, op.slug); + + const row = createRow({ + title: op.title, + slug: op.slug, + logoUrl: op.logoUrl || null, + firstClassValue: op.firstClass || 0, + secondClassValue: op.secondClass, + noFirstClass: op.noFirstClass || false, + singleClassOnly: op.singleClassOnly || false, + singlePersonOnly: op.singlePersonOnly || false, + maxFields: op.maxFields, + personLimit: state.personLimit, + showMultiplier: true, + applyPersonLimit: true, + stateObj: opState, + i18n, + onUpdate() { + updateRowState( + row, + opState, + op.firstClass || 0, + op.secondClass, + state.personLimit, + true, + op.singlePersonOnly || false, + ); + updateRowWarning( + row, + op.singleClassOnly || false, + op.singlePersonOnly || false, + state.personLimit, + i18n, + ); + updateSummary(container, config, i18n, state); + saveState(state); + runPlanning(ctx); + }, + onRemove() { + row.remove(); + delete state.operators[op.slug]; + searchSelect.markInactive(op.slug); + saveState(state); + updateSummary(container, config, i18n, state); + runPlanning(ctx); + }, + }); + + updateRowWarning( + row, + op.singleClassOnly || false, + op.singlePersonOnly || false, + state.personLimit, + i18n, + ); + if (searchSelect.el.nextSibling) { + wrapper.insertBefore(row, searchSelect.el.nextSibling); + } else { + wrapper.appendChild(row); + } +} + +function addNationalCard(ctx, wrapper, searchSelect, nat) { + const { container, config, i18n, state } = ctx; + const natState = getNationalState(state, nat.key); + + const row = createRow({ + title: nat.title, + slug: nat.key, + firstClassValue: nat.firstClass, + secondClassValue: nat.secondClass, + noFirstClass: false, + singleClassOnly: false, + personLimit: state.personLimit, + showMultiplier: false, + applyPersonLimit: false, + stateObj: natState, + i18n, + onUpdate() { + updateRowState( + row, + natState, + nat.firstClass, + nat.secondClass, + state.personLimit, + false, + false, + ); + updateSummary(container, config, i18n, state); + saveState(state); + runPlanning(ctx); + }, + onRemove() { + row.remove(); + delete state.national[nat.key]; + searchSelect.markInactive(nat.key); + saveState(state); + updateSummary(container, config, i18n, state); + runPlanning(ctx); + }, + }); + + if (searchSelect.el.nextSibling) { + wrapper.insertBefore(row, searchSelect.el.nextSibling); + } else { + wrapper.appendChild(row); + } +} + +function renderOperators(ctx, wrapper) { + if (!wrapper) return; + + const { config, i18n, state } = ctx; + + const sorted = [...config.operators].sort(function (a, b) { + return a.title.localeCompare(b.title); + }); + + const selectItems = sorted.map(function (op) { + return { + key: op.slug, + label: op.detailedTitle || op.title, + logoUrl: op.logoUrl || null, + data: op, + }; + }); + + const searchSelect = createSearchSelect({ + placeholder: i18n.addOperator, + items: selectItems, + onSelect(item) { + addOperatorCard(ctx, wrapper, searchSelect, item.data); + ctx.scrollCalculatorToTopOnMobile(); + }, + }); + + wrapper.appendChild(searchSelect.el); + + for (const op of sorted) { + const opState = state.operators[op.slug]; + if (hasActiveState(opState)) { + searchSelect.markActive(op.slug); + addOperatorCard(ctx, wrapper, searchSelect, op); + } + } +} + +function renderNational(ctx, wrapper) { + if (!wrapper) return; + + const { config, i18n, state } = ctx; + + const selectItems = config.national.map(function (nat) { + return { + key: nat.key, + label: nat.title, + data: nat, + }; + }); + + const searchSelect = createSearchSelect({ + placeholder: i18n.addNational, + items: selectItems, + onSelect(item) { + addNationalCard(ctx, wrapper, searchSelect, item.data); + ctx.scrollCalculatorToTopOnMobile(); + }, + }); + + wrapper.appendChild(searchSelect.el); + + for (const nat of config.national) { + const natState = state.national[nat.key]; + if (hasActiveState(natState)) { + searchSelect.markActive(nat.key); + addNationalCard(ctx, wrapper, searchSelect, nat); + } + } +} + +function renderOther(ctx) { + const { container, config, i18n, state } = ctx; + const wrapper = container.querySelector("[data-taxation-other]"); + if (!wrapper) return; + + const input = document.createElement("input"); + input.type = "text"; + input.inputMode = "decimal"; + input.className = "o-taxation-calculator__other-input"; + input.placeholder = i18n.otherPlaceholder; + input.value = state.other > 0 ? state.other.toFixed(2).replace(".", ",") : ""; + + input.addEventListener("input", function () { + var cleaned = input.value.replace(/[^0-9,]/g, ""); + var parts = cleaned.split(","); + if (parts.length > 2) { + cleaned = parts[0] + "," + parts.slice(1).join(""); + } + if (cleaned.indexOf(",") !== -1) { + var decimal = cleaned.split(","); + cleaned = decimal[0] + "," + decimal[1].slice(0, 2); + } + if (cleaned !== input.value) { + input.value = cleaned; + } + var normalized = cleaned.replace(",", "."); + var parsed = parseFloat(normalized); + state.other = isNaN(parsed) ? 0 : parsed; + updateSummary(container, config, i18n, state); + saveState(state); + runPlanning(ctx); + }); + + wrapper.appendChild(input); +} + +function updateRowWarning( + row, + _singleClassOnly, + singlePersonOnly, + personLimit, + i18n, +) { + const warningEl = row.querySelector( + ".o-taxation-calculator__warning-wrapper", + ); + + warningEl.innerHTML = ""; + + if (singlePersonOnly && personLimit > 1) { + warningEl.appendChild( + createHighlight( + "important", + i18n.highlightImportant, + "campaign", + i18n.personLimitNotSupportedWarning, + ), + ); + } +} + +function updateSummary(container, config, i18n, state) { + const total = calculateTotal(config, state); + const threshold = config.threshold; + const summaryHint = getSummaryHint(config, state, i18n, total, threshold); + + updateSummaryBlock( + container, + { + totalEl: container.querySelector("[data-taxation-total]"), + personsEl: container.querySelector("[data-taxation-persons-total]"), + thresholdEl: container.querySelector("[data-taxation-threshold-info]"), + }, + total, + state.personLimit, + summaryHint, + ); + + renderThresholdInfo( + container.querySelector("[data-taxation-mobile-threshold-detail]"), + summaryHint, + ); + + var mobileTotalEl = container.querySelector("[data-taxation-mobile-total]"); + if (mobileTotalEl) { + mobileTotalEl.textContent = formatCurrency(total); + } + + var mobileThresholdIcon = container.querySelector( + "[data-taxation-mobile-threshold-icon]", + ); + if (mobileThresholdIcon) { + mobileThresholdIcon.innerHTML = ""; + if (!summaryHint) { + mobileThresholdIcon.innerHTML = ""; + } else if (summaryHint.kind === "warning") { + var warnIcon = createIcon("warning"); + warnIcon.classList.add("o-taxation-calculator__mobile-icon--warning"); + mobileThresholdIcon.appendChild(warnIcon); + } else { + var okIcon = createIcon("check_circle"); + okIcon.classList.add("o-taxation-calculator__mobile-icon--success"); + mobileThresholdIcon.appendChild(okIcon); + } + } +} + +function runPlanning(ctx) { + if (ctx && typeof ctx.runOptimization === "function") { + ctx.runOptimization(); + } +} + +function updateRowMultipliers(row, effectivePersonLimit, i18n) { + var multiplierEls = row.querySelectorAll( + ".o-taxation-calculator__multiplier", + ); + for (const multiplierEl of multiplierEls) { + multiplierEl.textContent = + "x " + + effectivePersonLimit + + " " + + getPersonLabelByCount(i18n, effectivePersonLimit); + } +} + +function renderThresholdInfo(el, summaryHint) { + if (!el) return; + el.innerHTML = ""; + + if (!summaryHint) { + el.className = "o-taxation-calculator__threshold-info"; + return; + } + + el.className = + "o-taxation-calculator__threshold-info a-tag " + + (summaryHint.kind === "warning" ? "a-tag--warning" : "a-tag--success"); + + el.appendChild( + createIcon(summaryHint.kind === "warning" ? "warning" : "check_circle"), + ); + + var textEl = document.createElement("span"); + textEl.className = "o-taxation-calculator__threshold-text"; + textEl.textContent = summaryHint.text; + el.appendChild(textEl); +} + +function updateSummaryBlock(container, els, total, totalPersons, summaryHint) { + if (!els.totalEl) return; + + els.totalEl.textContent = formatCurrency(total); + + if (els.personsEl) { + els.personsEl.innerHTML = ""; + for (var i = 0; i < totalPersons; i++) { + var icon = createIcon("person"); + icon.classList.add("o-taxation-calculator__person"); + els.personsEl.appendChild(icon); + } + } + + renderThresholdInfo(els.thresholdEl, summaryHint); +} + +function getSummaryHint(config, state, i18n, total, threshold) { + var hasOperatorEntries = Object.keys(state.operators || {}).some( + function (key) { + var op = state.operators[key]; + return op && (op.first > 0 || op.second > 0); + }, + ); + var hasNationalEntries = Object.keys(state.national || {}).some( + function (key) { + var nat = state.national[key]; + return nat && (nat.first > 0 || nat.second > 0); + }, + ); + var hasAnyEntries = + hasOperatorEntries || hasNationalEntries || state.other > 0; + + if (!hasAnyEntries) { + return null; + } + + if (total <= threshold) { + return { kind: "success", text: i18n.summaryUnderLimit }; + } + + var plan = buildPlanningPlan(config, state, i18n); + if (plan.unassigned.length > 0) { + return { kind: "warning", text: i18n.summaryOverLimitUnassigned }; + } + + var monthOverLimit = plan.months.some(function (month) { + return month.total > threshold; + }); + + if (monthOverLimit) { + return { kind: "warning", text: i18n.summaryAllAssignedOverMonth }; + } + + return { kind: "success", text: i18n.summaryAllAssignedOk }; +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/assets/js/taxationInputs.js b/assets/js/taxationInputs.js new file mode 100644 index 000000000..e9ac2d015 --- /dev/null +++ b/assets/js/taxationInputs.js @@ -0,0 +1,80 @@ +import { createIcon } from "./taxationUtils.js"; + +const MAX_PERSONS = 10; + +export function renderPersonLimitControl(ctx, saveState) { + const { container, i18n, state } = ctx; + const wrapper = container.querySelector("[data-taxation-person-limit]"); + if (!wrapper) return; + + wrapper.innerHTML = ""; + + const label = document.createElement("span"); + label.className = "o-taxation-calculator__person-limit-label"; + label.textContent = i18n.personLimit; + + const controls = document.createElement("div"); + controls.className = "o-taxation-calculator__person-limit-controls"; + + const persons = document.createElement("span"); + persons.className = "o-taxation-calculator__person-limit-icons"; + + const decreaseBtn = document.createElement("button"); + decreaseBtn.type = "button"; + decreaseBtn.className = "o-taxation-calculator__btn"; + decreaseBtn.appendChild(createIcon("remove")); + decreaseBtn.title = i18n.decrease; + decreaseBtn.setAttribute("aria-label", i18n.decrease); + + const value = document.createElement("span"); + value.className = "o-taxation-calculator__person-limit-value"; + value.textContent = state.personLimit; + + const increaseBtn = document.createElement("button"); + increaseBtn.type = "button"; + increaseBtn.className = "o-taxation-calculator__btn"; + increaseBtn.appendChild(createIcon("add")); + increaseBtn.title = i18n.increase; + increaseBtn.setAttribute("aria-label", i18n.increase); + + function updateButtons() { + decreaseBtn.disabled = state.personLimit <= 1; + increaseBtn.disabled = state.personLimit >= MAX_PERSONS; + value.textContent = state.personLimit; + persons.innerHTML = ""; + for (var i = 0; i < state.personLimit; i++) { + var pIcon = createIcon("person"); + pIcon.classList.add("o-taxation-calculator__person"); + persons.appendChild(pIcon); + } + } + + decreaseBtn.addEventListener("click", function () { + if (state.personLimit <= 1) return; + state.personLimit -= 1; + if (typeof ctx.onPersonLimitChange === "function") { + ctx.onPersonLimitChange(); + } + saveState(state); + updateButtons(); + }); + + increaseBtn.addEventListener("click", function () { + if (state.personLimit >= MAX_PERSONS) return; + state.personLimit += 1; + if (typeof ctx.onPersonLimitChange === "function") { + ctx.onPersonLimitChange(); + } + saveState(state); + updateButtons(); + }); + + controls.appendChild(decreaseBtn); + controls.appendChild(value); + controls.appendChild(increaseBtn); + + wrapper.appendChild(label); + wrapper.appendChild(persons); + wrapper.appendChild(controls); + updateButtons(); +} diff --git a/assets/js/taxationIssuerSelection.js b/assets/js/taxationIssuerSelection.js new file mode 100644 index 000000000..3b78dab10 --- /dev/null +++ b/assets/js/taxationIssuerSelection.js @@ -0,0 +1,79 @@ +import { closeDropdown } from "./dropdown.js"; + +const ISSUER_KEY = "fipguide-issuer"; + +document.addEventListener("DOMContentLoaded", function () { + const button = document.querySelector("#taxation-issuer-button"); + if (!button) return; + + const dropdownId = "taxation-issuer"; + const scope = document; + const label = button.querySelector("[data-fip-validity-label]"); + const logoSlot = button.querySelector("[data-fip-validity-logo]"); + const options = scope.querySelectorAll( + "#taxation-issuer-dropdown [data-fip-option]", + ); + const wrappers = scope.querySelectorAll("[data-fip-issuer]"); + const selectFirstMessages = scope.querySelectorAll("[data-fip-select-first]"); + + function showIssuer(slug) { + wrappers.forEach(function (wrapper) { + var planning = wrapper.querySelector("[data-taxation-planning]"); + if (planning) { + planning.removeAttribute("id"); + } + if (wrapper.dataset.fipIssuer === slug) { + wrapper.removeAttribute("hidden"); + if (planning) { + planning.id = "planning-optimization"; + } + } else { + wrapper.setAttribute("hidden", ""); + } + }); + selectFirstMessages.forEach(function (msg) { + msg.setAttribute("hidden", ""); + }); + } + + function selectOption(slug, text) { + options.forEach(function (option) { + option + .closest("li") + .setAttribute( + "aria-selected", + option.dataset.fipOption === slug ? "true" : "false", + ); + }); + + label.textContent = text; + const operatorLogo = scope + .querySelector(`#taxation-issuer-dropdown [data-fip-option="${slug}"]`) + ?.querySelector("img"); + logoSlot.innerHTML = ""; + if (operatorLogo) { + logoSlot.appendChild(operatorLogo.cloneNode()); + } + + showIssuer(slug); + closeDropdown(dropdownId); + localStorage.setItem(ISSUER_KEY, slug); + } + + options.forEach(function (option) { + option.addEventListener("click", function () { + selectOption(option.dataset.fipOption, option.textContent.trim()); + }); + }); + + const savedSlug = localStorage.getItem(ISSUER_KEY); + if (savedSlug) { + const savedOption = scope.querySelector( + `#taxation-issuer-dropdown [data-fip-option="${savedSlug}"]`, + ); + if (savedOption) { + selectOption(savedSlug, savedOption.textContent.trim()); + return; + } + } +}); diff --git a/assets/js/taxationPlanning.js b/assets/js/taxationPlanning.js new file mode 100644 index 000000000..bf325340a --- /dev/null +++ b/assets/js/taxationPlanning.js @@ -0,0 +1,771 @@ +import { formatCurrency, createIcon } from "./taxationUtils.js"; + +function getClassLabel(classKey, i18n) { + return classKey === "first" ? i18n.firstClass : i18n.secondClass; +} + +function getItemText(item, i18n, personLimit) { + if (item.type === "other") { + return item.title; + } + + var personPrefix = ""; + if (item.person > 0 && personLimit > 1) { + personPrefix = i18n.person + " " + item.person + ": "; + } + + return ( + personPrefix + + item.fields + + "x " + + item.title + + " (" + + getClassLabel(item.classKey, i18n) + + ")" + ); +} + +function collectPlanningItems(config, state, i18n) { + var items = []; + + for (const op of config.operators) { + var operatorPersonLimit = op.singlePersonOnly ? 1 : state.personLimit; + for (var person = 1; person <= operatorPersonLimit; person++) { + var opState = state.operators[op.slug]; + if (!opState) continue; + + if (opState.first > 0 && op.firstClass) { + items.push({ + id: "op:" + op.slug + ":first:p" + person, + type: "operator", + title: op.title, + classKey: "first", + fields: opState.first, + person: op.singlePersonOnly ? 1 : person, + value: op.firstClass * opState.first, + }); + } + + if (opState.second > 0) { + items.push({ + id: "op:" + op.slug + ":second:p" + person, + type: "operator", + title: op.title, + classKey: "second", + fields: opState.second, + person: op.singlePersonOnly ? 1 : person, + value: op.secondClass * opState.second, + }); + } + } + } + + for (const nat of config.national) { + var natState = state.national[nat.key]; + if (!natState) continue; + + if (natState.first > 0) { + for (var firstIdx = 1; firstIdx <= natState.first; firstIdx++) { + items.push({ + id: "nat:" + nat.key + ":first:" + firstIdx, + type: "national", + title: nat.title, + classKey: "first", + fields: 1, + person: 0, + value: nat.firstClass, + }); + } + } + + if (natState.second > 0) { + for (var secondIdx = 1; secondIdx <= natState.second; secondIdx++) { + items.push({ + id: "nat:" + nat.key + ":second:" + secondIdx, + type: "national", + title: nat.title, + classKey: "second", + fields: 1, + person: 0, + value: nat.secondClass, + }); + } + } + } + + if (state.other > 0) { + items.push({ + id: "other", + type: "other", + title: i18n.categoryOther, + classKey: "", + fields: 1, + person: 0, + value: state.other, + }); + } + + for (const item of items) { + item.text = getItemText(item, i18n, state.personLimit); + } + + return items; +} + +function getValidAssignments(state) { + if ( + !state.planningAssignments || + typeof state.planningAssignments !== "object" + ) { + state.planningAssignments = {}; + } + return state.planningAssignments; +} + +function getManualAssignments(state) { + if ( + !state.planningManualAssignments || + typeof state.planningManualAssignments !== "object" + ) { + state.planningManualAssignments = {}; + } + return state.planningManualAssignments; +} + +function getMonthCount(state) { + var parsed = parseInt(state.planningMonthCount, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + state.planningMonthCount = 0; + return 0; + } + return parsed; +} + +function cleanupAssignments(assignments, manualAssignments, items) { + var itemIds = new Set( + items.map(function (item) { + return item.id; + }), + ); + for (const itemId in assignments) { + var month = parseInt(assignments[itemId], 10); + if (!itemIds.has(itemId) || !Number.isFinite(month) || month <= 0) { + delete assignments[itemId]; + } + } + + for (const itemId in manualAssignments) { + if (!itemIds.has(itemId) || !assignments[itemId]) { + delete manualAssignments[itemId]; + } + } +} + +function buildMonthData(items, assignments, explicitMonthCount) { + var assignedMaxMonth = 0; + for (const item of items) { + var month = parseInt(assignments[item.id], 10); + if (month > assignedMaxMonth) { + assignedMaxMonth = month; + } + } + + var monthCount = Math.max(assignedMaxMonth, explicitMonthCount || 0); + var months = []; + for (var monthIndex = 1; monthIndex <= monthCount; monthIndex++) { + months.push({ index: monthIndex, total: 0, items: [] }); + } + + var unassigned = []; + for (const item of items) { + var assignedMonth = parseInt(assignments[item.id], 10); + if (assignedMonth > 0 && assignedMonth <= monthCount) { + var targetMonth = months[assignedMonth - 1]; + targetMonth.items.push(item); + targetMonth.total += item.value; + } else { + unassigned.push(item); + } + } + + var unassignedTotal = unassigned.reduce(function (sum, item) { + return sum + item.value; + }, 0); + + return { + months, + unassigned, + unassignedTotal, + }; +} + +export function buildOptimizationPlan(config, state, i18n) { + var items = collectPlanningItems(config, state, i18n); + var assignments = getValidAssignments(state); + var manualAssignments = getManualAssignments(state); + var monthCount = getMonthCount(state); + cleanupAssignments(assignments, manualAssignments, items); + + for (const item of items) { + item.manual = Boolean(manualAssignments[item.id]); + } + + var oversizedCount = items.filter(function (item) { + return item.value > config.threshold; + }).length; + + var monthData = buildMonthData(items, assignments, monthCount); + return { + hasItems: items.length > 0, + oversizedCount, + threshold: config.threshold, + months: monthData.months, + unassigned: monthData.unassigned, + unassignedTotal: monthData.unassignedTotal, + }; +} + +export function optimizePlan(config, state, i18n) { + var items = collectPlanningItems(config, state, i18n); + var existingAssignments = getValidAssignments(state); + var manualAssignments = getManualAssignments(state); + cleanupAssignments(existingAssignments, manualAssignments, items); + + if (items.length === 0) { + state.planningAssignments = {}; + return; + } + + var threshold = config.threshold; + var nextAssignments = {}; + var monthTotals = {}; + var maxMonthIndex = Math.max(1, getMonthCount(state)); + + function ensureMonth(monthIndex) { + if (!monthTotals[monthIndex]) { + monthTotals[monthIndex] = 0; + } + if (monthIndex > maxMonthIndex) { + maxMonthIndex = monthIndex; + } + } + + var oversized = []; + var fixed = []; + var free = []; + var oversizedAnchorMonth = 1; + var oversizedAnchorLocked = false; + var fixedMonths = new Set(); + var fixedMonthTotals = {}; + + for (const item of items) { + var month = parseInt(existingAssignments[item.id], 10); + if (manualAssignments[item.id] && month > 0) { + fixedMonths.add(month); + if (!fixedMonthTotals[month]) { + fixedMonthTotals[month] = 0; + } + fixedMonthTotals[month] += item.value; + } + } + + for (const item of items) { + if (item.value <= threshold) continue; + var assignedMonth = parseInt(existingAssignments[item.id], 10); + if (manualAssignments[item.id] && assignedMonth > 0) { + oversizedAnchorMonth = assignedMonth; + oversizedAnchorLocked = true; + break; + } + } + + if (!oversizedAnchorLocked) { + var monthKeys = Object.keys(fixedMonthTotals) + .map(function (k) { + return parseInt(k, 10); + }) + .sort(function (a, b) { + return a - b; + }); + + for (const month of monthKeys) { + if (fixedMonthTotals[month] > threshold) { + oversizedAnchorMonth = month; + oversizedAnchorLocked = true; + break; + } + } + } + + if ( + !oversizedAnchorLocked && + oversizedAnchorMonth === 1 && + fixedMonths.has(1) + ) { + while (fixedMonths.has(oversizedAnchorMonth)) { + oversizedAnchorMonth += 1; + } + } + + for (const item of items) { + var existingMonth = parseInt(existingAssignments[item.id], 10); + + if (item.value > threshold) { + if (manualAssignments[item.id] && existingMonth > 0) { + fixed.push({ item, month: existingMonth }); + } else { + oversized.push(item); + } + continue; + } + + if (manualAssignments[item.id] && existingMonth > 0) { + fixed.push({ item, month: existingMonth }); + } else { + free.push(item); + } + } + + ensureMonth(oversizedAnchorMonth); + for (const item of oversized) { + nextAssignments[item.id] = oversizedAnchorMonth; + monthTotals[oversizedAnchorMonth] += item.value; + } + + for (const entry of fixed) { + ensureMonth(entry.month); + nextAssignments[entry.item.id] = entry.month; + monthTotals[entry.month] += entry.item.value; + } + + free.sort(function (a, b) { + return b.value - a.value; + }); + + for (const item of free) { + var bestMonth = 0; + var bestRemaining = Infinity; + + for (var monthIndex = 1; monthIndex <= maxMonthIndex; monthIndex++) { + ensureMonth(monthIndex); + var currentTotal = monthTotals[monthIndex]; + if (currentTotal >= threshold) continue; + var remaining = threshold - (currentTotal + item.value); + if (remaining >= 0 && remaining < bestRemaining) { + bestRemaining = remaining; + bestMonth = monthIndex; + } + } + + if (bestMonth === 0) { + bestMonth = maxMonthIndex + 1; + ensureMonth(bestMonth); + } + + nextAssignments[item.id] = bestMonth; + monthTotals[bestMonth] += item.value; + } + + state.planningAssignments = nextAssignments; + state.planningMonthCount = maxMonthIndex; +} + +export function clearOptimization(container, state) { + if (state) { + state.planningAssignments = {}; + state.planningManualAssignments = {}; + state.planningMonthCount = 0; + } + var target = container.querySelector("[data-taxation-planning]"); + if (target) { + target.innerHTML = ""; + } +} + +export function renderOptimization(container, plan, i18n, options) { + var onMove = options && options.onMove; + var onOptimize = options && options.onOptimize; + var onUnlock = options && options.onUnlock; + var onAddMonth = options && options.onAddMonth; + var onCloseMonth = options && options.onCloseMonth; + + var target = container.querySelector("[data-taxation-planning]"); + if (!target) return; + target.innerHTML = ""; + + var focusRow = document.createElement("div"); + focusRow.className = "o-taxation-calculator__planning-focus"; + focusRow.appendChild(createIcon("keyboard_double_arrow_down")); + var focusText = document.createElement("span"); + focusText.textContent = i18n.planningFocus; + focusRow.appendChild(focusText); + focusRow.appendChild(createIcon("keyboard_double_arrow_down")); + target.appendChild(focusRow); + + var title = document.createElement("h3"); + title.className = "o-taxation-calculator__planning-title"; + title.textContent = i18n.planning; + + var description = document.createElement("p"); + description.className = "o-taxation-calculator__planning-description"; + description.textContent = i18n.planningDescription; + + var header = document.createElement("div"); + header.className = "o-taxation-calculator__planning-header"; + header.appendChild(title); + + function createOptimizeButton() { + var button = document.createElement("button"); + button.type = "button"; + button.className = "o-taxation-calculator__optimization-run"; + button.disabled = !plan.hasItems; + button.title = i18n.optimize; + button.setAttribute("aria-label", i18n.optimize); + button.appendChild(createIcon("wand_stars")); + button.appendChild(document.createTextNode(i18n.optimize)); + button.addEventListener("click", function () { + if (typeof onOptimize === "function") { + onOptimize(); + } + }); + return button; + } + + target.appendChild(header); + target.appendChild(description); + + var actions = document.createElement("div"); + actions.className = "o-taxation-calculator__planning-actions"; + actions.appendChild(createOptimizeButton()); + target.appendChild(actions); + + var list = document.createElement("div"); + list.className = "o-taxation-calculator__optimization-list"; + + function activateDropZone(zone) { + zone.classList.add("o-taxation-calculator__optimization-drop-zone--active"); + } + + function deactivateDropZone(zone) { + zone.classList.remove( + "o-taxation-calculator__optimization-drop-zone--active", + ); + } + + function deactivateAllDropZones() { + var activeZones = list.querySelectorAll( + ".o-taxation-calculator__optimization-drop-zone--active", + ); + for (const zone of activeZones) { + deactivateDropZone(zone); + } + } + + function getDropZoneAtPoint(x, y) { + var dropTarget = document.elementFromPoint(x, y); + if (!dropTarget) return null; + return dropTarget.closest( + "[data-month-index], .o-taxation-calculator__optimization-new-month", + ); + } + + function createCard(item) { + var card = document.createElement("li"); + card.className = "o-taxation-calculator__optimization-item"; + card.dataset.itemId = item.id; + + var left = document.createElement("span"); + left.className = "o-taxation-calculator__optimization-item-left"; + + var dragHandle = document.createElement("span"); + dragHandle.className = "o-taxation-calculator__optimization-drag-handle"; + dragHandle.setAttribute("role", "button"); + dragHandle.setAttribute("tabindex", "0"); + dragHandle.draggable = true; + dragHandle.appendChild(createIcon("drag_indicator")); + dragHandle.addEventListener("touchstart", function (event) { + var touch = event.touches && event.touches[0]; + if (!touch) return; + var rect = card.getBoundingClientRect(); + card.classList.add("o-taxation-calculator__optimization-item--dragging"); + card.dataset.touchDragging = "true"; + card.style.position = "fixed"; + card.style.width = rect.width + "px"; + card.style.height = rect.height + "px"; + card.style.boxSizing = "border-box"; + card.style.left = touch.clientX - 16 + "px"; + card.style.top = touch.clientY - 16 + "px"; + card.style.zIndex = "9999"; + card.style.pointerEvents = "none"; + event.preventDefault(); + }); + dragHandle.addEventListener("touchmove", function (event) { + if (card.dataset.touchDragging !== "true") return; + var touch = event.touches && event.touches[0]; + if (!touch) return; + card.style.left = touch.clientX - 16 + "px"; + card.style.top = touch.clientY - 16 + "px"; + deactivateAllDropZones(); + var dropZone = getDropZoneAtPoint(touch.clientX, touch.clientY); + if (dropZone) { + activateDropZone(dropZone); + } + event.preventDefault(); + }); + function finishTouchDrag(event) { + if (card.dataset.touchDragging !== "true") return; + var touch = event.changedTouches && event.changedTouches[0]; + if (!touch) return; + var dropZone = getDropZoneAtPoint(touch.clientX, touch.clientY); + card.classList.remove( + "o-taxation-calculator__optimization-item--dragging", + ); + card.dataset.touchDragging = "false"; + card.style.position = ""; + card.style.width = ""; + card.style.height = ""; + card.style.boxSizing = ""; + card.style.left = ""; + card.style.top = ""; + card.style.zIndex = ""; + card.style.pointerEvents = ""; + deactivateAllDropZones(); + + if (!dropZone || typeof onMove !== "function") { + event.preventDefault(); + return; + } + + if ( + dropZone.classList.contains( + "o-taxation-calculator__optimization-new-month", + ) + ) { + var nextMonth = + plan.months.length > 0 + ? plan.months[plan.months.length - 1].index + 1 + : 1; + onMove(item.id, nextMonth); + } else { + var monthIndex = parseInt(dropZone.dataset.monthIndex, 10); + if (Number.isFinite(monthIndex)) { + onMove(item.id, monthIndex); + } + } + event.preventDefault(); + } + dragHandle.addEventListener("touchend", finishTouchDrag); + dragHandle.addEventListener("touchcancel", finishTouchDrag); + dragHandle.addEventListener("dragstart", function (event) { + card.classList.add("o-taxation-calculator__optimization-item--dragging"); + event.dataTransfer.setData("text/plain", item.id); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setDragImage(card, 16, 16); + }); + dragHandle.addEventListener("dragend", function () { + card.classList.remove( + "o-taxation-calculator__optimization-item--dragging", + ); + }); + left.appendChild(dragHandle); + + var text = document.createElement("span"); + text.className = "o-taxation-calculator__optimization-item-text"; + text.textContent = item.text; + left.appendChild(text); + card.appendChild(left); + + var amount = document.createElement("span"); + amount.className = "o-taxation-calculator__optimization-item-amount"; + amount.textContent = formatCurrency(item.value); + card.appendChild(amount); + + var lockSlot = document.createElement("span"); + lockSlot.className = "o-taxation-calculator__optimization-item-lock-slot"; + if (item.manual) { + var lockBtn = document.createElement("button"); + lockBtn.type = "button"; + lockBtn.className = "o-taxation-calculator__optimization-lock"; + lockBtn.title = i18n.unlock; + lockBtn.setAttribute("aria-label", i18n.unlock); + lockBtn.appendChild(createIcon("lock")); + lockBtn.addEventListener("mousedown", function (event) { + event.stopPropagation(); + }); + lockBtn.addEventListener("click", function () { + if (typeof onUnlock === "function") { + onUnlock(item.id); + } + }); + lockSlot.appendChild(lockBtn); + } + card.appendChild(lockSlot); + + return card; + } + + function createBucket( + labelText, + monthIndex, + items, + isUnassigned, + monthTotal, + ) { + var section = document.createElement("section"); + section.className = "o-taxation-calculator__optimization-month"; + if (isUnassigned) { + section.classList.add( + "o-taxation-calculator__optimization-month--unassigned", + ); + } + + var heading = document.createElement("div"); + heading.className = "o-taxation-calculator__optimization-month-title"; + var headingText = document.createElement("span"); + headingText.textContent = labelText; + headingText.className = "o-taxation-calculator__optimization-month-label"; + heading.appendChild(headingText); + + var headingRight = document.createElement("span"); + headingRight.className = "o-taxation-calculator__optimization-month-right"; + var headingAmount = document.createElement("span"); + headingAmount.className = "o-taxation-calculator__optimization-month-total"; + headingAmount.textContent = formatCurrency(monthTotal); + headingRight.appendChild(headingAmount); + + if (!isUnassigned) { + var statusIcon = createIcon( + monthTotal > plan.threshold ? "warning" : "check_circle", + ); + statusIcon.classList.add( + "o-taxation-calculator__optimization-month-icon", + ); + if (monthTotal > plan.threshold) { + statusIcon.classList.add( + "o-taxation-calculator__optimization-month-icon--warning", + ); + } else { + statusIcon.classList.add( + "o-taxation-calculator__optimization-month-icon--success", + ); + } + headingRight.appendChild(statusIcon); + + var closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = + "o-taxation-calculator__remove-btn o-taxation-calculator__optimization-month-close"; + closeBtn.title = i18n.remove; + closeBtn.setAttribute("aria-label", i18n.remove); + closeBtn.appendChild(createIcon("close")); + closeBtn.addEventListener("click", function () { + if (typeof onCloseMonth === "function") { + onCloseMonth(monthIndex); + } + }); + headingRight.appendChild(closeBtn); + } + + heading.appendChild(headingRight); + + section.appendChild(heading); + + var bucket = document.createElement("ul"); + bucket.className = + "o-taxation-calculator__optimization-month-items o-taxation-calculator__optimization-drop-zone"; + bucket.dataset.monthIndex = String(monthIndex); + + bucket.addEventListener("dragover", function (event) { + event.preventDefault(); + activateDropZone(this); + }); + + bucket.addEventListener("dragleave", function (event) { + if (!this.contains(event.relatedTarget)) { + deactivateDropZone(this); + } + }); + + bucket.addEventListener("drop", function (event) { + event.preventDefault(); + deactivateDropZone(this); + var itemId = event.dataTransfer.getData("text/plain"); + if (!itemId) return; + if (typeof onMove === "function") { + onMove(itemId, monthIndex); + } + }); + + for (const item of items) { + bucket.appendChild(createCard(item)); + } + + section.appendChild(bucket); + return section; + } + + list.appendChild( + createBucket( + i18n.unassigned, + 0, + plan.unassigned, + true, + plan.unassignedTotal, + ), + ); + + for (const month of plan.months) { + list.appendChild( + createBucket( + i18n.optimizationMonth + " " + month.index, + month.index, + month.items, + false, + month.total, + ), + ); + } + + var newMonthDrop = document.createElement("button"); + newMonthDrop.type = "button"; + newMonthDrop.className = + "o-taxation-calculator__optimization-new-month o-taxation-calculator__optimization-drop-zone"; + newMonthDrop.textContent = i18n.addMonth || i18n.optimizationMonth + " +"; + newMonthDrop.setAttribute( + "aria-label", + i18n.addMonth || i18n.optimizationMonth + " +", + ); + newMonthDrop.addEventListener("click", function () { + if (typeof onAddMonth === "function") { + onAddMonth(); + } + }); + + newMonthDrop.addEventListener("dragover", function (event) { + event.preventDefault(); + activateDropZone(this); + }); + + newMonthDrop.addEventListener("dragleave", function (event) { + if (!this.contains(event.relatedTarget)) { + deactivateDropZone(this); + } + }); + + newMonthDrop.addEventListener("drop", function (event) { + event.preventDefault(); + deactivateDropZone(this); + var itemId = event.dataTransfer.getData("text/plain"); + if (!itemId) return; + var nextMonth = + plan.months.length > 0 + ? plan.months[plan.months.length - 1].index + 1 + : 1; + if (typeof onMove === "function") { + onMove(itemId, nextMonth); + } + }); + + list.appendChild(newMonthDrop); + target.appendChild(list); +} diff --git a/assets/js/taxationUi.js b/assets/js/taxationUi.js new file mode 100644 index 000000000..d9f10f06e --- /dev/null +++ b/assets/js/taxationUi.js @@ -0,0 +1,180 @@ +import { + openOverlay, + closeOverlay, + addOverlayClickListener, +} from "./overlay.js"; + +export function initStickySidebar(container) { + const sidebar = container.querySelector(".o-taxation-calculator__sidebar"); + const body = container.querySelector(".o-taxation-calculator__body"); + const anchor = + container.querySelector("[data-taxation-person-limit]") || + container.querySelector("[data-taxation-operators]"); + const sheet = container.querySelector("[data-taxation-sheet]"); + if (!sidebar || !body || !anchor) return; + + const HEADER_OFFSET = 80; + const mq = window.matchMedia("(min-width: 993px)"); + var sidebarNaturalWidth = 0; + var sidebarFixedLeft = 0; + var anchorMargin = 0; + + function measure() { + sidebar.style.position = ""; + sidebar.style.top = ""; + sidebar.style.left = ""; + sidebar.style.right = ""; + sidebar.style.width = ""; + sidebar.style.marginTop = ""; + var sidebarRect = sidebar.getBoundingClientRect(); + sidebarNaturalWidth = sidebar.offsetWidth; + sidebarFixedLeft = sidebarRect.left; + var mainTop = body + .querySelector(".o-taxation-calculator__main") + .getBoundingClientRect().top; + var anchorTop = anchor.getBoundingClientRect().top; + anchorMargin = anchorTop - mainTop; + if (mq.matches) { + sidebar.style.marginTop = anchorMargin + "px"; + } + } + + function updateDesktop() { + var anchorRect = anchor.getBoundingClientRect(); + var bodyRect = body.getBoundingClientRect(); + var sidebarHeight = sidebar.offsetHeight; + + if (anchorRect.top >= HEADER_OFFSET) { + sidebar.style.position = ""; + sidebar.style.top = ""; + sidebar.style.left = ""; + sidebar.style.right = ""; + sidebar.style.width = ""; + sidebar.style.marginTop = anchorMargin + "px"; + } else if (bodyRect.bottom - sidebarHeight >= HEADER_OFFSET) { + sidebar.style.position = "fixed"; + sidebar.style.top = HEADER_OFFSET + "px"; + sidebar.style.left = sidebarFixedLeft + "px"; + sidebar.style.right = ""; + sidebar.style.width = sidebarNaturalWidth + "px"; + sidebar.style.marginTop = "0px"; + } else { + sidebar.style.position = "absolute"; + sidebar.style.top = bodyRect.height - sidebarHeight + "px"; + sidebar.style.left = ""; + sidebar.style.right = "0px"; + sidebar.style.width = sidebarNaturalWidth + "px"; + sidebar.style.marginTop = "0px"; + } + } + + function updateMobile() { + if (!sheet) return; + sidebar.style.position = ""; + sidebar.style.top = ""; + sidebar.style.left = ""; + sidebar.style.right = ""; + sidebar.style.width = ""; + sidebar.style.marginTop = ""; + sheet.classList.add("o-taxation-calculator__mobile-sheet--visible"); + } + + function update() { + if (mq.matches) { + if (sheet) { + sheet.classList.remove("o-taxation-calculator__mobile-sheet--visible"); + closeSheet(); + } + updateDesktop(); + } else { + updateMobile(); + } + } + + var sheetOpen = false; + var toggleBtn = container.querySelector("[data-taxation-sheet-toggle]"); + var sheetContent = container.querySelector("[data-taxation-sheet-content]"); + + function openSheet() { + if (sheetOpen || !sheet) return; + sheetOpen = true; + sheet.classList.add("o-taxation-calculator__mobile-sheet--open"); + if (toggleBtn) toggleBtn.setAttribute("aria-expanded", "true"); + if (sheetContent) { + sheetContent.setAttribute("role", "dialog"); + sheetContent.setAttribute("aria-hidden", "false"); + sheetContent.removeAttribute("inert"); + } + openOverlay("taxationSheet"); + } + + function closeSheet() { + if (!sheetOpen || !sheet) return; + sheetOpen = false; + sheet.classList.remove("o-taxation-calculator__mobile-sheet--open"); + if (toggleBtn) toggleBtn.setAttribute("aria-expanded", "false"); + if (sheetContent) { + sheetContent.removeAttribute("role"); + sheetContent.setAttribute("aria-hidden", "true"); + sheetContent.setAttribute("inert", ""); + } + closeOverlay(); + } + + if (toggleBtn) { + toggleBtn.addEventListener("click", function () { + if (sheetOpen) { + closeSheet(); + } else { + openSheet(); + } + }); + } + + addOverlayClickListener(closeSheet); + + if (toggleBtn) { + var startY = 0; + var isDragging = false; + + function dragStart(e) { + startY = e.clientY || (e.touches && e.touches[0].clientY); + isDragging = true; + } + + function dragging(e) { + if (!isDragging) return; + e.preventDefault(); + var currentY = e.clientY || (e.touches && e.touches[0].clientY); + var deltaY = startY - currentY; + if (!sheetOpen && deltaY > 50) { + openSheet(); + isDragging = false; + } + if (sheetOpen && deltaY < -50) { + closeSheet(); + isDragging = false; + } + } + + function dragEnd() { + isDragging = false; + } + + toggleBtn.addEventListener("mousedown", dragStart); + toggleBtn.addEventListener("mousemove", dragging); + toggleBtn.addEventListener("mouseup", dragEnd); + toggleBtn.addEventListener("touchstart", dragStart); + toggleBtn.addEventListener("touchmove", dragging); + toggleBtn.addEventListener("touchend", dragEnd); + } + + measure(); + + window.addEventListener("scroll", update, { passive: true }); + window.addEventListener("resize", function () { + measure(); + update(); + }); + update(); +} diff --git a/assets/js/taxationUtils.js b/assets/js/taxationUtils.js new file mode 100644 index 000000000..194e5e17e --- /dev/null +++ b/assets/js/taxationUtils.js @@ -0,0 +1,40 @@ +export function formatCurrency(value) { + return ( + value + .toFixed(2) + .replace(".", ",") + .replace(/\B(?=(\d{3})+(?!\d))/g, ".") + "\u00A0\u20AC" + ); +} + +export function createIcon(name) { + const span = document.createElement("span"); + span.className = "a-icon"; + span.setAttribute("aria-hidden", "true"); + span.setAttribute("data-icon", name); + span.style.setProperty( + "--icon-url", + "url('/icons/material-symbols-rounded/" + name + ".svg')", + ); + return span; +} + +export function createOperatorLogo(logoUrl) { + const img = document.createElement("img"); + img.src = logoUrl; + img.alt = ""; + img.className = "a-operator-logo"; + img.setAttribute("data-decorative", "true"); + return img; +} + +export function normalizeText(text) { + return text + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); +} + +export function clampCounter(value) { + return Math.max(Number(value) || 0, 0); +} diff --git a/assets/sass/content.scss b/assets/sass/content.scss index 3a313d9be..404403a2c 100644 --- a/assets/sass/content.scss +++ b/assets/sass/content.scss @@ -2,7 +2,7 @@ font-size: 1.3rem; } -.o-last-updated > .material-symbols-rounded::before { +.o-last-updated > .a-icon { font-size: 1.5rem; } diff --git a/assets/sass/contentNavigation.scss b/assets/sass/contentNavigation.scss index ea6ad4bea..d1edec7d3 100644 --- a/assets/sass/contentNavigation.scss +++ b/assets/sass/contentNavigation.scss @@ -33,6 +33,10 @@ @media print { display: none; } + + .a-icon { + font-size: 1.2em; + } } .o-aside__header { @@ -49,9 +53,9 @@ @include focus-indicator(0.1rem); - > .material-symbols-rounded { + > .a-icon { align-self: flex-start; - margin-top: 0.1rem; + margin-top: 0.3rem; } } @@ -118,19 +122,22 @@ } } - &-date { - padding-left: 2.5rem; + &__date { + margin-left: 2.5rem; + position: relative; + top: -0.2rem; } p { margin-bottom: 0; } -} -.o-related__item figure { - border-radius: var(--border-radius-s); + &__item { + figure { + border-radius: var(--border-radius-s); + } + } } - @media (max-width: #{$breakpoint-lg}) { .o-aside { position: fixed; diff --git a/assets/sass/dropdown.scss b/assets/sass/dropdown.scss index 0f4bba1ca..bddf5c2c5 100644 --- a/assets/sass/dropdown.scss +++ b/assets/sass/dropdown.scss @@ -39,7 +39,7 @@ color: var(--link-hovered); transition: color 0.3s ease; - & > :not(.material-symbols-rounded) { + & > :not(.a-icon) { text-decoration: underline; } } @@ -49,7 +49,7 @@ display: grid; } - &__button[aria-expanded="true"] &__icon { + &__button[aria-expanded="true"] .o-dropdown__icon .a-icon { transform: rotate(180deg); } @@ -105,8 +105,7 @@ } } - &__icon { - font-size: 0.7rem; + .o-dropdown__icon .a-icon { transition: transform 0.2s; } diff --git a/assets/sass/expander.scss b/assets/sass/expander.scss index c1095336d..1f865b6cc 100644 --- a/assets/sass/expander.scss +++ b/assets/sass/expander.scss @@ -39,7 +39,7 @@ } } - > .material-symbols-rounded { + > .a-icon { transition: transform 0.2s; @media print { @@ -133,7 +133,7 @@ details { } } -details[open] > .o-expander__summary > .material-symbols-rounded { +details[open] > .o-expander__summary > .a-icon { transform: rotate(180deg); } diff --git a/assets/sass/fonts.scss b/assets/sass/fonts.scss index 8b5202314..7fbf7a754 100644 --- a/assets/sass/fonts.scss +++ b/assets/sass/fonts.scss @@ -104,23 +104,6 @@ U+2215, U+FEFF, U+FFFD; } -/* material-symbols-rounded-latin-400-normal */ -@font-face { - font-family: "Material Symbols Rounded"; - font-style: normal; - font-display: swap; - font-weight: 400; - src: - url(@fontsource/material-symbols-rounded/files/material-symbols-rounded-latin-400-normal.woff2) - format("woff2"), - url(@fontsource/material-symbols-rounded/files/material-symbols-rounded-latin-400-normal.woff) - format("woff"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, - U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, - U+2212, U+2215, U+FEFF, U+FFFD; -} - body, button { font-family: "Roboto", Arial, Helvetica, sans-serif; @@ -128,22 +111,14 @@ button { hyphens: auto; } -.material-symbols-rounded::before { - content: attr(data-icon); - font-family: "Material Symbols Rounded"; - font-weight: normal; - font-style: normal; +.a-icon { display: inline-block; - font-size: 2rem; + width: 1em; + height: 1em; line-height: 1; - text-transform: none; - letter-spacing: normal; - word-wrap: normal; - white-space: nowrap; - direction: ltr; vertical-align: text-bottom; text-decoration: none; - width: 1em; - height: 1em; user-select: none; + background-color: currentColor; + mask: var(--icon-url) no-repeat center / contain; } diff --git a/assets/sass/header.scss b/assets/sass/header.scss index 6511f2ef5..ab82488be 100644 --- a/assets/sass/header.scss +++ b/assets/sass/header.scss @@ -107,6 +107,11 @@ z-index: 10; } +#header:has(.overlay--taxationSheet), +.overlay--taxationSheet { + z-index: 9; +} + body:has(.overlay--show), body:has(dialog[open]) { overflow: hidden; diff --git a/assets/sass/main.scss b/assets/sass/main.scss index 258bd7960..acfaf85af 100644 --- a/assets/sass/main.scss +++ b/assets/sass/main.scss @@ -25,3 +25,4 @@ @import "teamMember.scss"; @import "dialog.scss"; @import "fip-validity.scss"; +@import "taxation.scss"; diff --git a/assets/sass/navigation.scss b/assets/sass/navigation.scss index 142d13f4a..7e595eb21 100644 --- a/assets/sass/navigation.scss +++ b/assets/sass/navigation.scss @@ -135,7 +135,7 @@ menu > li > menu { &:hover, &:focus { - :not(.material-symbols-rounded) { + :not(.a-icon) { text-decoration: underline; } color: var(--link-hovered); @@ -150,7 +150,7 @@ menu > li > menu { display: none; } - > .material-symbols-rounded { + > .a-icon { margin-left: 0.8rem; align-self: center; text-decoration: none; @@ -233,6 +233,10 @@ menu > li > menu { &:hover { color: var(--link-hovered); } + + > .a-icon { + font-size: 1.15em; + } } body:has(.o-header__nav--open) { diff --git a/assets/sass/search.scss b/assets/sass/search.scss index 4c0997aa5..d86cb95d6 100644 --- a/assets/sass/search.scss +++ b/assets/sass/search.scss @@ -17,15 +17,19 @@ $search-z-index-focus: 12; position: relative; &::before { - content: "search"; + content: ""; + display: block; position: absolute; - top: 23px; + top: 22px; left: 20px; z-index: $search-z-index + 1; - font-family: "Material Symbols Rounded"; + width: 2.4rem; + height: 2.4rem; + background-color: currentColor; + mask: url("/icons/material-symbols-rounded/search.svg") no-repeat + center / contain; opacity: 0.7; pointer-events: none; - line-height: 1; } } @@ -148,8 +152,13 @@ $search-z-index-focus: 12; } .pagefind-ui__result-link::before { - content: "subdirectory_arrow_right"; - font-family: "Material Symbols Rounded"; + content: ""; + display: block; + width: 1.8rem; + height: 1.8rem; + background-color: currentColor; + mask: url("/icons/material-symbols-rounded/subdirectory_arrow_right.svg") + no-repeat center / contain; position: absolute; left: -2.4rem; } diff --git a/assets/sass/startpage.scss b/assets/sass/startpage.scss index d45f28edd..2c38cf6fd 100644 --- a/assets/sass/startpage.scss +++ b/assets/sass/startpage.scss @@ -45,6 +45,10 @@ gap: 2rem; margin-bottom: 2rem; + @media (max-width: #{$breakpoint-xl}) { + grid-template-columns: 1fr 1fr 1fr 1fr 2fr; + } + @media (max-width: #{$breakpoint-lg}) { display: flex; gap: 1rem; diff --git a/assets/sass/styles.scss b/assets/sass/styles.scss index 8dad07a23..ad202255a 100644 --- a/assets/sass/styles.scss +++ b/assets/sass/styles.scss @@ -79,12 +79,12 @@ a, text-decoration: none; color: var(--bs-body-color); - > .material-symbols-rounded { + > .a-icon { display: none; } } - & > .material-symbols-rounded { + & > .a-icon { margin: 0 0.2rem; } } @@ -205,10 +205,6 @@ figure { } } -.o-related__date { - margin-left: 2.4rem; -} - .o-list__list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); @@ -439,7 +435,7 @@ details > summary { .o-link__mail, .o-link__tel { - & > .material-symbols-rounded { + & > .a-icon { margin-right: 0.4rem; } } diff --git a/assets/sass/taxation.scss b/assets/sass/taxation.scss new file mode 100644 index 000000000..3cd5711d0 --- /dev/null +++ b/assets/sass/taxation.scss @@ -0,0 +1,1125 @@ +.o-taxation-calculator { + margin-top: 1rem; + --taxation-controls-width: 10.1rem; + --taxation-btn-width: 3.2rem; + --taxation-field-value-width: 3.6rem; + + &__subtitle { + color: var(--color-body); + opacity: 1; + margin-bottom: 2rem; + } + + &__person-limit { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 1rem 1.2rem; + border-radius: var(--border-radius-m); + background: var(--bg-neutral); + margin-bottom: 1.5rem; + } + + &__person-limit-controls { + display: flex; + align-items: center; + gap: 0; + } + + &__person-limit-label { + font-weight: bold; + white-space: nowrap; + margin-right: auto; + } + + &__person-limit-icons { + display: flex; + justify-content: flex-end; + gap: 0.1rem; + white-space: nowrap; + } + + &__person-limit-value { + display: flex; + align-items: center; + justify-content: center; + width: 3.6rem; + height: 3.2rem; + font-weight: bold; + font-size: 1.2rem; + font-variant-numeric: tabular-nums; + } + + &__person-limit-icons .o-taxation-calculator__person { + font-size: 1.8rem; + } + + &__reset-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 0.2rem solid var(--link-default); + border-radius: var(--border-radius-m); + background: transparent; + color: var(--color-body); + padding: 0.5rem 0.8rem; + font-family: inherit; + font-size: 0.95em; + cursor: pointer; + transition: + background 0.2s, + color 0.2s; + + .a-icon { + font-size: 1.1em; + } + + &:hover, + &:focus { + background: rgba($link-hovered, 0.15); + color: var(--link-hovered); + } + + @include focus-indicator(0.2rem); + } + + &__reset-btn, + &__optimization-run, + &__optimization-new-month, + &__optimization-lock, + &__search-item-btn, + &__mobile-header { + @include focus-indicator(0.2rem); + } + + &__summary-actions { + display: flex; + justify-content: flex-end; + gap: 0.8rem; + margin-top: 1.2rem; + } + + &__planning { + margin-top: 2rem; + } + + &__planning-focus { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + margin-bottom: 1rem; + font-size: 1em; + font-weight: normal; + font-style: italic; + color: var(--color-body); + + .a-icon { + color: var(--tag-warning-color); + opacity: 1; + font-size: 1.6em; + flex-shrink: 0; + } + } + + &__planning-title { + display: flex; + align-items: center; + gap: 0.6rem; + margin: 0; + font-size: 1.15em; + + &::before { + content: ""; + display: inline-flex; + width: 1.2em; + height: 1.2em; + background: var(--color-body); + mask: var(--icon-url) center / contain no-repeat; + -webkit-mask: var(--icon-url) center / contain no-repeat; + --icon-url: url("/icons/material-symbols-rounded/calendar_month.svg"); + opacity: 0.6; + flex-shrink: 0; + } + } + + &__planning-description { + margin-top: 0.8rem; + margin-bottom: 1rem; + } + + &__planning-header { + display: flex; + align-items: flex-start; + justify-content: flex-start; + gap: 0.8rem; + padding-bottom: 0.8rem; + border-bottom: var(--border-visible); + } + + &__planning-actions { + display: flex; + justify-content: flex-end; + margin-top: 0.2rem; + margin-bottom: 0.8rem; + } + + &__planning-header > &__optimization-run { + margin: 0; + } + + &__optimization-run { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 0.2rem solid var(--link-default); + border-radius: var(--border-radius-m); + background: transparent; + color: var(--color-body); + padding: 0.35rem 0.7rem; + font-family: inherit; + cursor: pointer; + + .a-icon { + font-size: 1.6rem; + } + + &:hover, + &:focus { + background: rgba($link-hovered, 0.15); + color: var(--link-hovered); + } + } + + &__optimization-list { + display: flex; + flex-direction: column; + gap: 0.8rem; + } + + &__optimization-month { + background: var(--bg-neutral); + border-radius: var(--border-radius-s); + padding: 0.8rem 1rem; + + &--unassigned { + background: color-mix(in srgb, var(--bg-neutral) 72%, var(--bg-default)); + border: 0.2rem dashed rgba($link-default, 0.35); + + .o-taxation-calculator__optimization-month-label { + opacity: 0.85; + } + } + } + + &__optimization-month-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-weight: bold; + margin-bottom: 0.4rem; + } + + &__optimization-month-label { + min-width: 0; + } + + &__optimization-month-right { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex-shrink: 0; + } + + &__optimization-month-total { + font-weight: bold; + min-width: 7.8rem; + text-align: right; + font-variant-numeric: tabular-nums; + } + + &__optimization-month-icon { + flex-shrink: 0; + + &--warning { + color: var(--tag-warning-color); + } + + &--success { + color: var(--tag-success-color); + } + } + + &__optimization-month-close { + width: 2.8rem; + height: 2.8rem; + border-radius: var(--border-radius-m); + + .a-icon { + font-size: 1.6rem; + } + } + + &__optimization-month-items { + margin: 0; + padding: 0; + list-style: none; + min-height: 3.8rem; + } + + &__optimization-drop-zone { + border: 0.2rem dashed transparent; + border-radius: var(--border-radius-s); + transition: + border-color 0.2s, + background 0.2s; + + &--active { + border-color: rgba($link-default, 0.6); + background: rgba($link-hovered, 0.04); + } + } + + &__optimization-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + cursor: default; + background: var(--bg-default); + border-radius: var(--border-radius-s); + padding: 0.5rem 0.6rem; + margin: 0.4rem; + } + + &__optimization-drag-handle { + display: inline-flex; + align-items: center; + cursor: grab; + color: var(--color-body); + opacity: 0.5; + + &:active { + cursor: grabbing; + } + + .a-icon { + font-size: 1.4rem; + } + } + + &__optimization-item-left { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + flex: 1 1 auto; + } + + &__optimization-item-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__optimization-item-amount { + margin-left: auto; + font-weight: normal; + font-variant-numeric: tabular-nums; + flex-shrink: 0; + min-width: 7.8rem; + text-align: right; + } + + &__optimization-item-lock-slot { + width: 2.8rem; + min-width: 2.8rem; + display: inline-flex; + justify-content: flex-end; + align-items: center; + } + + @media (max-width: $breakpoint-lg) { + &__optimization-month { + padding: 0.7rem 0.8rem; + } + + &__optimization-month-title { + gap: 0.4rem; + } + + &__optimization-month-right { + gap: 0.3rem; + } + + &__optimization-month-total { + min-width: auto; + font-size: 0.95em; + } + + &__optimization-item { + margin: 0.3rem; + gap: 0.4rem; + padding: 0.45rem 0.5rem; + touch-action: none; + } + + &__optimization-item-text { + white-space: normal; + overflow: visible; + text-overflow: clip; + overflow-wrap: anywhere; + } + + &__optimization-item-amount { + min-width: auto; + font-size: 0.95em; + } + + &__optimization-item-lock-slot { + width: 2.4rem; + min-width: 2.4rem; + } + + &__optimization-drag-handle { + touch-action: none; + } + } + + &__optimization-item--dragging { + opacity: 0.5; + } + + &__optimization-new-month { + border: 0.2rem dashed rgba($link-default, 0.5); + border-radius: var(--border-radius-m); + padding: 1rem; + background: var(--bg-default); + color: var(--link-default); + text-align: center; + font-family: inherit; + font-size: 0.95em; + cursor: default; + transition: + background 0.2s, + border-color 0.2s; + + &--active, + &.o-taxation-calculator__optimization-drop-zone--active { + border-color: var(--link-default); + background: rgba($link-hovered, 0.06); + } + } + + &__optimization-lock { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 2.4rem; + border: 0; + border-radius: var(--border-radius-s); + background: transparent; + color: var(--link-default); + cursor: pointer; + flex-shrink: 0; + + .a-icon { + font-size: 1.5rem; + } + + &:hover, + &:focus { + background: rgba($link-hovered, 0.15); + color: var(--link-hovered); + } + } + + &__body { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1.5rem; + align-items: start; + position: relative; + + @media (max-width: $breakpoint-lg) { + display: flex; + flex-direction: column; + } + } + + &__main { + min-width: 0; + } + + &__sidebar { + @media (max-width: $breakpoint-lg) { + display: none; + } + } + + &__sections { + display: flex; + flex-direction: column; + gap: 2.5rem; + } + + &__section { + h3 { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1.2rem; + padding-bottom: 0.8rem; + border-bottom: var(--border-visible); + + .a-icon { + font-size: 1.2em; + opacity: 0.6; + } + } + } + + &__operators, + &__national { + display: flex; + flex-direction: column; + gap: 1.2rem; + } + + &__row { + padding: 1.2rem; + background-color: var(--bg-neutral); + border-radius: var(--border-radius-m); + border: 0.2rem solid transparent; + } + + &__row-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + } + + &__row-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: bold; + font-size: 1.05em; + flex: 1 1 auto; + + .a-operator-logo { + height: 1.4em; + flex-shrink: 0; + } + } + + &__remove-btn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2.8rem; + height: 2.8rem; + border: 0; + background: transparent; + color: var(--color-body); + opacity: 0.4; + cursor: pointer; + padding: 0; + border-radius: var(--border-radius-m); + transition: + opacity 0.2s, + background 0.2s, + color 0.2s; + + .a-icon { + font-size: 1.6rem; + } + + &:hover, + &:focus { + opacity: 1; + color: var(--link-hovered); + background: rgba($link-hovered, 0.15); + } + + @include focus-indicator(0.2rem); + } + + &__row-body { + display: flex; + align-items: flex-start; + gap: 1rem; + } + + &__row-col { + &--left { + display: flex; + flex-direction: column; + gap: 0.6rem; + flex: 1 1 auto; + } + + &--right { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.6rem 1rem; + margin-left: auto; + } + } + + &__class-line { + display: flex; + align-items: center; + gap: 0.8rem; + min-height: 3.2rem; + } + + &__class-label { + font-size: 0.9em; + width: 7rem; + flex-shrink: 0; + white-space: nowrap; + color: var(--color-body); + opacity: 0.7; + } + + &__class-value { + font-size: 0.9em; + width: 5.5rem; + flex-shrink: 0; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--color-body); + opacity: 0.8; + } + + &__row-total { + font-weight: bold; + font-size: 1.2em; + font-variant-numeric: tabular-nums; + white-space: nowrap; + margin-left: auto; + text-align: right; + transition: color 0.2s; + + &--active { + color: var(--link-default); + } + } + + &__field-controls { + display: flex; + align-items: center; + width: var(--taxation-controls-width); + gap: 0; + } + + &__multiply-symbol { + font-size: 0.9em; + color: var(--color-body); + opacity: 0.7; + margin-left: 0.1rem; + margin-right: 0.75rem; + } + + &__btn { + display: flex; + align-items: center; + justify-content: center; + width: var(--taxation-btn-width); + height: 3.2rem; + border: 0.2rem solid var(--link-default); + background: transparent; + color: var(--color-body); + cursor: pointer; + padding: 0; + border-radius: var(--border-radius-m); + transition: + background 0.2s, + color 0.2s; + + .a-icon { + font-size: 1.8rem; + } + + &:disabled { + opacity: 0.25; + cursor: not-allowed; + } + + &:not(:disabled):hover, + &:not(:disabled):focus { + background: rgba($link-hovered, 0.15); + } + + @include focus-indicator(0.2rem); + } + + &__field-value { + display: flex; + align-items: center; + justify-content: center; + width: var(--taxation-field-value-width); + height: 3.2rem; + background: transparent; + font-weight: bold; + font-size: 1.2rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + &__field-value--text { + font-size: 0.85em; + font-weight: normal; + font-style: italic; + opacity: 0.75; + flex: 0 0 var(--taxation-field-value-width); + text-align: center; + transform: translateX(0.72rem); + } + + &__multiplier { + font-size: 0.85em; + color: var(--color-body); + opacity: 0.55; + margin-left: 0.6rem; + white-space: normal; + line-height: 1.2; + text-indent: 0; + padding-left: 0; + } + + &__person { + font-size: 2rem; + color: var(--link-default); + } + + &__warning-wrapper { + margin-top: 0.8rem; + + &:empty { + margin-top: 0; + } + + .m-text-highlight { + margin-bottom: 0; + } + } + + &__other-input, + &__search-input { + width: 100%; + box-sizing: border-box; + padding: 0.8rem 1.2rem; + border: var(--border-visible); + border-radius: var(--border-radius-m); + background: var(--bg-default); + color: var(--color-body); + font-size: 1em; + font-family: inherit; + transition: border-color 0.15s ease; + + &::placeholder { + opacity: 0.45; + } + + &:focus { + outline: var(--outline-focus-indicator) solid 0.2rem; + outline-offset: 0.2rem; + } + } + + &__other-input { + max-width: 100%; + border-radius: var(--border-radius-s); + } + + &__summary { + padding: 1.6rem; + border: 0.2rem solid var(--link-default); + border-radius: var(--border-radius-m); + background: var(--bg-neutral); + } + + > .m-text-highlight { + margin-top: 1.5rem; + } + + &__sum-row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 0.6rem; + font-size: 1.15em; + } + + &__sum-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: bold; + + .a-icon { + font-size: 1.3em; + opacity: 0.6; + } + } + + &__sum-value { + font-weight: bold; + font-size: 1.4em; + font-variant-numeric: tabular-nums; + color: var(--link-default); + + .o-taxation-calculator__sidebar & { + font-size: 1.6em; + } + } + + &__sum-right { + display: flex; + align-items: center; + gap: 1rem; + } + + @media (min-width: 993px) and (max-width: 1200px) { + &__sidebar &__sum-right { + flex-direction: column; + align-items: flex-end; + gap: 0.3rem; + } + + &__sidebar &__sum-value { + order: 1; + } + } + + &__threshold-info { + display: flex; + align-items: flex-start; + gap: 0.8rem; + margin-top: 1.2rem; + padding: 0.8rem 1.2rem; + font-size: 0.92em; + line-height: 1.5; + white-space: normal; + + > .a-icon { + font-size: 1.2em; + flex-shrink: 0; + margin-top: 0.15em; + } + } + + &__search-select { + position: relative; + margin-top: 0.4rem; + } + + &__search-list { + display: none; + position: absolute; + z-index: 20; + top: 100%; + left: 0; + right: 0; + margin: 0.4rem 0 0; + padding: 0; + list-style: none; + background: var(--bg-default); + border: var(--border-visible); + border-radius: var(--border-radius-m); + box-shadow: var(--box-shadow); + max-height: 24rem; + overflow-y: auto; + animation: dropdown-open-below 0.2s ease; + + .o-taxation-calculator__search-select--open & { + display: block; + } + } + + &__search-item { + &:first-child &-btn { + border-radius: var(--border-radius-m) var(--border-radius-m) 0 0; + } + + &:last-child &-btn { + border-radius: 0 0 var(--border-radius-m) var(--border-radius-m); + } + } + + &__search-item-btn { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.7rem 1rem; + border: 0; + background: transparent; + color: var(--color-body); + font-size: 0.95em; + font-family: inherit; + cursor: pointer; + text-align: left; + transition: + background 0.2s, + color 0.2s; + + .a-operator-logo { + height: 1.4em; + flex-shrink: 0; + } + + &:hover, + &:focus { + color: var(--link-hovered); + background: rgba($link-hovered, 0.15); + } + } + + &__search-add-icon { + font-size: 1.2em; + color: var(--link-default); + flex-shrink: 0; + } + + @media (max-width: $breakpoint-sm) { + &__person-limit { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + "label controls" + "icons icons"; + align-items: start; + column-gap: 0.7rem; + row-gap: 0.6rem; + } + + &__person-limit-label { + grid-area: label; + margin-right: 0; + } + + &__person-limit-controls { + grid-area: controls; + } + + &__person-limit-icons { + grid-area: icons; + max-width: 100%; + margin-top: 0.25rem; + overflow: hidden; + } + + &__row-body { + flex-direction: column; + gap: 0.6rem; + } + + &__row-col--right { + margin-left: 0; + align-self: stretch; + justify-content: flex-end; + } + + &__summary-actions { + flex-wrap: wrap; + justify-content: flex-start; + } + + &__class-line { + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.6rem; + min-height: auto; + } + + &__class-label { + width: auto; + min-width: 5.8rem; + } + + &__class-value { + width: auto; + min-width: 4.8rem; + text-align: left; + } + + &__field-controls { + margin-left: auto; + } + + &__multiplier { + margin-top: -1rem; + flex: 1 1 100%; + margin-left: 0; + align-self: flex-start; + } + + &__planning-header { + align-items: center; + margin-bottom: 0.6rem; + } + + &__planning-title { + font-size: 1.08em; + } + } + + &__mobile-sheet { + display: none; + + @media (max-width: $breakpoint-lg) { + display: block; + position: fixed; + bottom: 0; + left: 0; + width: 100%; + z-index: 10; + overflow: hidden; + flex-flow: column; + transition: transform 0.25s ease-in-out; + border-radius: var(--border-radius-m) var(--border-radius-m) 0 0; + transform: translateY(100%); + box-shadow: var(--box-shadow); + max-height: calc(100vh - 10rem - env(safe-area-inset-bottom)); + pointer-events: none; + + &--visible { + transform: translateY( + calc(100% - 6.8rem - env(safe-area-inset-bottom)) + ); + pointer-events: auto; + } + + &--open { + transform: translateY(0); + } + + &:not(&--open) > .o-taxation-calculator__mobile-header { + border-bottom: none; + } + + &:not(&--open) > .o-taxation-calculator__mobile-content { + pointer-events: none; + user-select: none; + + * { + opacity: 0; + } + } + } + } + + &__mobile-header { + display: flex; + align-items: center; + flex-direction: column; + gap: 1.5rem; + width: 100%; + border-top: var(--border); + border-bottom: none; + border-left: none; + border-right: none; + padding: 1rem 1rem 1.4rem; + height: 6.8rem; + border-radius: 1rem 1rem 0 0; + background: var(--bg-default); + color: var(--color-body); + cursor: pointer; + font-family: inherit; + font-size: inherit; + appearance: none; + box-shadow: none; + + &:focus, + &:focus-visible { + outline: none; + box-shadow: none; + } + } + + &__mobile-drag { + height: 0.4rem; + width: 4rem; + display: block; + background: var(--color-body); + border-radius: 2rem; + flex-shrink: 0; + } + + &__mobile-bar { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + } + + &__mobile-sum-group { + display: flex; + align-items: center; + gap: 0.5rem; + + .a-icon { + font-size: 1.3em; + opacity: 0.6; + } + } + + &__mobile-sum-label { + font-weight: bold; + white-space: nowrap; + } + + &__mobile-bar-right { + display: flex; + align-items: center; + gap: 0.6rem; + margin-left: auto; + } + + &__mobile-threshold-icon { + display: flex; + align-items: center; + } + + &__mobile-icon--warning { + color: var(--tag-warning-color); + } + + &__mobile-icon--success { + color: var(--tag-success-color); + } + + &__mobile-content { + background: var(--bg-default); + border-top: var(--border-visible); + overflow-y: auto; + overscroll-behavior-y: contain; + padding: 1.5rem; + + > .o-taxation-calculator__threshold-info { + margin-top: 0; + } + + > .o-taxation-calculator__mobile-reset-actions { + margin-top: 1rem; + justify-content: flex-end; + display: flex; + } + + * { + transition: opacity 0.3s ease-in-out; + } + } +} diff --git a/assets/sass/teaser.scss b/assets/sass/teaser.scss index 44111eaa6..7428948b1 100644 --- a/assets/sass/teaser.scss +++ b/assets/sass/teaser.scss @@ -23,7 +23,7 @@ @include focus-indicator(0.3rem); - .material-symbols-rounded::before { + .a-icon { font-size: 2.4rem; margin-top: 1rem; } @@ -110,7 +110,7 @@ flex-direction: column; justify-content: space-between; - > .material-symbols-rounded { + > .a-icon { @media print { display: none; } diff --git a/content/operator/db/taxation.yaml b/content/operator/db/taxation.yaml new file mode 100644 index 000000000..b107c98f7 --- /dev/null +++ b/content/operator/db/taxation.yaml @@ -0,0 +1,234 @@ +year: 2026 +threshold: 50 +info: + de: | + FIP Freifahrtscheine von Mitarbeitenden in Deutschland gelten als _Geldwerter Vorteil_ und unterliegen § 8 des Einkommensteuergesetzes (EStG). Die FIP Freifahrtscheine sind somit steuer- und sozialversicherungspflichtig. + + Der angerechnete Wert unterscheidet sich je nach Streckennetzlänge die mit dem jeweiligen Freifahrtschein gefahren werden kann und wird jährlich aktualisiert. Mit dem [FIP Guide Steuerrechner](#calculator) kannst du den Wert aller deiner Freifahrten berechnen. + + Monatlich werden alle Sachbezugswerte zusammengerechnet (auch andere geldwerte Vorteile wie nationale Freifahrten). Wenn die Summe einen gewissen Grenzwert übersteigt (50 € in 2025) wird der gesamte Betrag (nicht nur, was über dem Grenzwert liegt, sondern alles) auf das zu versteuernde Einkommen (üblicherweise das Bruttogehalt) aufgeschlagen. Dementsprechend höher fallen in diesem Monat dann Steuer- und Sozialabgaben aus. + + FIP Freifahrtscheine und nationale Fahrvergünstigungen von Angehörigen werden dem Mitarbeitenden angerechnet. Für FIP 50 Tickets wird kein Geldwerter Vorteil berechnet. + en: | + FIP Coupons for employees in Germany are considered _non-monetary compensation_ subject to § 8 of the German Income Tax Act (EStG). FIP Coupons are therefore subject to income tax and social security contributions. + + The assessed value differs depending on the network length that can be covered with the respective coupon and is updated annually. A list of the current amounts is available in the DB Reisemarkt or DB employee portal. + + Each month, all taxable benefits are added together (including other non-monetary benefits such as national FIP discounts). If the sum exceeds a certain threshold (€50 in 2025), the entire amount (not just what exceeds the threshold, but everything) is added to taxable income (usually gross salary). Accordingly, taxes and social contributions are higher that month. + + FIP Coupons and national travel discounts of dependents are counted toward the employee. No non-monetary compensation is calculated for FIP 50 tickets. + fr: | + Les Coupons FIP pour les employés en Allemagne sont considérés comme une _réduction non monétaire_ assujettie à l’article 8 de la Loi de l’impôt sur le revenu allemand (EStG). Les Coupons FIP sont donc assujettis à l’impôt sur le revenu et aux cotisations de sécurité sociale. + + La valeur évaluée varie selon la longueur du réseau qui peut être couverte avec le coupon respectif et est mise à jour annuellement. Une liste des montants actuels est disponible sur le marché des voyages DB ou le portail des employés DB. + + Chaque mois, tous les avantages imposables sont additionnés (y compris les autres avantages non monétaires tels que les réductions FIP de voyage nationales). Si la somme dépasse un certain seuil (50 € en 2025), le montant entier (pas seulement ce qui dépasse le seuil, mais tout) est ajouté au revenu imposable (généralement le salaire brut). En conséquence, les impôts et les cotisations sociales sont plus élevés ce mois-là. + + Les Coupons FIP et les réductions FIP de voyage nationales des ayants droit sont comptabilisés auprès de l’employé. Aucune réduction non monétaire n’est calculée pour les billets FIP 50. +national: + tagesticket-m-fern-zu: + title: TagesTicket M Fern Zu + first_class: 50.00 + second_class: 24.96 + children_half: true + tagesticket-m-fern-f: + title: TagesTicket M Fern F + first_class: 87.25 + second_class: 49.96 + children_half: true +operators: + bdz: + title: BDŽ (Bulgarien) + max_fields: 4 + first_class: 5.27 + second_class: 3.23 + bls: + title: BLS (Schweiz) + max_fields: 4 + first_class: 0.57 + second_class: 0.35 + cd: + title: ČD (Tschechien) + max_fields: 4 + first_class: 12.24 + second_class: 7.50 + cfl: + title: CFL (Luxemburg) + max_fields: 4 + first_class: 0.35 + second_class: 0.22 + cfr: + title: CFR Călători (Rumänien) + max_fields: 4 + first_class: 13.90 + second_class: 8.51 + cie: + title: CIE (Irland) + max_fields: 4 + first_class: 2.16 + second_class: 1.32 + cp: + title: CP (Portugal) + max_fields: 4 + first_class: 3.31 + second_class: 2.03 + dsb: + title: DSB (Dänemark) + max_fields: 4 + first_class: 2.79 + second_class: 1.71 + euskotren: + title: Euskotren (Spanien) + max_fields: 4 + no_first_class: true + second_class: 0.15 + fs: + title: Trenitalia (Italien) + max_fields: 4 + first_class: 22.72 + second_class: 13.91 + gb: + title: National Rail (Vereinigtes Königreich) + max_fields: 4 + first_class: 20.53 + second_class: 12.57 + gysev: + title: GySEV / Raaberbahn (Österreich) + max_fields: 4 + first_class: 0.66 + second_class: 0.41 + ht: + title: Hellenic Train (Griechenland) + max_fields: 4 + first_class: 2.39 + second_class: 1.46 + hz: + title: HŽPP (Kroatien) + max_fields: 4 + first_class: 3.43 + second_class: 2.10 + kd: + title: KD (Polen) + max_fields: 4 + no_first_class: true + second_class: 0.85 + ks: + title: KŚ (Polen) + max_fields: 4 + no_first_class: true + second_class: 0.61 + kw: + title: KW (Polen) + max_fields: 4 + no_first_class: true + second_class: 0.74 + lka: + title: ŁKA (Polen) + max_fields: 4 + no_first_class: true + second_class: 0.52 + ltg: + title: LTG-Link (Litauen) + max_fields: 4 + first_class: 2.51 + second_class: 1.54 + mav: + title: MÁV-START (Ungarn) + max_fields: 4 + first_class: 9.00 + second_class: 5.51 + nir: + title: NIR (Vereinigtes Königreich) + max_fields: 4 + first_class: 0.44 + second_class: 0.27 + ns: + title: NS (Niederlande) + max_fields: 4 + first_class: 3.97 + second_class: 2.43 + oebb: + title: ÖBB (Österreich) + max_fields: 4 + first_class: 6.57 + second_class: 4.02 + pkp: + title: PKP (Polen) + max_fields: 4 + first_class: 24.65 + second_class: 15.10 + renfe: + title: Renfe (Spanien) + max_fields: 4 + first_class: 20.49 + second_class: 12.55 + sbb: + title: SBB CFF FFS (Schweiz) + max_fields: 4 + first_class: 4.28 + second_class: 2.62 + sncb: + title: SNCB / NMBS (Belgien) + max_fields: 4 + first_class: 4.75 + second_class: 2.91 + sncf: + title: SNCF Voyageurs (Frankreich) + max_fields: 4 + first_class: 35.42 + second_class: 21.70 + sp: + title: Schweizer Privatbahnen (Schweiz) + max_fields: 4 + first_class: 0.46 + second_class: 0.28 + sv: + title: Srbija Voz (Serbien) + max_fields: 4 + first_class: 4.40 + second_class: 2.69 + sz: + title: SŽ (Slowenien) + max_fields: 4 + first_class: 1.58 + second_class: 0.97 + vy: + title: Vy (Norwegen) + max_fields: 4 + first_class: 5.09 + second_class: 3.12 + zpcg: + title: ŽPCG (Montenegro) + max_fields: 4 + first_class: 0.33 + second_class: 0.20 + zrs: + title: ŽRS (Bosnien und Herzegowina) + max_fields: 4 + first_class: 0.55 + second_class: 0.33 + zrsm: + title: ŽRSM (Nordmazedonien) + max_fields: 4 + first_class: 0.89 + second_class: 0.55 + zssk: + title: ZSSK / ŽSR (Slowakei) + max_fields: 4 + first_class: 4.75 + second_class: 2.91 + sll: + title: Stena Line Limited (Irland) + max_fields: 4 + no_first_class: true + second_class: 0.28 + stl: + title: Stena Line BV (Niederlande) + max_fields: 2 + no_first_class: true + second_class: 0.28 + bsb: + title: BSB (Österreich) + max_fields: 4 + no_first_class: true + second_class: 0.41 diff --git a/content/operator/db/validity.yaml b/content/operator/db/validity.yaml index f1081bc21..07a6948fa 100644 --- a/content/operator/db/validity.yaml +++ b/content/operator/db/validity.yaml @@ -61,37 +61,6 @@ card-validity: fr: | La Carte FIP est valable pour une période fixe de trois ans. La période actuelle est 2025-2026-2027. -taxation: - de: | - FIP Freifahrtscheine von Mitarbeitenden in Deutschland gelten als _Geldwerter Vorteil_ und unterliegen § 8 des Einkommensteuergesetzes (EStG). Die FIP Freifahrtscheine sind somit steuer- und sozialversicherungspflichtig. - - Der angerechnete Wert unterscheidet sich je nach Streckennetzlänge die mit dem jeweiligen Freifahrtschein gefahren werden kann und wird jährlich aktualisiert. Eine Liste der genauen aktuellen Beträge ist im DB Reisemarkt oder DB Personalportal verfügbar. - - Monatlich werden alle Sachbezugswerte zusammengerechnet (auch andere geldwerte Vorteile wie nationale Freifahrten). Wenn die Summe einen gewissen Grenzwert übersteigt (50 € in 2025) wird der gesamte Betrag (nicht nur, was über dem Grenzwert liegt, sondern alles) auf das zu versteuernde Einkommen (üblicherweise das Bruttogehalt) aufgeschlagen. Dementsprechend höher fallen in diesem Monat dann Steuer- und Sozialabgaben aus. - - FIP Freifahrtscheine und nationale Fahrvergünstigungen von Angehörigen werden dem Mitarbeitenden angerechnet. - - Für FIP 50 Tickets wird kein Geldwerter Vorteil berechnet. - en: | - FIP Coupons for employees in Germany are considered _non-monetary compensation_ subject to § 8 of the German Income Tax Act (EStG). FIP Coupons are therefore subject to income tax and social security contributions. - - The assessed value differs depending on the network length that can be covered with the respective coupon and is updated annually. A list of the current amounts is available in the DB Reisemarkt or DB employee portal. - - Each month, all taxable benefits are added together (including other non-monetary benefits such as national FIP discounts). If the sum exceeds a certain threshold (€50 in 2025), the entire amount (not just what exceeds the threshold, but everything) is added to taxable income (usually gross salary). Accordingly, taxes and social contributions are higher that month. - - FIP Coupons and national travel discounts of dependents are counted toward the employee. - - No non-monetary compensation is calculated for FIP 50 tickets. - fr: | - Les Coupons FIP pour les employés en Allemagne sont considérés comme une _réduction non monétaire_ assujettie à l’article 8 de la Loi de l’impôt sur le revenu allemand (EStG). Les Coupons FIP sont donc assujettis à l’impôt sur le revenu et aux cotisations de sécurité sociale. - - La valeur évaluée varie selon la longueur du réseau qui peut être couverte avec le coupon respectif et est mise à jour annuellement. Une liste des montants actuels est disponible sur le marché des voyages DB ou le portail des employés DB. - - Chaque mois, tous les avantages imposables sont additionnés (y compris les autres avantages non monétaires tels que les réductions FIP de voyage nationales). Si la somme dépasse un certain seuil (50 € en 2025), le montant entier (pas seulement ce qui dépasse le seuil, mais tout) est ajouté au revenu imposable (généralement le salaire brut). En conséquence, les impôts et les cotisations sociales sont plus élevés ce mois-là. - - Les Coupons FIP et les réductions FIP de voyage nationales des ayants droit sont comptabilisés auprès de l’employé. - - Aucune réduction non monétaire n’est calculée pour les billets FIP 50. national-discounts: de: | Bei Fahrten ins Ausland, können Mitarbeitende der Deutschen Bahn für die Fahrt bis zum Grenztarifpunkt nationale Vergünstigungen nutzen. Dafür kann ein Tagesticket M Fern oder eine NetzCard genutzt werden. Diese gelten im gesamten Netz und somit bis zum Grenztarifpunkt zu den Nachbarländern. @@ -110,15 +79,15 @@ general: de: | Die Beantragung von FIP Freifahrtscheinen für DB Mitarbeitende muss mind. vier Wochen vor Gültigkeitsbeginn über das DB Personalportal beantragt werden. - Freifahrtscheine können mit 1 bis 4 Feldern beantragt werden. Wenn bereits ein Freifahrtschein mit weniger als vier Feldern im gleichen Jahr bentragt wurde, verfallen die weiteren Felder und können nicht erneut beantragt werden. Jedes Feld wird individuell versteuert. Mehr Informationen dazu unter [Versteuerung](/fip-validity#taxation). + Freifahrtscheine können mit 1 bis 4 Feldern beantragt werden. Wenn bereits ein Freifahrtschein mit weniger als vier Feldern im gleichen Jahr bentragt wurde, verfallen die weiteren Felder und können nicht erneut beantragt werden. Jedes Feld wird individuell versteuert. Mehr Informationen dazu unter [Versteuerung](/taxation). en: | FIP Coupon requests for DB employees must be submitted via the DB employee portal at least four weeks before the start of validity. - Coupons can be requested with 1 to 4 fields. If a coupon with fewer than four fields has already been requested in the same year, the remaining fields expire and cannot be requested again. Each field is taxed individually. More information is available under [Taxation](/fip-validity#taxation). + Coupons can be requested with 1 to 4 fields. If a coupon with fewer than four fields has already been requested in the same year, the remaining fields expire and cannot be requested again. Each field is taxed individually. More information is available under [Taxation](/taxation). fr: | Les demandes de Coupons FIP pour les employés DB doivent être soumises via le portail des employés DB au moins quatre semaines avant le début de validité. - Les Coupons peuvent être demandés avec 1 à 4 champs. Si un coupon avec moins de quatre champs a déjà été demandé la même année, les champs restants expirent et ne peuvent pas être redemandés. Chaque champ est imposé individuellement. Plus d’informations sous [Imposition](/fip-validity#taxation). + Les Coupons peuvent être demandés avec 1 à 4 champs. Si un coupon avec moins de quatre champs a déjà été demandé la même année, les champs restants expirent et ne peuvent pas être redemandés. Chaque champ est imposé individuellement. Plus d’informations sous [Imposition](/taxation). sncf: fip-coupon: active: *coupon-4fields @@ -392,7 +361,7 @@ zrs: retired: *coupon-former-1-onetime retired-relatives: *coupon-not-available fip-reduced-ticket: *reduced-50-group -zrms: +zrsm: fip-coupon: active: *coupon-4fields active-relatives: *coupon-4fields diff --git a/content/taxation/index.de.md b/content/taxation/index.de.md new file mode 100644 index 000000000..27d9b57e9 --- /dev/null +++ b/content/taxation/index.de.md @@ -0,0 +1,8 @@ +--- +draft: false +title: "Versteuerung" +--- + +{{< taxation-info >}} + +{{< taxation-calculator >}} diff --git a/content/taxation/index.en.md b/content/taxation/index.en.md new file mode 100644 index 000000000..c9a5e295c --- /dev/null +++ b/content/taxation/index.en.md @@ -0,0 +1,8 @@ +--- +draft: false +title: "Taxation" +--- + +{{< taxation-info >}} + +{{< taxation-calculator >}} diff --git a/content/taxation/index.fr.md b/content/taxation/index.fr.md new file mode 100644 index 000000000..62a70c847 --- /dev/null +++ b/content/taxation/index.fr.md @@ -0,0 +1,8 @@ +--- +draft: false +title: "Imposition" +--- + +{{< taxation-info >}} + +{{< taxation-calculator >}} diff --git a/hugo.yaml b/hugo.yaml index fe6b3d5b4..58bb8c078 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -30,6 +30,8 @@ module: target: "static" - source: "node_modules/@fontsource" target: "static/css/@fontsource" + - source: "node_modules/@material-symbols/svg-700/rounded" + target: "static/icons/material-symbols-rounded" related: includeNewer: true diff --git a/i18n/de.yaml b/i18n/de.yaml index 187425da3..b816cdff1 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -177,6 +177,80 @@ support: um Inhalte beizutragen. Alternativ kannst du uns auch über das [Kontaktformular](/contact) schreiben. title: Unterstütze uns +taxation: + add-month: Monat hinzufügen + add-national: Nationale Vergünstigung hinzufügen + add-operator: Betreiber hinzufügen + calculator: Steuerrechner {{ .Year }} + calculator-description: >- + Hier kannst du den Sachbezugswert deiner Fahrvergünstigungen berechnen. + + 1. Wähle die Anzahl der Personen aus, für die Freifahrtscheine + + 2. Wähle die Betreiber und die Anzahl der Felder aus + + 3. Füge nationale Vergünstigungen und weitere steuerpflichtige + Sachbezugswerte hinzu + + Anschließend wird die Summe deiner Sachbezugswerte berechnet. + + Im Bereich [Planung & Optimierung](#planning-optimization) kannst du deine + Steuerlast optimieren, indem du sie auf mehrere Monate verteilst. + category-international: Internationale FIP Freifahrtscheine + category-national: Nationale Vergünstigungen + category-other: Sonstiges + children: Kinder + decrease: Verringern + disclaimer: >- + Wir übernehmen keine Haftung oder Garantie für die Richtigkeit der + berechneten Daten. Die Berechnung dient ausschließlich zur Orientierung. + first-class: 1. Klasse + general-heading: Allgemein + increase: Erhöhen + missing-info: Derzeit liegen uns keine Informationen zur Versteuerung vor. + no-first-class: Nur 2. Klasse + optimization-month: Monat + optimize: Optimieren + other-placeholder: Sonstige Sachbezüge (€) + person: Person + person-limit: Anzahl Personen + person-limit-not-supported-warning: >- + Für diesen Betreiber sind Freifahrtscheine für Angehörige nicht verfügbar. + Die Berechnung erfolgt daher immer mit nur einer Person, auch wenn mehr + Personen ausgewählt wurden. + person-one: Person + person-other: Personen + planning: Planung & Optimierung + planning-description: >- + Hier kannst du über „Optimieren“ deine Freifahrtscheine optimal auf Monate + verteilen. + + Wenn du einen Freifahrtschein in einem bestimmten Monat brauchst, kannst du + ihn per Drag-and-Drop verschieben. Er ist dann in diesem Monat fest + zugeordnet, bis du ihn über das Schloss wieder löst. Die + „Optimieren“-Funktion berücksichtigt deine Zuordnung und passt die + Verteilung der übrigen Freifahrtscheine entsprechend an. + planning-focus: Steuer optimieren + remove: Entfernen + reset: Zurücksetzen + second-class: 2. Klasse + sum: Summe + summary-all-assigned-ok: >- + Alle Freifahrtscheine sind auf Monate verteilt. Kein Monat überschreitet {{ + .Threshold }} €. + summary-all-assigned-over-month: >- + Alle Freifahrtscheine sind zugewiesen, aber mindestens ein Monat liegt über + dem {{ .Threshold }}-€-Grenzwert. In diesem Fall wird der gesamte + Monatsbetrag versteuert, nicht nur der Anteil über dem Grenzwert. + summary-over-limit-unassigned: >- + Die Summe liegt oberhalb des {{ .Threshold }} € Grenzwerts. Der gesamte + Monatsbetrag ist steuerpflichtig, nicht nur der Anteil über dem Grenzwert. + Verteile den Betrag auf mehrere Monate, um die Steuerlast zu optimieren. + summary-under-limit: >- + Die Summe liegt innerhalb des {{ .Threshold }} € Grenzwerts. Es entsteht + keine zusätzliche Versteuerung. + unassigned: Nicht zugewiesen + unlock: Zuordnung aufheben teamMembers: lennart: name: Lennart Rommeiß diff --git a/i18n/en.yaml b/i18n/en.yaml index 81ecd1e87..766931604 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -169,6 +169,75 @@ support: [GitHub Repository](https://github.com/fipguide/fipguide.github.io) to contribute content. Alternatively, you can also write to us via the [contact form](/contact). +taxation: + add-month: Add month + add-national: Add national discount + add-operator: Add operator + calculator: Tax Calculator {{ .Year }} + calculator-description: >- + The tax calculator helps you determine the taxable non-cash benefit value of + your travel benefits. + + 1. Select the number of persons for whom you want to calculate + international coupons. + 2. Select the coupons and the number of fields you need (the same class and + quantity are applied to each person, where possible). + 3. Add national discounts and any additional taxable non-cash benefits. + + The total taxable amount is then calculated. + + In [Planning & Optimization](#planning-optimization), you can reduce your + tax impact by distributing amounts across multiple months. + category-international: International FIP Coupons + category-national: National Discounts + category-other: Other + children: Children + decrease: Decrease + disclaimer: >- + We assume no liability or guarantee for the accuracy of the calculated data. + The calculation is for orientation purposes only. + first-class: 1st Class + general-heading: General + increase: Increase + missing-info: We currently do not have information on taxation. + no-first-class: 2nd Class only + optimization-month: Month + optimize: Optimize + other-placeholder: Other taxable benefits (€) + person: Person + person-limit: Number of persons + person-limit-not-supported-warning: >- + For this operator, coupons for relatives are not available. The calculation + therefore always uses one person, even if more persons were selected. + person-one: Person + person-other: Persons + planning: Planning & Optimization + planning-description: >- + In Planning & Optimization mode, “Optimize” distributes your coupons across + months in an optimal way. If you need a specific coupon in a specific month, + move it via drag and drop. It stays fixed in that month until you unlock it. + “Optimize” respects these fixed assignments and adjusts all remaining + coupons accordingly. Individual coupons that exceed the threshold on their + own are grouped as early as possible. + planning-focus: Optimize taxes + remove: Remove + reset: Reset + second-class: 2nd Class + sum: Total + summary-all-assigned-ok: All coupons are assigned to months, and no month exceeds €{{ .Threshold }}. + summary-all-assigned-over-month: >- + All coupons are assigned, but at least one month is above the €{{ .Threshold + }} threshold. In that case, the full monthly amount is taxable, not just the + part above the threshold. + summary-over-limit-unassigned: >- + The total is above the €{{ .Threshold }} threshold. The full monthly amount + is taxable, not just the part above the threshold. Distribute the amount + across multiple months to optimize your tax impact. + summary-under-limit: >- + The total is within the €{{ .Threshold }} threshold. No additional taxation + applies. + unassigned: Unassigned + unlock: Remove assignment teamMembers: lennart: name: Lennart Rommeiß diff --git a/i18n/fr.yaml b/i18n/fr.yaml index 58e9e6398..f4f5607cb 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -91,6 +91,7 @@ fipValidity: footer-love: aria-label: Fait avec amour en Europe text: Fait avec ♥️ en Europe +general: Général highlight: confusion: Risque de confusion important: Informations importantes @@ -175,6 +176,82 @@ support: Visitez notre [dépôt GitHub](https://github.com/fipguide/fipguide.github.io) pour contribuer au contenu. Vous pouvez également nous écrire via le [formulaire de contact](/contact). +taxation: + add-month: Ajouter un mois + add-national: Ajouter une réduction nationale + add-operator: Ajouter un opérateur + calculator: Calculateur d’imposition {{ .Year }} + calculator-description: >- + Le calculateur fiscal vous permet de déterminer la valeur imposable de vos + avantages de voyage. + + 1. Sélectionnez le nombre de personnes pour lesquelles vous souhaitez + calculer des coupons internationaux. + 2. Sélectionnez les coupons et le nombre de champs nécessaires (la même + classe et la même quantité sont prises en compte pour chaque personne, + dans la mesure du possible). + 3. Ajoutez les réductions nationales et, si nécessaire, d’autres avantages + imposables. + + Le total imposable est ensuite calculé. + + Dans la section [Planification et optimisation](#planning-optimization), + vous pouvez réduire la charge fiscale en répartissant les montants sur + plusieurs mois. + category-international: Coupons FIP internationaux + category-national: Réductions nationales + category-other: Autres + children: Enfants + decrease: Diminuer + disclaimer: >- + Nous n’assumons aucune responsabilité ni garantie quant à l’exactitude des + données calculées. Le calcul est fourni à titre indicatif uniquement. + first-class: 1re classe + general-heading: Général + increase: Augmenter + missing-info: Nous ne disposons actuellement d’aucune information sur l’imposition. + no-first-class: 2nd classe uniquement + optimization-month: Mois + optimize: Optimiser + other-placeholder: Autres avantages imposables (€) + person: Personne + person-limit: Nombre de personnes + person-limit-not-supported-warning: >- + Pour cet opérateur, les coupons pour les ayants droit ne sont pas + disponibles. Le calcul est donc toujours effectué avec une seule personne, + même si plusieurs personnes ont été sélectionnées. + person-one: Personne + person-other: Personnes + planning: Planification et optimisation + planning-description: >- + En mode Planification et optimisation, « Optimiser » répartit vos coupons de + manière optimale sur les mois. Si vous avez besoin d’un coupon dans un mois + précis, déplacez-le par glisser-déposer. Il reste alors affecté à ce mois + jusqu’à ce que vous le déverrouilliez. La fonction « Optimiser » tient + compte de ces affectations fixes et ajuste la répartition des coupons + restants en conséquence. Les coupons qui dépassent à eux seuls le seuil sont + regroupés le plus tôt possible. + planning-focus: Optimiser les impôts + remove: Supprimer + reset: Réinitialiser + second-class: 2nd classe + sum: Total + summary-all-assigned-ok: >- + Tous les coupons sont répartis par mois et aucun mois ne dépasse {{ + .Threshold }} €. + summary-all-assigned-over-month: >- + Tous les coupons sont attribués, mais au moins un mois dépasse le seuil de + {{ .Threshold }} €. Dans ce cas, le montant mensuel complet est imposable, + et pas seulement la part au-dessus du seuil. + summary-over-limit-unassigned: >- + Le total dépasse le seuil de {{ .Threshold }} €. Le montant mensuel complet + est imposable, et pas seulement la part au-dessus du seuil. Répartissez le + montant sur plusieurs mois pour optimiser la charge fiscale. + summary-under-limit: >- + Le total reste dans la limite du seuil de {{ .Threshold }} €. Aucune + imposition supplémentaire ne s’applique. + unassigned: Non attribué + unlock: Supprimer l’attribution teamMembers: lennart: name: Lennart Rommeiß diff --git a/layouts/_default/rss.xml b/layouts/_default/rss.xml index 0e17f9487..028b1bcc9 100644 --- a/layouts/_default/rss.xml +++ b/layouts/_default/rss.xml @@ -10,8 +10,7 @@ {{ .Title }} {{ .Permalink }} - - {{ $cleanedHTML := partial "strip-material-icons" .Summary | transform.XMLEscape | safeHTML}} + {{ $cleanedHTML := .Summary | plainify | transform.XMLEscape | safeHTML }} {{ $cleanedHTML }} {{ .PublishDate.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} diff --git a/layouts/news/single.html b/layouts/news/single.html index 14a61cb4e..46bbf9396 100644 --- a/layouts/news/single.html +++ b/layouts/news/single.html @@ -15,9 +15,9 @@ ) }} {{ if eq .Page.Type "news" }} - {{ .Date | time.Format ":date_long" }} + + {{ .Date | time.Format ":date_long" }} + {{ end }} {{ end }} diff --git a/layouts/partials/fip-validity/comparison.html b/layouts/partials/fip-validity/comparison.html index ec6673030..5296ee56d 100644 --- a/layouts/partials/fip-validity/comparison.html +++ b/layouts/partials/fip-validity/comparison.html @@ -21,7 +21,7 @@

{{ T "general" }}

{{ T "fipValidity.selectIssuerFirst" }}

- {{- range $entryKey := slice "card-validity" "taxation" "national-discounts" -}} + {{- range $entryKey := slice "card-validity" "national-discounts" -}} {{- range $issuers -}} {{- $issuerSlug := .File.ContentBaseName -}} {{- $validity := .Resources.Get "validity.yaml" | transform.Unmarshal -}} diff --git a/layouts/partials/icon.html b/layouts/partials/icon.html index b07f74a2a..077850108 100644 --- a/layouts/partials/icon.html +++ b/layouts/partials/icon.html @@ -1,8 +1,9 @@ {{- /* Needed, otherwise links break: https://github.com/fipguide/fipguide.github.io/issues/116 */ -}} diff --git a/layouts/partials/strip-material-icons.html b/layouts/partials/strip-material-icons.html deleted file mode 100644 index 445937299..000000000 --- a/layouts/partials/strip-material-icons.html +++ /dev/null @@ -1 +0,0 @@ -{{ return replaceRE `]*class="[^"]*\bmaterial-symbols-rounded\b[^"]*"[^>]*>.*?` "" . }} diff --git a/layouts/partials/teaser.html b/layouts/partials/teaser.html index c87dce0f2..b8772fac2 100644 --- a/layouts/partials/teaser.html +++ b/layouts/partials/teaser.html @@ -23,7 +23,7 @@

{{ $page.Title }}

- {{ partial "strip-material-icons" $page.Summary | plainify | truncate 500 }} + {{ $page.Summary | plainify | truncate 500 }}
{{- partial "icon" "arrow_forward" -}} diff --git a/layouts/shortcodes/taxation-calculator.html b/layouts/shortcodes/taxation-calculator.html new file mode 100644 index 000000000..f734c19bd --- /dev/null +++ b/layouts/shortcodes/taxation-calculator.html @@ -0,0 +1,326 @@ +{{- $lang := .Page.Language.Lang -}} +{{- $logoSlugMapping := dict "hz" "hzpp" -}} +{{- $issuerConfigs := slice -}} + +{{- range where .Site.RegularPages "Type" "operator" -}} + {{- $taxationRes := .Resources.Get "taxation.yaml" -}} + {{- if $taxationRes -}} + {{- $taxation := $taxationRes | transform.Unmarshal -}} + {{- $validityRes := .Resources.Get "validity.yaml" -}} + {{- $validity := dict -}} + {{- if $validityRes -}} + {{- $validity = $validityRes | transform.Unmarshal -}} + {{- end -}} + + {{- $operatorData := slice -}} + {{- range $slug, $data := $taxation.operators -}} + {{- $operatorPage := $.Page.Site.GetPage (printf "/operator/%s" $slug) -}} + {{- $detailedTitle := "" -}} + {{- if $data.title -}} + {{- $detailedTitle = $data.title -}} + {{- else -}} + {{- errorf "Missing required operator title in content/operator/%s/taxation.yaml for '%s'" .File.ContentBaseName $slug -}} + {{- end -}} + {{- $title := replaceRE `\s*\([^()]*\)\s*$` "" $detailedTitle -}} + {{- if $operatorPage -}} + {{- $title = $operatorPage.Title -}} + {{- end -}} + {{- $logoSlug := $slug -}} + {{- if index $logoSlugMapping $slug -}} + {{- $logoSlug = index $logoSlugMapping $slug -}} + {{- end -}} + {{- $entry := dict + "slug" $slug + "title" $title + "detailedTitle" $detailedTitle + "maxFields" $data.max_fields + "secondClass" $data.second_class + -}} + {{- $singleClassOnly := false -}} + {{- $singlePersonOnly := false -}} + {{- $validityOperator := index $validity $slug -}} + {{- if $validityOperator -}} + {{- $coupon := index $validityOperator "fip-coupon" -}} + {{- if $coupon -}} + {{- $activeStatus := "invalid" -}} + {{- $activeRelStatus := "invalid" -}} + {{- $retiredStatus := "invalid" -}} + {{- $retiredRelStatus := "invalid" -}} + {{- with index $coupon "active" -}} + {{- with index . "status" -}} + {{- $activeStatus = . -}} + {{- end -}} + {{- end -}} + {{- with index $coupon "active-relatives" -}} + {{- with index . "status" -}} + {{- $activeRelStatus = . -}} + {{- end -}} + {{- end -}} + {{- with index $coupon "retired" -}} + {{- with index . "status" -}} + {{- $retiredStatus = . -}} + {{- end -}} + {{- end -}} + {{- with index $coupon "retired-relatives" -}} + {{- with index . "status" -}} + {{- $retiredRelStatus = . -}} + {{- end -}} + {{- end -}} + {{- if and + (eq $activeStatus "valid") + (eq $activeRelStatus "invalid") + (eq $retiredStatus "invalid") + (eq $retiredRelStatus "invalid") + -}} + {{- $singleClassOnly = true -}} + {{- end -}} + {{- if and (eq $activeRelStatus "invalid") (eq $retiredRelStatus "invalid") -}} + {{- $singlePersonOnly = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if $singleClassOnly -}} + {{- $entry = merge $entry (dict "singleClassOnly" true) -}} + {{- end -}} + {{- if $singlePersonOnly -}} + {{- $entry = merge $entry (dict "singlePersonOnly" true) -}} + {{- end -}} + {{- $logo := resources.Get (printf "/images/logos/%s.svg" $logoSlug) -}} + {{- if $logo -}} + {{- $entry = merge $entry (dict "logoUrl" $logo.RelPermalink) -}} + {{- end -}} + {{- if $data.no_first_class -}} + {{- $entry = merge $entry (dict "noFirstClass" true) -}} + {{- else -}} + {{- $entry = merge $entry (dict "firstClass" $data.first_class) -}} + {{- end -}} + {{- $operatorData = $operatorData | append $entry -}} + {{- end -}} + + {{- $nationalData := slice -}} + {{- range $key, $data := $taxation.national -}} + {{- $title := "" -}} + {{- if $data.title -}} + {{- $title = $data.title -}} + {{- else -}} + {{- errorf "Missing required national title in content/operator/%s/taxation.yaml for '%s'" .File.ContentBaseName $key -}} + {{- end -}} + {{- $entry := dict + "key" $key + "title" $title + "firstClass" $data.first_class + "secondClass" $data.second_class + -}} + {{- $nationalData = $nationalData | append $entry -}} + {{- if $data.children_half -}} + {{- $childEntry := dict + "key" (printf "%s-children" $key) + "title" (printf "%s (%s)" $title (T "taxation.children")) + "firstClass" (div $data.first_class 2) + "secondClass" (div $data.second_class 2) + -}} + {{- $nationalData = $nationalData | append $childEntry -}} + {{- end -}} + {{- end -}} + + {{- $issuerConfigs = $issuerConfigs | append (dict + "slug" .File.ContentBaseName + "year" $taxation.year + "threshold" $taxation.threshold + "operators" $operatorData + "national" $nationalData + ) + -}} + {{- end -}} +{{- end -}} + +{{- if gt (len $issuerConfigs) 0 -}} + {{- $headingYear := (index $issuerConfigs 0).year -}} +

+ {{ T "taxation.calculator" (dict "Year" $headingYear) }} +

+ + {{- range $issuerConfigs -}} + {{- $threshold := .threshold -}} + {{- $calcData := dict + "year" .year + "threshold" $threshold + "operators" .operators + "national" .national + -}} + + {{- end -}} +{{- end -}} diff --git a/layouts/shortcodes/taxation-info.html b/layouts/shortcodes/taxation-info.html new file mode 100644 index 000000000..9a1f67e4c --- /dev/null +++ b/layouts/shortcodes/taxation-info.html @@ -0,0 +1,43 @@ +{{- $lang := .Page.Language.Lang -}} +{{- $issuers := slice -}} +{{- range where .Site.RegularPages "Type" "operator" -}} + {{- if .Resources.Get "taxation.yaml" -}} + {{- $issuers = $issuers | append . -}} + {{- end -}} +{{- end -}} + + +
+ + {{ T "fipValidity.selectIssuer" }} + + {{ partial "fip-validity/issuer-dropdown" (dict + "id" "taxation-issuer" + "issuers" $issuers + ) + }} +
+ +

{{ T "taxation.general-heading" }}

+ +

{{ T "fipValidity.selectIssuerFirst" }}

+ +{{- range $issuers -}} + {{- $issuerSlug := .File.ContentBaseName -}} + {{- $taxationRes := .Resources.Get "taxation.yaml" -}} + {{- $text := T "taxation.missing-info" -}} + {{- if $taxationRes -}} + {{- $taxation := $taxationRes | transform.Unmarshal -}} + {{- $info := index $taxation "info" -}} + {{- if $info -}} + {{- with index $info $lang -}} + {{- $text = . -}} + {{- end -}} + {{- end -}} + {{- end -}} + +{{- end -}} + +{{ partial "intersector" }} diff --git a/package-lock.json b/package-lock.json index b9ee50061..b290326b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@fontsource/material-symbols-rounded": "^5.2.33", "@fontsource/roboto": "^5.2.9", "@fontsource/sansita": "^5.2.8", + "@material-symbols/svg-700": "^0.43.0", "@panzoom/panzoom": "^4.6.1", "pagefind": "^1.4.0" }, @@ -20,15 +20,6 @@ "prettier-plugin-go-template": "^0.0.15" } }, - "node_modules/@fontsource/material-symbols-rounded": { - "version": "5.2.33", - "resolved": "https://registry.npmjs.org/@fontsource/material-symbols-rounded/-/material-symbols-rounded-5.2.33.tgz", - "integrity": "sha512-aBrTR+t41AeVoce2+EHeAFdgGWCYvVYHaT8nDuW+AFRr78Nl/AvbHKYgtHdGzim7KNXEo2pLfT2pj6U5/ACsbw==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, "node_modules/@fontsource/roboto": { "version": "5.2.9", "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.9.tgz", @@ -47,6 +38,12 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@material-symbols/svg-700": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@material-symbols/svg-700/-/svg-700-0.43.0.tgz", + "integrity": "sha512-apCxQvQ1eZrxvgvr+CbwnPvN6aS0hn9EgGm+vI3bzEMdE1gqZ4Tk8liDuAhEXdFyYVX6CDUzz/zt+6lCufmSVg==", + "license": "Apache-2.0" + }, "node_modules/@pagefind/darwin-arm64": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.4.0.tgz", diff --git a/package.json b/package.json index de615b9a0..b2be8a192 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ "homepage": "https://github.com/fipguide/fipguide.github.io#readme", "description": "", "dependencies": { - "@fontsource/material-symbols-rounded": "^5.2.33", "@fontsource/roboto": "^5.2.9", "@fontsource/sansita": "^5.2.8", + "@material-symbols/svg-700": "^0.43.0", "@panzoom/panzoom": "^4.6.1", "pagefind": "^1.4.0" },