From 18dd981930170be1b3db04127eedd9c9ed940b9c Mon Sep 17 00:00:00 2001 From: MoritzWeber0 Date: Sun, 29 Mar 2026 14:40:28 +0200 Subject: [PATCH 1/7] refactor: Replace icon font with SVG icons --- assets/js/darkmode.js | 2 +- assets/sass/content.scss | 2 +- assets/sass/contentNavigation.scss | 2 +- assets/sass/dropdown.scss | 7 ++--- assets/sass/expander.scss | 4 +-- assets/sass/fonts.scss | 35 ++++------------------ assets/sass/navigation.scss | 4 +-- assets/sass/styles.scss | 6 ++-- assets/sass/teaser.scss | 4 +-- hugo.yaml | 2 ++ layouts/_default/rss.xml | 3 +- layouts/partials/icon.html | 3 +- layouts/partials/strip-material-icons.html | 1 - layouts/partials/teaser.html | 2 +- package-lock.json | 18 +++++------ package.json | 2 +- 16 files changed, 35 insertions(+), 62 deletions(-) delete mode 100644 layouts/partials/strip-material-icons.html 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/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 76fcdfc34..1cc01b383 100644 --- a/assets/sass/contentNavigation.scss +++ b/assets/sass/contentNavigation.scss @@ -49,7 +49,7 @@ @include focus-indicator(0.1rem); - > .material-symbols-rounded { + > .a-icon { align-self: flex-start; margin-top: 0.1rem; } 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/navigation.scss b/assets/sass/navigation.scss index 142d13f4a..5b312fdc4 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; diff --git a/assets/sass/styles.scss b/assets/sass/styles.scss index 8dad07a23..cb6f4af6c 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; } } @@ -439,7 +439,7 @@ details > summary { .o-link__mail, .o-link__tel { - & > .material-symbols-rounded { + & > .a-icon { margin-right: 0.4rem; } } 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/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/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/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/package-lock.json b/package-lock.json index b9ee50061..c9eda717c 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", @@ -154,6 +151,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, 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" }, From 0729ca8921e3943f6ae40e91aabeb3e99f1916a5 Mon Sep 17 00:00:00 2001 From: MoritzWeber0 Date: Sun, 29 Mar 2026 15:32:38 +0200 Subject: [PATCH 2/7] fix: Sizing of icons --- assets/sass/contentNavigation.scss | 21 ++++++++++++++------- assets/sass/navigation.scss | 4 ++++ assets/sass/styles.scss | 4 ---- layouts/news/single.html | 6 +++--- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/assets/sass/contentNavigation.scss b/assets/sass/contentNavigation.scss index 1cc01b383..a94bbbd56 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 { @@ -51,7 +55,7 @@ > .a-icon { align-self: flex-start; - margin-top: 0.1rem; + margin-top: 0.3rem; } } @@ -108,19 +112,22 @@ padding-left: 0; } - &-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/navigation.scss b/assets/sass/navigation.scss index 5b312fdc4..7e595eb21 100644 --- a/assets/sass/navigation.scss +++ b/assets/sass/navigation.scss @@ -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/styles.scss b/assets/sass/styles.scss index cb6f4af6c..ad202255a 100644 --- a/assets/sass/styles.scss +++ b/assets/sass/styles.scss @@ -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)); 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 }} From 0475d95c865cd5adc6f26aa9946aec51c9ba7a30 Mon Sep 17 00:00:00 2001 From: MoritzWeber0 Date: Sun, 29 Mar 2026 15:39:12 +0200 Subject: [PATCH 3/7] fix: Make search icons work again --- assets/sass/search.scss | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) 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; } From 879c125589b42cdb88eb735f0afe143cc0405920 Mon Sep 17 00:00:00 2001 From: MoritzWeber0 Date: Sun, 29 Mar 2026 16:00:02 +0200 Subject: [PATCH 4/7] feat: Add more space for startpage teaser --- assets/sass/startpage.scss | 4 ++++ 1 file changed, 4 insertions(+) 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; From 6e4f24efc7352183fdd99093260d73f3f7edcc48 Mon Sep 17 00:00:00 2001 From: lennartrommeiss <61516567+lenderom@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:37:13 +0200 Subject: [PATCH 5/7] feat: initial --- assets/js/main.js | 1 + assets/js/taxationCalculator.js | 1287 +++++++++++++++++ assets/js/taxationInputs.js | 82 ++ assets/js/taxationPlanning.js | 631 ++++++++ assets/js/taxationUi.js | 180 +++ assets/sass/header.scss | 5 + assets/sass/main.scss | 1 + assets/sass/taxation.scss | 1196 +++++++++++++++ content/operator/db/taxation.yaml | 209 +++ content/operator/db/validity.yaml | 68 +- content/taxation/index.de.md | 10 + content/taxation/index.en.md | 10 + content/taxation/index.fr.md | 10 + i18n/de.yaml | 82 ++ i18n/en.yaml | 73 + i18n/fr.yaml | 81 ++ layouts/partials/fip-validity/comparison.html | 36 + layouts/shortcodes/taxation-calculator.html | 362 +++++ layouts/shortcodes/taxation-info.html | 13 + package-lock.json | 1 - requirements.md | 21 + 21 files changed, 4357 insertions(+), 2 deletions(-) create mode 100644 assets/js/taxationCalculator.js create mode 100644 assets/js/taxationInputs.js create mode 100644 assets/js/taxationPlanning.js create mode 100644 assets/js/taxationUi.js create mode 100644 assets/sass/taxation.scss create mode 100644 content/operator/db/taxation.yaml create mode 100644 content/taxation/index.de.md create mode 100644 content/taxation/index.en.md create mode 100644 content/taxation/index.fr.md create mode 100644 layouts/shortcodes/taxation-calculator.html create mode 100644 layouts/shortcodes/taxation-info.html create mode 100644 requirements.md diff --git a/assets/js/main.js b/assets/js/main.js index b9c687e29..54cf4deb6 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -11,3 +11,4 @@ import "./interactiveMap.js"; import "./expander.js"; import "./dialog.js"; import "./fipValidityComparison.js"; +import "./taxationCalculator.js"; diff --git a/assets/js/taxationCalculator.js b/assets/js/taxationCalculator.js new file mode 100644 index 000000000..36d788d3b --- /dev/null +++ b/assets/js/taxationCalculator.js @@ -0,0 +1,1287 @@ +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"; + +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 }; + + function rerenderAll() { + 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); + } + + ctx.rerenderAll = rerenderAll; + ctx.runOptimization = runOptimization; + + renderPersonLimitControlInput(ctx, saveState); + rerenderAll(); + + if (resetBtns.length > 0 && operatorsWrapper && nationalWrapper) { + const resetCalculator = function () { + state.operators = {}; + state.national = {}; + state.other = 0; + 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, + perField: container.dataset.i18nPerField, + field: container.dataset.i18nField, + fields: container.dataset.i18nFields, + increase: container.dataset.i18nIncrease, + decrease: container.dataset.i18nDecrease, + sum: container.dataset.i18nSum, + categoryInternational: container.dataset.i18nCategoryInternational, + categoryNational: container.dataset.i18nCategoryNational, + categoryOther: container.dataset.i18nCategoryOther, + otherPlaceholder: container.dataset.i18nOtherPlaceholder, + classMixedWarning: container.dataset.i18nClassMixedWarning, + secondPersonHint: container.dataset.i18nSecondPersonHint, + highlightImportant: container.dataset.i18nHighlightImportant, + highlightTip: container.dataset.i18nHighlightTip, + thresholdAbove: container.dataset.i18nThresholdAbove, + thresholdBelow: container.dataset.i18nThresholdBelow, + thresholdExcess: container.dataset.i18nThresholdExcess, + disclaimer: container.dataset.i18nDisclaimer, + personsRequired: container.dataset.i18nPersonsRequired, + personLimit: container.dataset.i18nPersonLimit, + noRelativesWarning: container.dataset.i18nNoRelativesWarning, + reset: container.dataset.i18nReset, + planning: container.dataset.i18nPlanning, + optimize: container.dataset.i18nOptimize, + manualMode: container.dataset.i18nManualMode, + optimizationTitle: container.dataset.i18nOptimizationTitle, + optimizationMonth: container.dataset.i18nOptimizationMonth, + optimizationItems: container.dataset.i18nOptimizationItems, + optimizationImpossible: container.dataset.i18nOptimizationImpossible, + unassigned: container.dataset.i18nUnassigned, + person: container.dataset.i18nPerson, + persons: container.dataset.i18nPersons, + 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 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 clampCounter(value) { + return Math.max(Number(value) || 0, 0); +} + +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 formatCurrency(value) { + return ( + value + .toFixed(2) + .replace(".", ",") + .replace(/\B(?=(\d{3})+(?!\d))/g, ".") + "\u00A0\u20AC" + ); +} + +function normalizeText(text) { + return text + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); +} + +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 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; +} + +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 createOperatorLogo(logoUrl) { + const img = document.createElement("img"); + img.src = logoUrl; + img.alt = ""; + img.className = "a-operator-logo"; + img.setAttribute("data-decorative", "true"); + return img; +} + +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__class-hint"; + 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"; + if (effectivePersonLimit === 1) { + multiplier.textContent = "x 1 " + i18n.person; + } else { + multiplier.textContent = "x " + effectivePersonLimit + " " + i18n.persons; + } + 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"); + + 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"; + + 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 maxHeight = parseFloat(window.getComputedStyle(list).maxHeight) || 0; + const listHeight = Math.min( + list.scrollHeight, + maxHeight || list.scrollHeight, + ); + const requiredBottom = wrapperRect.bottom + listHeight; + + const calculator = wrapper.closest("[data-taxation-calculator]"); + const sheet = calculator + ? calculator.querySelector("[data-taxation-sheet]") + : null; + const hasVisibleSheet = + sheet && window.getComputedStyle(sheet).display !== "none"; + const bottomLimit = hasVisibleSheet + ? sheet.getBoundingClientRect().top - 28 + : window.innerHeight - 28; + + const delta = requiredBottom - bottomLimit; + if (hasVisibleSheet) { + window.scrollTo(window.scrollX, window.scrollY + delta); + } else if (delta > 0) { + window.scrollTo(window.scrollX, window.scrollY + delta); + } + } + + function openList() { + const count = buildItems(); + if (count > 0) { + list.setAttribute("aria-hidden", "false"); + input.setAttribute("aria-expanded", "true"); + wrapper.classList.add("o-taxation-calculator__search-select--open"); + requestAnimationFrame(ensureDropdownSpace); + } + } + + function closeList() { + 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); + }, + }); + + 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); + }, + }); + + 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 (singleClassOnly) { + warningEl.appendChild( + createHighlight( + "important", + i18n.highlightImportant, + "campaign", + i18n.noRelativesWarning, + ), + ); + } + + if (singlePersonOnly && personLimit > 1) { + warningEl.appendChild( + createHighlight( + "important", + i18n.highlightImportant, + "campaign", + i18n.personLimitNotSupportedWarning, + ), + ); + } +} + +function calculateTotalPersons(config, state) { + return state.personLimit; +} + +function updateSummary(container, config, i18n, state) { + const total = calculateTotal(config, state); + const totalPersons = calculateTotalPersons(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, + totalPersons, + summaryHint, + ); + + updateSummaryBlock( + container, + { + totalEl: container.querySelector("[data-taxation-bottom-total]"), + personsEl: container.querySelector("[data-taxation-bottom-persons]"), + thresholdEl: container.querySelector("[data-taxation-bottom-threshold]"), + }, + total, + totalPersons, + summaryHint, + ); + + updateThresholdBlock( + 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 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); + } + } + + if (!els.thresholdEl) return; + els.thresholdEl.innerHTML = ""; + + if (!summaryHint) { + els.thresholdEl.className = "o-taxation-calculator__threshold-info"; + return; + } + + els.thresholdEl.className = + "o-taxation-calculator__threshold-info a-tag " + + (summaryHint.kind === "warning" ? "a-tag--warning" : "a-tag--success"); + + els.thresholdEl.appendChild( + createIcon(summaryHint.kind === "warning" ? "warning" : "check_circle"), + ); + + var textEl = document.createElement("span"); + textEl.className = "o-taxation-calculator__threshold-text"; + textEl.textContent = summaryHint.text; + els.thresholdEl.appendChild(textEl); +} + +function updateThresholdBlock(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 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..693914aca --- /dev/null +++ b/assets/js/taxationInputs.js @@ -0,0 +1,82 @@ +const MAX_PERSONS = 10; + +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 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")); + + 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")); + + 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; + ctx.rerenderAll(); + saveState(state); + updateButtons(); + }); + + increaseBtn.addEventListener("click", function () { + if (state.personLimit >= MAX_PERSONS) return; + state.personLimit += 1; + ctx.rerenderAll(); + 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/taxationPlanning.js b/assets/js/taxationPlanning.js new file mode 100644 index 000000000..2c28011ba --- /dev/null +++ b/assets/js/taxationPlanning.js @@ -0,0 +1,631 @@ +function formatCurrency(value) { + return ( + value + .toFixed(2) + .replace(".", ",") + .replace(/\B(?=(\d{3})+(?!\d))/g, ".") + "\u00A0\u20AC" + ); +} + +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; +} + +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; + + 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; + break; + } + } + + 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 title = document.createElement("h3"); + title.className = "o-taxation-calculator__planning-title"; + title.textContent = i18n.planning; + + 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.appendChild(createIcon("wand_stars")); + button.appendChild(document.createTextNode(i18n.optimize)); + button.addEventListener("click", function () { + if (typeof onOptimize === "function") { + onOptimize(); + } + }); + return button; + } + + header.appendChild(createOptimizeButton()); + target.appendChild(header); + + 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 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.draggable = true; + dragHandle.appendChild(createIcon("drag_indicator")); + 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.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/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/taxation.scss b/assets/sass/taxation.scss new file mode 100644 index 000000000..04016fa35 --- /dev/null +++ b/assets/sass/taxation.scss @@ -0,0 +1,1196 @@ +.o-taxation-calculator { + margin-top: 1rem; + + &__subtitle { + color: var(--color-body); + opacity: 0.7; + 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); + } + + &__summary-actions { + display: flex; + justify-content: flex-end; + gap: 0.8rem; + margin-top: 1.2rem; + } + + &__optimization { + margin-top: 1.2rem; + } + + &__planning { + margin-top: 2rem; + } + + &__planning-title { + margin: 0; + font-size: 1.15em; + } + + &__planning-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + margin-bottom: 0.8rem; + } + + &__planning-controls { + display: flex; + justify-content: flex-end; + margin-top: 1rem; + margin-bottom: 1rem; + + &--bottom { + margin-top: 1rem; + margin-bottom: 0; + } + } + + &__planning-header > &__optimization-run { + margin: 0; + } + + &__optimization-title { + font-size: 1em; + margin-bottom: 0.6rem; + } + + &__optimization-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + margin-bottom: 0.8rem; + } + + &__optimization-toggle { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9em; + + input { + position: absolute; + opacity: 0; + pointer-events: none; + } + + input:checked + .o-taxation-calculator__optimization-switch { + background: var(--link-default); + + > span { + transform: translateX(1.8rem); + } + } + } + + &__optimization-switch { + display: inline-flex; + align-items: center; + width: 4rem; + height: 2.2rem; + background: #6c6c6c; + border-radius: 999px; + padding: 0.2rem; + transition: background 0.2s; + + > span { + width: 1.8rem; + height: 1.8rem; + border-radius: 50%; + background: white; + transition: transform 0.2s; + } + } + + &__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; + } + + &__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-items-label { + font-size: 0.9em; + opacity: 0.8; + margin-bottom: 0.2rem; + } + + &__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; + } + + &__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-line { + display: block; + padding: 0.2rem 0; + } + + &__optimization-drag { + display: inline-flex; + align-items: center; + color: var(--color-body); + opacity: 0.5; + + .a-icon { + font-size: 1.4rem; + } + } + + &__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; + } + + &__bottom-summary { + margin-top: 2rem; + display: none; + + @media (max-width: $breakpoint-lg) { + display: none; + } + } + + &__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.8; + } + + &__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; + } + + &__class-hint { + font-size: 0.85em; + font-style: italic; + width: 10rem; + text-align: center; + } + + &__row-total { + font-weight: bold; + font-size: 1.2em; + font-variant-numeric: tabular-nums; + white-space: nowrap; + transition: color 0.2s; + + &--active { + color: var(--link-default); + } + } + + &__field-controls { + display: flex; + align-items: center; + 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: 3.2rem; + 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: 3.6rem; + height: 3.2rem; + background: transparent; + font-weight: bold; + font-size: 1.2rem; + font-variant-numeric: tabular-nums; + } + + &__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; + } + + &__persons-wrapper { + display: flex; + align-items: center; + justify-content: flex-end; + } + + &__persons { + display: flex; + gap: 0.1rem; + } + + &__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 { + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 0.8rem 1.2rem; + border: var(--border-visible); + border-radius: var(--border-radius-s); + 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; + } + } + + &__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; + } + + &__sidebar &__sum-persons { + order: 2; + } + } + + &__sum-persons { + display: flex; + gap: 0.1rem; + } + + &__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; + } + } + + &__threshold-excess { + margin-top: 0.4rem; + font-weight: bold; + font-size: 1.05em; + } + + &__search-select { + position: relative; + margin-top: 0.4rem; + } + + &__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; + } + } + + &__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-start; + } + + &__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; + } + + &__class-hint { + width: auto; + text-align: left; + flex: 1 1 100%; + } + + &__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-persons { + display: flex; + gap: 0.1rem; + + .o-taxation-calculator__person { + font-size: 1.6rem; + } + } + + &__mobile-content { + background: var(--bg-default); + border-top: 1px solid #3d444d; + overflow-y: auto; + overscroll-behavior-y: contain; + padding: 1.5rem; + + > .o-taxation-calculator__threshold-info { + margin-top: 0; + } + + * { + transition: opacity 0.3s ease-in-out; + } + } +} diff --git a/content/operator/db/taxation.yaml b/content/operator/db/taxation.yaml new file mode 100644 index 000000000..4cd0f72a9 --- /dev/null +++ b/content/operator/db/taxation.yaml @@ -0,0 +1,209 @@ +year: 2026 +threshold: 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 3cbfa47c3..faaf3c562 100644 --- a/content/operator/db/validity.yaml +++ b/content/operator/db/validity.yaml @@ -53,6 +53,72 @@ _anchors: fr: | **1 coupon** Unique, dans les 45 mois suivant le départ. +card-validity: + de: | + Der FIP Ausweis ist für eine feste Periode von drei Jahren gültig. Die aktuelle Periode ist 2025-2026-2027. + en: | + The FIP Card is valid for a fixed period of three years. The current period is 2025-2026-2027. + 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 2026) 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 2026), 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 2026), 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. + + Teilweise sind andere Tickets, die auf dem gesamten Abschnitt gelten, günstigere Alternativen zu FIP. Dazu gehören (Super) Sparpreise Europa DB-PEP (Nur in Verbindung mit einem Reisenden mit FIP, siehe Nutzungsbestimmungen) oder auch normale (Super) Sparpreise. Weitere Informationen dazu sind im DB Reisemarkt und DB Personalportal zu finden. + en: | + When traveling abroad, DB employees can use national discounts for travel up to the border tariff point. For this, a Tagesticket M Fern or a NetzCard can be used. These are valid on the entire network and thus up to the border tariff point to neighboring countries. + + In some cases, other tickets valid for the entire segment offer cheaper alternatives to FIP. These include (Super) Sparpreise Europa DB-PEP (only in combination with a traveler with FIP, see terms of use) or regular (Super) Sparpreise (savings fares). More information can be found in the DB Reisemarkt and DB employee portal. + fr: | + Lors de déplacements à l’étranger, les employés de la Deutsche Bahn peuvent bénéficier de réductions nationales pour le trajet jusqu’au point de tarif frontalier. Pour cela, un Tagesticket M Fern ou une NetzCard peut être utilisé. Ceux-ci sont valables sur l’ensemble du réseau et donc jusqu’au point de tarif frontalier vers les pays voisins. + + Dans certains cas, d’autres billets valables pour le segment entier offrent des alternatives moins chères au FIP. Ceux-ci comprennent (Super) Sparpreise Europa DB-PEP (uniquement en combinaison avec un voyageur avec FIP, voir conditions d’utilisation) ou les (Super) Sparpreise réguliers (tarifs de réduction). Plus d’informations peuvent être trouvées sur le DB Reisemarkt et le portail des employés DB. +general: + fip-coupon: + 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](/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](/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](/taxation). sncf: fip-coupon: active: *coupon-4fields @@ -326,7 +392,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..b5bf52406 --- /dev/null +++ b/content/taxation/index.de.md @@ -0,0 +1,10 @@ +--- +draft: false +title: "Versteuerung" +--- + +[Direkt zum Steuerrechner](#calculator) + +{{< taxation-info >}} + +{{< taxation-calculator >}} diff --git a/content/taxation/index.en.md b/content/taxation/index.en.md new file mode 100644 index 000000000..f55688365 --- /dev/null +++ b/content/taxation/index.en.md @@ -0,0 +1,10 @@ +--- +draft: false +title: "Taxation" +--- + +[Jump directly to the tax calculator](#calculator) + +{{< taxation-info >}} + +{{< taxation-calculator >}} diff --git a/content/taxation/index.fr.md b/content/taxation/index.fr.md new file mode 100644 index 000000000..9b9c1700d --- /dev/null +++ b/content/taxation/index.fr.md @@ -0,0 +1,10 @@ +--- +draft: false +title: "Imposition" +--- + +[Aller directement au calculateur fiscal](#calculator) + +{{< taxation-info >}} + +{{< taxation-calculator >}} diff --git a/i18n/de.yaml b/i18n/de.yaml index f8a28c3da..65638fcfa 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -50,6 +50,8 @@ fipValidity: active-relatives: Angehörige aktiver Mitarbeitender additional: Sonstige buttonLabel: Mehr Informationen zur FIP Gültigkeit anzeigen + card-validityHeading: Gültigkeit FIP Ausweis + card-validityMissingInfo: Keine Informationen zur Gültigkeit des FIP Ausweises verfügbar. fip-coupon: FIP Freifahrtschein fip-coupon-description: >- Wähle zunächst den Aussteller deines FIP Ausweises. Wenn du berechtigt bist, @@ -66,12 +68,15 @@ fipValidity: Aussteller deines FIP Ausweises aus. Einen Überblick aller Rabatte erhälst du [hier](/fip-validity). issuer: Aussteller des FIP Ausweises + national-discountsHeading: Nationale Vergünstigungen + national-discountsMissingInfo: Keine Informationen zu nationalen Vergünstigungen verfügbar. retired: Rentner:innen retired-relatives: Angehörige von Rentner:innen rules: Regeln selectIssuer: 'FIP Ausweis Aussteller wählen:' selectIssuerFirst: Bitte zuerst einen FIP Ausweis Aussteller auswählen. selectIssuerPlaceholder: FIP Ausweis Aussteller wählen + taxationHeading: Versteuerung unknown: Unbekannt validity: Gültigkeit widows: Witwen, Witwer und Waisen @@ -167,6 +172,83 @@ support: um Inhalte beizutragen. Alternativ kannst du uns auch über das [Kontaktformular](/contact) schreiben. title: Unterstütze uns +taxation: + add-national: Nationale Vergünstigung hinzufügen + add-operator: Betreiber hinzufügen + calculator: Steuerrechner + category-international: Internationale FIP Freifahrtscheine + category-national: Nationale Vergünstigungen + category-other: Sonstiges + children: Kinder + children-half: Kinder bis einschließlich 15 Jahren die Hälfte + class-mixed-warning: >- + Bei Mischung von 1. und 2. Klasse für den gleichen Betreiber müssen die + Freifahrtscheine auf zwei verschiedene Personen (z. B. Angehörige) + aufgeteilt werden. + decrease: Verringern + disclaimer: >- + Wir übernehmen keine Haftung oder Garantie für die Richtigkeit der + berechneten Daten. Die Berechnung dient ausschließlich zur Orientierung. + field: Feld + fields: Felder + first-class: 1. Klasse + increase: Erhöhen + manual-mode: Manueller Modus + no-first-class: Nur 2. Klasse + no-relatives-warning: >- + Für diesen Betreiber sind Freifahrtscheine nur für aktive Mitarbeitende + verfügbar. Es kann nur eine Klassenart ausgewählt werden. + optimization-impossible: >- + Mindestens eine Bestellung überschreitet bereits alleine die Freigrenze. + Eine vollständige Aufteilung ohne Überschreitung ist daher nicht möglich. + Alle Bestellungen, die einzeln einen Monat überschreiten würden, werden + gemeinsam in einem Monat zusammengefasst. + optimization-items: Bestellungen + optimization-month: Monat + optimization-title: Optimale Aufteilung auf Monate + optimize: Optimieren + other-placeholder: Sonstige Sachbezüge (€) + per-field: pro Feld + person: Person + person-limit: Anzahl Personen + person-limit-not-supported-warning: >- + Für diesen Betreiber sind Coupons für Angehörige nicht verfügbar. Die + Berechnung erfolgt daher immer mit x 1, auch wenn oben mehr Personen + ausgewählt sind. + persons: Personen + persons-required: Benötigte Personen + planning: Planung + remove: Entfernen + reset: Zurücksetzen + second-class: 2. Klasse + second-person-hint: Zweite Person benötigt + 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 + {{ .Threshold }} €. In diesem Fall wird der gesamte Monatsbetrag versteuert, + nicht nur der Anteil über dem Grenzwert. + summary-over-limit-unassigned: >- + Die Summe liegt über {{ .Threshold }} € und ist noch nicht vollständig auf + Monate verteilt. Sobald {{ .Threshold }} € überschritten sind, ist der + gesamte Monatsbetrag steuerpflichtig, nicht nur der Anteil über dem + Grenzwert. + summary-under-limit: >- + Die Summe liegt bei höchstens {{ .Threshold }} €. Es entsteht keine + zusätzliche Versteuerung. + threshold-above: >- + Die Summe liegt über der Freigrenze von {{ .Threshold }} €. Der gesamte + Betrag (nicht nur was über der Grenze liegt) wird auf das zu versteuernde + Einkommen aufgeschlagen. + threshold-below: >- + Die Summe liegt unter der Freigrenze von {{ .Threshold }} €. Es fallen keine + zusätzlichen Steuer- und Sozialabgaben an. + threshold-excess: 'Davon über der Freigrenze:' + title: Sachbezugswerte Internationaler Freifahrtscheine {{ .Year }} + unassigned: Nicht zugewiesen + unlock: Zuordnung aufheben teamMembers: lennart: name: Lennart Rommeiß diff --git a/i18n/en.yaml b/i18n/en.yaml index 5c1756dd7..399bf8218 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -49,6 +49,8 @@ fipValidity: active-relatives: Relatives of Active Employees additional: Other buttonLabel: Show more information on FIP validity + card-validityHeading: FIP Card Validity + card-validityMissingInfo: No information on FIP Card validity available. fip-coupon: FIP Coupon fip-coupon-description: >- First select the issuer of your FIP Card. If you are eligible, you can @@ -63,12 +65,15 @@ fipValidity: discount applies to you, first select the issuer of your FIP Card. You can find an overview of all discounts [here](/fip-validity). issuer: FIP Card Issuer + national-discountsHeading: National Discounts + national-discountsMissingInfo: No information on national discounts available. retired: Retirees retired-relatives: Relatives of Retirees rules: Rules selectIssuer: 'Select your FIP Card issuer:' selectIssuerFirst: Please select a FIP Card issuer first. selectIssuerPlaceholder: Select FIP Card issuer + taxationHeading: Taxation unknown: Unknown validity: validity widows: Widows, Widowers and Orphans @@ -159,6 +164,74 @@ 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-national: Add national discount + add-operator: Add operator + calculator: Tax Calculator + category-international: International FIP Coupons + category-national: National Discounts + category-other: Other + children: Children + children-half: Children up to and including 15 years of age pay half + class-mixed-warning: >- + When mixing 1st and 2nd class for the same operator, the coupons must be + split between two different persons (e.g. relatives). + decrease: Decrease + disclaimer: >- + We assume no liability or guarantee for the accuracy of the calculated data. + The calculation is for orientation purposes only. + field: field + fields: fields + first-class: 1st Class + increase: Increase + manual-mode: Manual mode + no-first-class: 2nd Class only + no-relatives-warning: >- + For this operator, coupons are only available for active employees. Only one + class type can be selected. + optimization-impossible: >- + At least one booking alone already exceeds the threshold. A complete split + without exceeding the threshold is therefore not possible. All bookings that + would exceed a single month on their own are grouped into one month. + optimization-items: Bookings + optimization-month: Month + optimization-title: Optimal split by month + optimize: Optimize + other-placeholder: Other taxable benefits (€) + per-field: per field + 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 x 1, even if more persons are selected above. + persons: Persons + persons-required: Persons required + planning: Planning + remove: Remove + reset: Reset + second-class: 2nd Class + second-person-hint: Second person required + 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 exceeds €{{ .Threshold }}. + In that case, the full monthly amount is taxable, not just the part above + the threshold. + summary-over-limit-unassigned: >- + The total exceeds €{{ .Threshold }} and is not yet fully split across + months. Once €{{ .Threshold }} is exceeded, the full monthly amount is + taxable, not just the part above the threshold. + summary-under-limit: The total is at most €{{ .Threshold }}. No additional taxation applies. + threshold-above: >- + The total exceeds the tax-free threshold of €{{ .Threshold }}. The entire + amount (not just what exceeds the threshold) is added to taxable income. + threshold-below: >- + The total is below the tax-free threshold of €{{ .Threshold }}. No + additional tax or social contributions apply. + threshold-excess: 'Amount exceeding threshold:' + title: Taxable Values of International FIP Coupons {{ .Year }} + unassigned: Unassigned + unlock: Remove assignment teamMembers: lennart: name: Lennart Rommeiß diff --git a/i18n/fr.yaml b/i18n/fr.yaml index da5866236..c0d403e01 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -50,6 +50,8 @@ fipValidity: active-relatives: Ayants droit des employés actifs additional: Autre buttonLabel: Afficher plus d'informations sur la validité du FIP + card-validityHeading: Validité de la Carte FIP + card-validityMissingInfo: Aucune information sur la validité de la Carte FIP disponible. fip-coupon: Coupon FIP fip-coupon-description: >- Sélectionnez d'abord l'émetteur de votre Carte FIP. Si vous êtes éligible, @@ -65,18 +67,22 @@ fipValidity: s'applique à votre cas, sélectionnez d'abord l'émetteur de votre Carte FIP. Vous trouverez un aperçu de toutes les réductions [ici](/fip-validity). issuer: Émetteur de la carte FIP + national-discountsHeading: Réductions nationales + national-discountsMissingInfo: Aucune information sur les réductions nationales disponible. retired: Retraités retired-relatives: Ayants droit des retraités rules: Règles selectIssuer: 'Sélectionner l''émetteur de votre Carte FIP :' selectIssuerFirst: Veuillez d'abord sélectionner un émetteur de Carte FIP. selectIssuerPlaceholder: Sélectionner l'émetteur de la Carte FIP + taxationHeading: Imposition unknown: Inconnu validity: validité widows: Veuves, veufs et orphelins 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 @@ -161,6 +167,81 @@ 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-national: Ajouter une réduction nationale + add-operator: Ajouter un opérateur + calculator: Calculateur d’imposition + category-international: Coupons FIP internationaux + category-national: Réductions nationales + category-other: Autres + children: Enfants + children-half: Enfants jusqu’à 15 ans inclus paient la moitié + class-mixed-warning: >- + En cas de mélange de 1re et 2nd classe pour le même opérateur, les coupons + doivent être répartis entre deux personnes différentes (p. ex. ayants + droit). + 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. + field: champ + fields: champs + first-class: 1re classe + increase: Augmenter + manual-mode: Mode manuel + no-first-class: 2nd classe uniquement + no-relatives-warning: >- + Pour cet opérateur, les coupons ne sont disponibles que pour les employés + actifs. Une seule classe peut être sélectionnée. + optimization-impossible: >- + Au moins une réservation dépasse déjà à elle seule le seuil. Une répartition + complète sans dépassement n’est donc pas possible. Toutes les réservations + qui dépasseraient un mois à elles seules sont regroupées dans un même mois. + optimization-items: Réservations + optimization-month: Mois + optimization-title: Répartition optimale par mois + optimize: Optimiser + other-placeholder: Autres avantages imposables (€) + per-field: par champ + 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 utilise donc toujours x 1, même si plusieurs + personnes sont sélectionnées ci-dessus. + persons: Personnes + persons-required: Personnes requises + planning: Planification + remove: Supprimer + reset: Réinitialiser + second-class: 2nd classe + second-person-hint: Deuxième personne requise + 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 {{ .Threshold + }} €. Dans ce cas, le montant mensuel entier est imposable, et pas seulement + la part au-dessus du seuil. + summary-over-limit-unassigned: >- + Le total dépasse {{ .Threshold }} € et n’est pas encore entièrement réparti + par mois. Dès que {{ .Threshold }} € sont dépassés, le montant mensuel + entier est imposable, et pas seulement la part au-dessus du seuil. + summary-under-limit: >- + Le total est au maximum de {{ .Threshold }} €. Aucune imposition + supplémentaire ne s’applique. + threshold-above: >- + Le total dépasse le seuil d’exonération de {{ .Threshold }} €. Le montant + entier (pas seulement ce qui dépasse le seuil) est ajouté au revenu + imposable. + threshold-below: >- + Le total est inférieur au seuil d’exonération de {{ .Threshold }} €. Aucune + cotisation fiscale ou sociale supplémentaire ne s’applique. + threshold-excess: 'Montant dépassant le seuil :' + title: Valeurs imposables des Coupons FIP internationaux {{ .Year }} + unassigned: Non attribué + unlock: Supprimer l’attribution teamMembers: lennart: name: Lennart Rommeiß diff --git a/layouts/partials/fip-validity/comparison.html b/layouts/partials/fip-validity/comparison.html index 241c806f9..baa72a9e9 100644 --- a/layouts/partials/fip-validity/comparison.html +++ b/layouts/partials/fip-validity/comparison.html @@ -23,6 +23,42 @@

{{ T (printf "fipValidity.%s" $types) }}

{{ T "fipValidity.selectIssuerFirst" }}

+ {{- range $entryKey := slice "card-validity" "national-discounts" -}} + {{- range $issuers -}} + {{- $issuerSlug := .File.ContentBaseName -}} + {{- $validity := .Resources.Get "validity.yaml" | transform.Unmarshal -}} + {{- $entry := index $validity $entryKey -}} + {{- $text := T (printf "fipValidity.%sMissingInfo" $entryKey) -}} + {{- if $entry -}} + {{- $text = index $entry $lang -}} + {{- end -}} + + + + {{- end -}} + {{- end -}} + + {{- range $issuers -}} + {{- $issuerSlug := .File.ContentBaseName -}} + {{- $validity := .Resources.Get "validity.yaml" | transform.Unmarshal -}} + {{- $entry := index $validity "taxation" -}} + {{- if $entry -}} + + {{- end -}} + {{- end -}} + {{- range $issuers -}} {{- $issuerSlug := .File.ContentBaseName -}} {{- $validity := .Resources.Get "validity.yaml" | transform.Unmarshal -}} diff --git a/layouts/shortcodes/taxation-calculator.html b/layouts/shortcodes/taxation-calculator.html new file mode 100644 index 000000000..374ccbab9 --- /dev/null +++ b/layouts/shortcodes/taxation-calculator.html @@ -0,0 +1,362 @@ +{{- $dbPage := .Page.Site.GetPage "/operator/db" -}} +{{- if $dbPage -}} + {{- with $dbPage.Resources.Get "taxation.yaml" -}} + {{- $taxation := . | transform.Unmarshal -}} + {{- $lang := $.Page.Language.Lang -}} + {{- $year := $taxation.year -}} + {{- $threshold := $taxation.threshold -}} + {{- $operators := $taxation.operators -}} + {{- $national := $taxation.national -}} + {{- $validityRes := $dbPage.Resources.Get "validity.yaml" -}} + {{- $validity := dict -}} + {{- if $validityRes -}} + {{- $validity = $validityRes | transform.Unmarshal -}} + {{- end -}} + + {{- $logoSlugMapping := dict "hz" "hzpp" -}} + + {{- $operatorData := slice -}} + {{- range $slug, $data := $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/db/taxation.yaml for '%s'" $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 -}} + + {{- $activeRelExact := "" -}} + {{- with index $coupon "active-relatives" -}} + {{- with index . "status" -}} + {{- $activeRelExact = . -}} + {{- end -}} + {{- end -}} + {{- $retiredRelExact := "" -}} + {{- with index $coupon "retired-relatives" -}} + {{- with index . "status" -}} + {{- $retiredRelExact = . -}} + {{- end -}} + {{- end -}} + {{- if and (eq $activeRelExact "invalid") (eq $retiredRelExact "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 := $national -}} + {{- $title := "" -}} + {{- if $data.title -}} + {{- $title = $data.title -}} + {{- else -}} + {{- errorf "Missing required national title in content/operator/db/taxation.yaml for '%s'" $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 -}} + + {{- $calcData := dict + "year" $year + "threshold" $threshold + "operators" $operatorData + "national" $nationalData + -}} + + +
+

{{ T "taxation.calculator" }}

+

+ {{ T "taxation.title" (dict "Year" $year) }} +

+ +
+
+
+
+

+ {{- partial "icon" "public" -}} + {{ T "taxation.category-international" }} +

+
+
+
+ +
+

+ {{- partial "icon" "train" -}} + {{ T "taxation.category-national" }} +

+
+
+ +
+

+ {{- partial "icon" "other_admission" -}} + {{ T "taxation.category-other" }} +

+
+
+
+ +
+ +
+
+
+ + {{- partial "icon" "equal" -}} + {{ T "taxation.sum" }} + + + 0,00 € + +
+
+
+ +
+
+
+
+ +
+
+
+ + {{- partial "icon" "equal" -}} + {{ T "taxation.sum" }} + + + 0,00 € + +
+
+
+ +
+
+
+
+ +
+ + +
+ + {{ partial "highlight" (dict "Type" "inofficial" "Content" (T "taxation.disclaimer")) }} +
+ {{- end -}} +{{- end -}} diff --git a/layouts/shortcodes/taxation-info.html b/layouts/shortcodes/taxation-info.html new file mode 100644 index 000000000..e59f02e1e --- /dev/null +++ b/layouts/shortcodes/taxation-info.html @@ -0,0 +1,13 @@ +{{- $dbPage := .Page.Site.GetPage "/operator/db" -}} +{{- $lang := .Page.Language.Lang -}} +{{- if $dbPage -}} + {{- with $dbPage.Resources.Get "validity.yaml" -}} + {{- $validity := . | transform.Unmarshal -}} + {{- with index $validity "taxation" -}} + {{- $text := index . $lang -}} + {{- if $text -}} + {{ $text | $.Page.RenderString }} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} diff --git a/package-lock.json b/package-lock.json index c9eda717c..b290326b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,7 +151,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, diff --git a/requirements.md b/requirements.md new file mode 100644 index 000000000..e8754de5d --- /dev/null +++ b/requirements.md @@ -0,0 +1,21 @@ +In der taxationInput.js wird die Datenerfassung inklusive der Beschränkungen durch den Nutzer gemacht. Die kann bei Bedarf angepasst werden, aber es funktioniert alles. Wenn erforderlich kann aber auf eine effizientere Datenstruktur umgestellt werden. Es soll zum Beispiel nicht alles im local Storage des Browsers gespeichert werden, sondern nur, wenn auch wirklich ein Freifahrtschein ausgewählt wurde. + +Kontext: ein Freifahrtschein besteht immer aus bis zu 4 Felder (teilweise auch nur 2, das ist Betreiberabhängig). Im Bereich der Datenerfassung kann der Nutzer angeben wie viele Felder er pro Freifahrtschein der Bahngesellschaft haben möchte und für wie viele Personen er diese Freifahrtscheine benötigt. Also zum Beispiel 3 Personen die jeweils einen Freifahrtschein für die CFL haben mit 4 Feldern. Für den Geldwerten Vorteil wird dann das Produkt der Multiplikation gebildet. + +Die taxationPlanning funktioniert nicht und ist sehr fehlerbehaftet. Daher muss diese komplett neu geschrieben werden. Die TaxationPlanning.js ist die Grundlage für einen Planungmodus mit dem Nutzer empfehlungen bekommen, wie sie ihre Freifahrtscheine am besten auf die Monate aufteilen, um die 50€ Grenze möglichst nah zu erreichen. Dabei kann der Nutzer auch Randbedingungen setzen, wenn er einen bestimmten Freifahrtschein in einem bestimmten Monat benötigt. Hier die Anforderunge dafür: + +- Mache dir zuerst Gedanken über eine effiziente Datenstruktur und speichere dort nur die Daten die du für die Berechnung wirklich benötigst, da du mit diesen viel arbeiten musst. +- Auf die Werte der taxationInput.js zugreifen per Variable +- Der Planungmodus soll nicht in der Summenbox angezeigt werden, sondern in einem eigenem Bereich unter dem Input Bereich, damit es übersichtlicher ist. +- Initial sollen alle Freifahrtscheine in einem Feld "Nicht zugewiesen" angezeigt werden. Immer wenn neue Freifahrtscheine hinzugefügt werden, sollen diese automatisch diesem Feld zugeordnet werden. +- Über den Button "Optimieren" soll die Aufteilung der Freifahrtscheine auf die Monate berechnet und angezeigt werden. Dabei soll die Aufteilung so sein, dass die 50€ Grenze möglichst nah erreicht wird, ohne sie zu überschreiten. Diese Optimierungs soll nur beim Drücken des Buttons erfolgen +- Felder eines Freifahrtscheins können nicht voneinander getrennt werden. Das heißt ein Freifahrtschein mit 4 Feldern muss auch als Ganzes einem Monat zugeordnet werden, es können nicht einzelne Felder dieses Freifahrtscheins auf verschiedene Monate verteilt werden. Die gleiche Person kann verschiedene Freifahrtscheine aber auf mehrere Monate verteilt haben, solange die 50€ Grenze pro Monat nicht überschritten wird. Wichtig ist nur, dass Felder eines Freifahrtscheins nie auf verschiedene Monate verteilt werden. +- Wenn durch einen einzelnen Freifahrtschein bereits die 50€ Grenze erreicht wird, sollen diese Freifahrtscheine auf die das zutrifft alle in Monat 1 zugeordnet werden. Damit es nur einen Monat gibt, bei dem die Grenze in jedem Fall überschritten wird. Zusätzlich soll eine Warmeldung angezeigt werden. +- Der Nutzer soll die Möglichkeit haben Freifahrtscheine per Drag & Drop manuell auf die Monate zu verteilen, damit er die Aufteilung anpassen kann, bevor er die Optimierung durchführt. Es soll aber auch hier nicht direkt eine automatische Optimierung stattfinden, damit der Nutzer die Möglichkeit hat, die Aufteilung manuell anzupassen, bevor er den Optimierungsbutton drückt. Die Freifahrtscheine sollen dazu als kleine Karten angezeigt werden, die der Nutzer per Drag & Drop auf die Monate ziehen kann. Dabei soll die Anzahl der Felder eines Freifahrtscheins immer als Ganzes verschoben werden, da diese nicht auf verschiedene Monate verteilt werden können. Die Zuordnung muss dann auch genau in dem Monat bleiben, wenn ein Nutzer also auf Monat 3 einen Freifahrtschein zieht, muss dieser auch genau in diesem Monat bleiben. Die Reihenfolge der Monate ist wichtig. Per drag an drop kann der Nutzer einen Freifahrtschein auch nach unten zu einem gestrichelten Kästchen ziehen, bei dem ein neuer Monat am Ende der Liste erstellt wird. +- Es müssen immer alle Monate angezeigt werden, auch wenn in diesen keine Freifahrtscheine liegen. Nur leere Monate am Ende dürfen abgeschnitten werden. +- Bei Nationalen Vergünstigungen gibt es nicht mehrere Felder, dort ist z.B. 1 TagesTicket genau ein einzelnen Ticket was frei verteilt werden kann. Wenn man also 2 angibt werden diese in der Übersicht einzeln geführt, damit man diese verteilen kann. +- Die einzelnen Monate zeigen live die Summe aus einen Freifahrtscheinen, damit der Nutzer sofort sieht, wie viel er in diesem Monat bereits an Geldwerten Vorteilen hat. So kann er auch manuell die Aufteilung anpassen, bevor er die Optimierung durchführt. +- Sobald ein Nutzer die Anzahl der Felder in einem Freifahrtschein über den Input Bereich ändert, soll diese sofort im Planungsmodus richtig angezeigt werden. Das heißt, wenn ein Freifahrtschein von 4 auf 2 Felder geändert wird, soll dieser sofort mit 2 Feldern im Planungsmodus angezeigt werden. Es soll aber auch hier keine automatische Optimierung stattfinden, damit der Nutzer die Möglichkeit hat, die Aufteilung manuell anzupassen, bevor er die Optimierung durchführt. +- Sonstige Sachbezüge ist einfach eine einzelen Karte die als ganzes in einem Monat zugeordnet werden kann, da es hier keine Felder gibt. +- Die Karten sollen folgenden Text haben, z.B. "[Person 1: ]4x CFL (1. Klasse)" und dann rechtsbündig der Betrag für diesen Freifahrtschein. Das Person wird nur benötigt, wenn es mehrere Personen gibt. +- Sobald ein Freifahrtschein vom Nutzer über den Input Bereich wieder entfernt wird (durch Minus auf Null oder X), soll dieser sofort aus dem Planungsmodus entfernt werden. Eine erneute Optimierung findet aber auch dann nur über den Button "Optimieren" statt, damit der Nutzer die Möglichkeit hat, die Aufteilung manuell anzupassen, bevor er die Optimierung durchführt. From ca2f2ca6132f04f6540b204cfbbeb4944d472452 Mon Sep 17 00:00:00 2001 From: lennartrommeiss <61516567+lenderom@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:48:30 +0200 Subject: [PATCH 6/7] fix: some improvements --- assets/js/taxationCalculator.js | 50 ++++++------- assets/js/taxationIssuerSelection.js | 7 ++ assets/js/taxationPlanning.js | 43 +++++++++++ assets/sass/taxation.scss | 33 ++++++--- content/operator/db/taxation.yaml | 2 +- i18n/de.yaml | 82 +++++++++------------ i18n/en.yaml | 73 ++++++++---------- i18n/fr.yaml | 82 +++++++++------------ layouts/shortcodes/taxation-calculator.html | 10 +-- 9 files changed, 201 insertions(+), 181 deletions(-) diff --git a/assets/js/taxationCalculator.js b/assets/js/taxationCalculator.js index fb7cd55ca..67f6aef92 100644 --- a/assets/js/taxationCalculator.js +++ b/assets/js/taxationCalculator.js @@ -204,7 +204,6 @@ function getI18n(container) { categoryOther: container.dataset.i18nCategoryOther, otherPlaceholder: container.dataset.i18nOtherPlaceholder, highlightImportant: container.dataset.i18nHighlightImportant, - noRelativesWarning: container.dataset.i18nNoRelativesWarning, planning: container.dataset.i18nPlanning, planningDescription: container.dataset.i18nPlanningDescription, planningFocus: container.dataset.i18nPlanningFocus, @@ -214,7 +213,8 @@ function getI18n(container) { personLimit: container.dataset.i18nPersonLimit, unassigned: container.dataset.i18nUnassigned, person: container.dataset.i18nPerson, - persons: container.dataset.i18nPersons, + personOne: container.dataset.i18nPersonOne, + personOther: container.dataset.i18nPersonOther, unlock: container.dataset.i18nUnlock, personLimitNotSupportedWarning: container.dataset.i18nPersonLimitNotSupportedWarning, @@ -227,6 +227,15 @@ function getI18n(container) { }; } +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); @@ -674,7 +683,8 @@ function createClassLine(options) { line.appendChild(priceSpacer); const hint = document.createElement("span"); - hint.className = "o-taxation-calculator__class-hint"; + hint.className = + "o-taxation-calculator__field-value o-taxation-calculator__field-value--text"; hint.textContent = disabledHint; line.appendChild(hint); @@ -752,11 +762,11 @@ function createClassLine(options) { if (showMultiplier) { const multiplier = document.createElement("span"); multiplier.className = "o-taxation-calculator__multiplier"; - if (effectivePersonLimit === 1) { - multiplier.textContent = "x 1 " + i18n.person; - } else { - multiplier.textContent = "x " + effectivePersonLimit + " " + i18n.persons; - } + multiplier.textContent = + "x " + + effectivePersonLimit + + " " + + getPersonLabelByCount(i18n, effectivePersonLimit); line.appendChild(multiplier); } @@ -1133,7 +1143,7 @@ function renderOther(ctx) { function updateRowWarning( row, - singleClassOnly, + _singleClassOnly, singlePersonOnly, personLimit, i18n, @@ -1144,17 +1154,6 @@ function updateRowWarning( warningEl.innerHTML = ""; - if (singleClassOnly) { - warningEl.appendChild( - createHighlight( - "important", - i18n.highlightImportant, - "campaign", - i18n.noRelativesWarning, - ), - ); - } - if (singlePersonOnly && personLimit > 1) { warningEl.appendChild( createHighlight( @@ -1229,12 +1228,11 @@ function updateRowMultipliers(row, effectivePersonLimit, i18n) { ".o-taxation-calculator__multiplier", ); for (const multiplierEl of multiplierEls) { - if (effectivePersonLimit === 1) { - multiplierEl.textContent = "x 1 " + i18n.person; - } else { - multiplierEl.textContent = - "x " + effectivePersonLimit + " " + i18n.persons; - } + multiplierEl.textContent = + "x " + + effectivePersonLimit + + " " + + getPersonLabelByCount(i18n, effectivePersonLimit); } } diff --git a/assets/js/taxationIssuerSelection.js b/assets/js/taxationIssuerSelection.js index 2c1ca525a..3b78dab10 100644 --- a/assets/js/taxationIssuerSelection.js +++ b/assets/js/taxationIssuerSelection.js @@ -18,8 +18,15 @@ document.addEventListener("DOMContentLoaded", function () { 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", ""); } diff --git a/assets/js/taxationPlanning.js b/assets/js/taxationPlanning.js index 60c633fbe..075a6339e 100644 --- a/assets/js/taxationPlanning.js +++ b/assets/js/taxationPlanning.js @@ -273,16 +273,59 @@ export function optimizePlan(config, state, i18n) { 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); diff --git a/assets/sass/taxation.scss b/assets/sass/taxation.scss index 382444c0b..5d042b80e 100644 --- a/assets/sass/taxation.scss +++ b/assets/sass/taxation.scss @@ -1,5 +1,8 @@ .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); @@ -590,7 +593,7 @@ flex-shrink: 0; white-space: nowrap; color: var(--color-body); - opacity: 0.8; + opacity: 0.7; } &__class-value { @@ -606,7 +609,7 @@ &__class-hint { font-size: 0.85em; font-style: italic; - width: 10rem; + width: var(--taxation-controls-width); text-align: center; } @@ -615,6 +618,8 @@ font-size: 1.2em; font-variant-numeric: tabular-nums; white-space: nowrap; + margin-left: auto; + text-align: right; transition: color 0.2s; &--active { @@ -625,6 +630,7 @@ &__field-controls { display: flex; align-items: center; + width: var(--taxation-controls-width); gap: 0; } @@ -640,7 +646,7 @@ display: flex; align-items: center; justify-content: center; - width: 3.2rem; + width: var(--taxation-btn-width); height: 3.2rem; border: 0.2rem solid var(--link-default); background: transparent; @@ -673,12 +679,23 @@ display: flex; align-items: center; justify-content: center; - width: 3.6rem; + 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 { @@ -967,7 +984,7 @@ &__row-col--right { margin-left: 0; align-self: stretch; - justify-content: flex-start; + justify-content: flex-end; } &__summary-actions { @@ -993,12 +1010,6 @@ text-align: left; } - &__class-hint { - width: auto; - text-align: left; - flex: 1 1 100%; - } - &__field-controls { margin-left: auto; } diff --git a/content/operator/db/taxation.yaml b/content/operator/db/taxation.yaml index 9069c7a34..b107c98f7 100644 --- a/content/operator/db/taxation.yaml +++ b/content/operator/db/taxation.yaml @@ -4,7 +4,7 @@ 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. Eine Liste der genauen aktuellen Beträge ist im DB Reisemarkt oder DB Personalportal verfügbar. + 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. diff --git a/i18n/de.yaml b/i18n/de.yaml index 3d03cf4ed..b816cdff1 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -183,88 +183,72 @@ taxation: add-operator: Betreiber hinzufügen calculator: Steuerrechner {{ .Year }} calculator-description: >- - 1) Erfassung: Trage alle FIP Freifahrtscheine, nationale Vergünstigungen und - ggf. weitere Sachbezüge ein. 2) Planung: Klicke auf „Optimieren“, um eine - sinnvolle Monatsverteilung zu erhalten, und passe sie bei Bedarf - anschließend per Drag-and-Drop manuell an. + 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 - children-half: Kinder bis einschließlich 15 Jahren die Hälfte - class-mixed-warning: >- - Bei Mischung von 1. und 2. Klasse für den gleichen Betreiber müssen die - Freifahrtscheine auf zwei verschiedene Personen (z. B. Angehörige) - aufgeteilt werden. decrease: Verringern disclaimer: >- Wir übernehmen keine Haftung oder Garantie für die Richtigkeit der berechneten Daten. Die Berechnung dient ausschließlich zur Orientierung. - field: Feld - fields: Felder first-class: 1. Klasse general-heading: Allgemein increase: Erhöhen - manual-mode: Manueller Modus missing-info: Derzeit liegen uns keine Informationen zur Versteuerung vor. no-first-class: Nur 2. Klasse - no-relatives-warning: >- - Für diesen Betreiber sind Freifahrtscheine nur für aktive Mitarbeitende - verfügbar. Es kann nur eine Klassenart ausgewählt werden. - optimization-impossible: >- - Mindestens eine Bestellung überschreitet bereits alleine die Freigrenze. - Eine vollständige Aufteilung ohne Überschreitung ist daher nicht möglich. - Alle Bestellungen, die einzeln einen Monat überschreiten würden, werden - gemeinsam in einem Monat zusammengefasst. - optimization-items: Bestellungen optimization-month: Monat - optimization-title: Optimale Aufteilung auf Monate optimize: Optimieren other-placeholder: Sonstige Sachbezüge (€) - per-field: pro Feld person: Person person-limit: Anzahl Personen person-limit-not-supported-warning: >- - Für diesen Betreiber sind Coupons für Angehörige nicht verfügbar. Die - Berechnung erfolgt daher immer mit x 1, auch wenn oben mehr Personen - ausgewählt sind. - persons: Personen - persons-required: Benötigte Personen + 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: >- - Klicke zuerst auf „Optimieren“, um eine sinnvolle Verteilung auf Monate zu - erhalten. Anschließend kannst du einzelne Freifahrtscheine per Drag-and-Drop - verschieben, Monate ergänzen oder feste Zuordnungen wieder lösen. + 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 - second-person-hint: Zweite Person benötigt 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 - {{ .Threshold }} €. In diesem Fall wird der gesamte Monatsbetrag versteuert, - nicht nur der Anteil über dem Grenzwert. + 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 über {{ .Threshold }} € und ist noch nicht vollständig auf - Monate verteilt. Sobald {{ .Threshold }} € überschritten sind, ist der - gesamte Monatsbetrag steuerpflichtig, nicht nur der Anteil über dem - Grenzwert. + 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 bei höchstens {{ .Threshold }} €. Es entsteht keine - zusätzliche Versteuerung. - threshold-above: >- - Die Summe liegt über der Freigrenze von {{ .Threshold }} €. Der gesamte - Betrag (nicht nur was über der Grenze liegt) wird auf das zu versteuernde - Einkommen aufgeschlagen. - threshold-below: >- - Die Summe liegt unter der Freigrenze von {{ .Threshold }} €. Es fallen keine - zusätzlichen Steuer- und Sozialabgaben an. - threshold-excess: 'Davon über der Freigrenze:' - title: Sachbezugswerte Internationaler Freifahrtscheine {{ .Year }} + Die Summe liegt innerhalb des {{ .Threshold }} € Grenzwerts. Es entsteht + keine zusätzliche Versteuerung. unassigned: Nicht zugewiesen unlock: Zuordnung aufheben teamMembers: diff --git a/i18n/en.yaml b/i18n/en.yaml index f285fe1e6..766931604 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -175,78 +175,67 @@ taxation: add-operator: Add operator calculator: Tax Calculator {{ .Year }} calculator-description: >- - 1) Entry: Add all FIP Coupons, national discounts, and any other taxable - benefits if needed. 2) Planning: Click “Optimize” to get a sensible monthly - split, then fine-tune it manually with drag and drop if required. + 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 - children-half: Children up to and including 15 years of age pay half - class-mixed-warning: >- - When mixing 1st and 2nd class for the same operator, the coupons must be - split between two different persons (e.g. relatives). decrease: Decrease disclaimer: >- We assume no liability or guarantee for the accuracy of the calculated data. The calculation is for orientation purposes only. - field: field - fields: fields first-class: 1st Class general-heading: General increase: Increase - manual-mode: Manual mode missing-info: We currently do not have information on taxation. no-first-class: 2nd Class only - no-relatives-warning: >- - For this operator, coupons are only available for active employees. Only one - class type can be selected. - optimization-impossible: >- - At least one booking alone already exceeds the threshold. A complete split - without exceeding the threshold is therefore not possible. All bookings that - would exceed a single month on their own are grouped into one month. - optimization-items: Bookings optimization-month: Month - optimization-title: Optimal split by month optimize: Optimize other-placeholder: Other taxable benefits (€) - per-field: per field 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 x 1, even if more persons are selected above. - persons: Persons - persons-required: Persons required + therefore always uses one person, even if more persons were selected. + person-one: Person + person-other: Persons planning: Planning & Optimization planning-description: >- - Start with “Optimize” to generate a sensible monthly distribution. Then you - can move individual coupons with drag and drop, add months, or release fixed - assignments. + 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 - second-person-hint: Second person required 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 exceeds €{{ .Threshold }}. - In that case, the full monthly amount is taxable, not just the part above - the threshold. + 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 exceeds €{{ .Threshold }} and is not yet fully split across - months. Once €{{ .Threshold }} is exceeded, the full monthly amount is - taxable, not just the part above the threshold. - summary-under-limit: The total is at most €{{ .Threshold }}. No additional taxation applies. - threshold-above: >- - The total exceeds the tax-free threshold of €{{ .Threshold }}. The entire - amount (not just what exceeds the threshold) is added to taxable income. - threshold-below: >- - The total is below the tax-free threshold of €{{ .Threshold }}. No - additional tax or social contributions apply. - threshold-excess: 'Amount exceeding threshold:' - title: Taxable Values of International FIP Coupons {{ .Year }} + 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: diff --git a/i18n/fr.yaml b/i18n/fr.yaml index c9c893a8e..f4f5607cb 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -182,86 +182,74 @@ taxation: add-operator: Ajouter un opérateur calculator: Calculateur d’imposition {{ .Year }} calculator-description: >- - 1) Saisie : renseigner tous les Coupons FIP, les réductions nationales et, - si nécessaire, d’autres avantages imposables. 2) Planification : cliquer sur - « Optimiser » pour obtenir une répartition mensuelle cohérente, puis - l’ajuster manuellement par glisser-déposer si besoin. + 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 - children-half: Enfants jusqu’à 15 ans inclus paient la moitié - class-mixed-warning: >- - En cas de mélange de 1re et 2nd classe pour le même opérateur, les coupons - doivent être répartis entre deux personnes différentes (p. ex. ayants - droit). 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. - field: champ - fields: champs first-class: 1re classe general-heading: Général increase: Augmenter - manual-mode: Mode manuel missing-info: Nous ne disposons actuellement d’aucune information sur l’imposition. no-first-class: 2nd classe uniquement - no-relatives-warning: >- - Pour cet opérateur, les coupons ne sont disponibles que pour les employés - actifs. Une seule classe peut être sélectionnée. - optimization-impossible: >- - Au moins une réservation dépasse déjà à elle seule le seuil. Une répartition - complète sans dépassement n’est donc pas possible. Toutes les réservations - qui dépasseraient un mois à elles seules sont regroupées dans un même mois. - optimization-items: Réservations optimization-month: Mois - optimization-title: Répartition optimale par mois optimize: Optimiser other-placeholder: Autres avantages imposables (€) - per-field: par champ 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 utilise donc toujours x 1, même si plusieurs - personnes sont sélectionnées ci-dessus. - persons: Personnes - persons-required: Personnes requises + 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: >- - Commencez par « Optimiser » pour obtenir une répartition mensuelle - cohérente. Ensuite, vous pouvez déplacer des coupons par glisser-déposer, - ajouter des mois ou supprimer des affectations fixes. + 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 - second-person-hint: Deuxième personne requise 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 {{ .Threshold - }} €. Dans ce cas, le montant mensuel entier est imposable, et pas seulement - la part au-dessus du seuil. + 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 {{ .Threshold }} € et n’est pas encore entièrement réparti - par mois. Dès que {{ .Threshold }} € sont dépassés, le montant mensuel - entier est imposable, et pas seulement la part au-dessus du seuil. + 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 est au maximum de {{ .Threshold }} €. Aucune imposition - supplémentaire ne s’applique. - threshold-above: >- - Le total dépasse le seuil d’exonération de {{ .Threshold }} €. Le montant - entier (pas seulement ce qui dépasse le seuil) est ajouté au revenu - imposable. - threshold-below: >- - Le total est inférieur au seuil d’exonération de {{ .Threshold }} €. Aucune - cotisation fiscale ou sociale supplémentaire ne s’applique. - threshold-excess: 'Montant dépassant le seuil :' - title: Valeurs imposables des Coupons FIP internationaux {{ .Year }} + 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: diff --git a/layouts/shortcodes/taxation-calculator.html b/layouts/shortcodes/taxation-calculator.html index f72b5d983..f734c19bd 100644 --- a/layouts/shortcodes/taxation-calculator.html +++ b/layouts/shortcodes/taxation-calculator.html @@ -166,7 +166,6 @@

data-i18n-other-placeholder="{{ T "taxation.other-placeholder" }}" data-i18n-highlight-important="{{ T "highlight.important" }}" data-i18n-person-limit="{{ T "taxation.person-limit" }}" - data-i18n-no-relatives-warning="{{ T "taxation.no-relatives-warning" }}" data-i18n-planning="{{ T "taxation.planning" }}" data-i18n-planning-description="{{ T "taxation.planning-description" }}" data-i18n-planning-focus="{{ T "taxation.planning-focus" }}" @@ -175,7 +174,8 @@

data-i18n-optimization-month="{{ T "taxation.optimization-month" }}" data-i18n-unassigned="{{ T "taxation.unassigned" }}" data-i18n-person="{{ T "taxation.person" }}" - data-i18n-persons="{{ T "taxation.persons" }}" + data-i18n-person-one="{{ T "taxation.person-one" }}" + data-i18n-person-other="{{ T "taxation.person-other" }}" data-i18n-unlock="{{ T "taxation.unlock" }}" data-i18n-person-limit-not-supported-warning="{{ T "taxation.person-limit-not-supported-warning" }}" data-i18n-summary-under-limit="{{ T "taxation.summary-under-limit" (dict "Threshold" $threshold) }}" @@ -183,9 +183,9 @@

data-i18n-summary-all-assigned-ok="{{ T "taxation.summary-all-assigned-ok" (dict "Threshold" $threshold) }}" data-i18n-summary-all-assigned-over-month="{{ T "taxation.summary-all-assigned-over-month" (dict "Threshold" $threshold) }}" > -

- {{ T "taxation.calculator-description" }} -

+
+ {{ T "taxation.calculator-description" | $.Page.RenderString }} +
From 079c7e5c2e0e7a20ddd3d626d0f6de6e4d34941a Mon Sep 17 00:00:00 2001 From: lennartrommeiss <61516567+lenderom@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:46:22 +0200 Subject: [PATCH 7/7] fix: cleanup --- assets/js/taxationCalculator.js | 112 ++++++++------------------------ assets/js/taxationInputs.js | 14 +--- assets/js/taxationPlanning.js | 21 +----- assets/js/taxationUtils.js | 40 ++++++++++++ assets/sass/taxation.scss | 102 ++++------------------------- 5 files changed, 84 insertions(+), 205 deletions(-) create mode 100644 assets/js/taxationUtils.js diff --git a/assets/js/taxationCalculator.js b/assets/js/taxationCalculator.js index 67f6aef92..e84dc5517 100644 --- a/assets/js/taxationCalculator.js +++ b/assets/js/taxationCalculator.js @@ -6,6 +6,13 @@ import { } 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; @@ -288,10 +295,6 @@ function saveState(state) { } } -function clampCounter(value) { - return Math.max(Number(value) || 0, 0); -} - function sanitizeState(state) { state.personLimit = Math.min( Math.max(Number(state.personLimit) || 1, 1), @@ -356,22 +359,6 @@ function getNationalState(state, key) { return state.national[key]; } -function formatCurrency(value) { - return ( - value - .toFixed(2) - .replace(".", ",") - .replace(/\B(?=(\d{3})+(?!\d))/g, ".") + "\u00A0\u20AC" - ); -} - -function normalizeText(text) { - return text - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, ""); -} - function calculateTotal(config, state) { let total = 0; @@ -433,18 +420,6 @@ function canIncreaseByClassLimit(stateObj, maxFields, field) { return stateObj.second < maxFields; } -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; -} - function createHighlight(type, rooflineLabel, iconName, content) { const wrapper = document.createElement("div"); wrapper.className = "m-text-highlight m-text-highlight--" + type; @@ -462,15 +437,6 @@ function createHighlight(type, rooflineLabel, iconName, content) { return wrapper; } -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; -} - function updateRowState( row, stateObj, @@ -1166,13 +1132,8 @@ function updateRowWarning( } } -function calculateTotalPersons(config, state) { - return state.personLimit; -} - function updateSummary(container, config, i18n, state) { const total = calculateTotal(config, state); - const totalPersons = calculateTotalPersons(config, state); const threshold = config.threshold; const summaryHint = getSummaryHint(config, state, i18n, total, threshold); @@ -1184,11 +1145,11 @@ function updateSummary(container, config, i18n, state) { thresholdEl: container.querySelector("[data-taxation-threshold-info]"), }, total, - totalPersons, + state.personLimit, summaryHint, ); - updateThresholdBlock( + renderThresholdInfo( container.querySelector("[data-taxation-mobile-threshold-detail]"), summaryHint, ); @@ -1236,43 +1197,7 @@ function updateRowMultipliers(row, effectivePersonLimit, i18n) { } } -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); - } - } - - if (!els.thresholdEl) return; - els.thresholdEl.innerHTML = ""; - - if (!summaryHint) { - els.thresholdEl.className = "o-taxation-calculator__threshold-info"; - return; - } - - els.thresholdEl.className = - "o-taxation-calculator__threshold-info a-tag " + - (summaryHint.kind === "warning" ? "a-tag--warning" : "a-tag--success"); - - els.thresholdEl.appendChild( - createIcon(summaryHint.kind === "warning" ? "warning" : "check_circle"), - ); - - var textEl = document.createElement("span"); - textEl.className = "o-taxation-calculator__threshold-text"; - textEl.textContent = summaryHint.text; - els.thresholdEl.appendChild(textEl); -} - -function updateThresholdBlock(el, summaryHint) { +function renderThresholdInfo(el, summaryHint) { if (!el) return; el.innerHTML = ""; @@ -1295,6 +1220,23 @@ function updateThresholdBlock(el, summaryHint) { 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) { diff --git a/assets/js/taxationInputs.js b/assets/js/taxationInputs.js index f405862ee..e9ac2d015 100644 --- a/assets/js/taxationInputs.js +++ b/assets/js/taxationInputs.js @@ -1,16 +1,6 @@ -const MAX_PERSONS = 10; +import { createIcon } from "./taxationUtils.js"; -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; -} +const MAX_PERSONS = 10; export function renderPersonLimitControl(ctx, saveState) { const { container, i18n, state } = ctx; diff --git a/assets/js/taxationPlanning.js b/assets/js/taxationPlanning.js index 075a6339e..bf325340a 100644 --- a/assets/js/taxationPlanning.js +++ b/assets/js/taxationPlanning.js @@ -1,23 +1,4 @@ -function formatCurrency(value) { - return ( - value - .toFixed(2) - .replace(".", ",") - .replace(/\B(?=(\d{3})+(?!\d))/g, ".") + "\u00A0\u20AC" - ); -} - -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; -} +import { formatCurrency, createIcon } from "./taxationUtils.js"; function getClassLabel(classKey, i18n) { return classKey === "first" ? i18n.firstClass : i18n.secondClass; 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/taxation.scss b/assets/sass/taxation.scss index 5d042b80e..3cd5711d0 100644 --- a/assets/sass/taxation.scss +++ b/assets/sass/taxation.scss @@ -99,10 +99,6 @@ margin-top: 1.2rem; } - &__optimization { - margin-top: 1.2rem; - } - &__planning { margin-top: 2rem; } @@ -266,12 +262,6 @@ } } - &__optimization-items-label { - font-size: 0.9em; - opacity: 0.8; - margin-bottom: 0.2rem; - } - &__optimization-month-items { margin: 0; padding: 0; @@ -373,6 +363,7 @@ margin: 0.3rem; gap: 0.4rem; padding: 0.45rem 0.5rem; + touch-action: none; } &__optimization-item-text { @@ -391,6 +382,10 @@ width: 2.4rem; min-width: 2.4rem; } + + &__optimization-drag-handle { + touch-action: none; + } } &__optimization-item--dragging { @@ -606,13 +601,6 @@ opacity: 0.8; } - &__class-hint { - font-size: 0.85em; - font-style: italic; - width: var(--taxation-controls-width); - text-align: center; - } - &__row-total { font-weight: bold; font-size: 1.2em; @@ -709,17 +697,6 @@ padding-left: 0; } - &__persons-wrapper { - display: flex; - align-items: center; - justify-content: flex-end; - } - - &__persons { - display: flex; - gap: 0.1rem; - } - &__person { font-size: 2rem; color: var(--link-default); @@ -737,13 +714,13 @@ } } - &__other-input { + &__other-input, + &__search-input { width: 100%; - max-width: 100%; box-sizing: border-box; padding: 0.8rem 1.2rem; border: var(--border-visible); - border-radius: var(--border-radius-s); + border-radius: var(--border-radius-m); background: var(--bg-default); color: var(--color-body); font-size: 1em; @@ -760,6 +737,11 @@ } } + &__other-input { + max-width: 100%; + border-radius: var(--border-radius-s); + } + &__summary { padding: 1.6rem; border: 0.2rem solid var(--link-default); @@ -819,15 +801,6 @@ &__sidebar &__sum-value { order: 1; } - - &__sidebar &__sum-persons { - order: 2; - } - } - - &__sum-persons { - display: flex; - gap: 0.1rem; } &__threshold-info { @@ -847,39 +820,11 @@ } } - &__threshold-excess { - margin-top: 0.4rem; - font-weight: bold; - font-size: 1.05em; - } - &__search-select { position: relative; margin-top: 0.4rem; } - &__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; - } - } - &__search-list { display: none; position: absolute; @@ -1156,18 +1101,9 @@ color: var(--tag-success-color); } - &__mobile-persons { - display: flex; - gap: 0.1rem; - - .o-taxation-calculator__person { - font-size: 1.6rem; - } - } - &__mobile-content { background: var(--bg-default); - border-top: 1px solid #3d444d; + border-top: var(--border-visible); overflow-y: auto; overscroll-behavior-y: contain; padding: 1.5rem; @@ -1186,14 +1122,4 @@ transition: opacity 0.3s ease-in-out; } } - - @media (max-width: $breakpoint-lg) { - &__optimization-item { - touch-action: none; - } - - &__optimization-drag-handle { - touch-action: none; - } - } }